mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-28 02:31:05 -07:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c60f743f8 | |||
| 79753ec5ec | |||
| e151b4890b | |||
| e6b43cb846 | |||
| 2ae5e1ae20 | |||
| 67d9b76e81 | |||
| 4a3c7ae8b7 | |||
| bd2744031f | |||
| 92a5f725a1 | |||
| 4494f9e062 | |||
| ece001f264 | |||
| d3cfbdb3b7 | |||
| 7a132512cf | |||
| 6dae3a5402 | |||
| 0a3ecf3a75 | |||
| 9081743a7f | |||
| 89d4556c45 | |||
| 5d0570b113 | |||
| eb94284256 | |||
| cc2798018b | |||
| c9f9a7f67a | |||
| fd65416a2f | |||
| bce1caefd0 | |||
| 80057c5208 | |||
| 14219bb57d | |||
| 80e9fd51eb | |||
| ef957a368d | |||
| a3e5b564f7 | |||
| 1b98c1f806 | |||
| 9e41b2cd89 | |||
| 3fb1937247 | |||
| 07056c8f16 | |||
| 08dca3e1d6 | |||
| bcc0f27594 | |||
| 262e8384d4 | |||
| e73288f25f | |||
| aebc107d2c | |||
| fca27bd4a4 |
@@ -2,6 +2,10 @@
|
||||
"experimental": {
|
||||
"toolOutputMasking": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"plan": true
|
||||
},
|
||||
"general": {
|
||||
"devtools": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,25 +14,34 @@ repository's standards.
|
||||
|
||||
Follow these steps to create a Pull Request:
|
||||
|
||||
1. **Branch Management**: Check the current branch to avoid working directly
|
||||
on `main`.
|
||||
1. **Branch Management**: **CRITICAL:** Ensure you are NOT working on the
|
||||
`main` branch.
|
||||
- Run `git branch --show-current`.
|
||||
- If the current branch is `main`, create and switch to a new descriptive
|
||||
branch:
|
||||
- If the current branch is `main`, you MUST create and switch to a new
|
||||
descriptive branch:
|
||||
```bash
|
||||
git checkout -b <new-branch-name>
|
||||
```
|
||||
|
||||
2. **Locate Template**: Search for a pull request template in the repository.
|
||||
2. **Commit Changes**: Verify that all intended changes are committed.
|
||||
- Run `git status` to check for unstaged or uncommitted changes.
|
||||
- If there are uncommitted changes, stage and commit them with a descriptive
|
||||
message before proceeding. NEVER commit directly to `main`.
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "type(scope): description"
|
||||
```
|
||||
|
||||
3. **Locate Template**: Search for a pull request template in the repository.
|
||||
- Check `.github/pull_request_template.md`
|
||||
- Check `.github/PULL_REQUEST_TEMPLATE.md`
|
||||
- If multiple templates exist (e.g., in `.github/PULL_REQUEST_TEMPLATE/`),
|
||||
ask the user which one to use or select the most appropriate one based on
|
||||
the context (e.g., `bug_fix.md` vs `feature.md`).
|
||||
|
||||
3. **Read Template**: Read the content of the identified template file.
|
||||
4. **Read Template**: Read the content of the identified template file.
|
||||
|
||||
4. **Draft Description**: Create a PR description that strictly follows the
|
||||
5. **Draft Description**: Create a PR description that strictly follows the
|
||||
template's structure.
|
||||
- **Headings**: Keep all headings from the template.
|
||||
- **Checklists**: Review each item. Mark with `[x]` if completed. If an item
|
||||
@@ -44,14 +53,24 @@ Follow these steps to create a Pull Request:
|
||||
- **Related Issues**: Link any issues fixed or related to this PR (e.g.,
|
||||
"Fixes #123").
|
||||
|
||||
5. **Preflight Check**: Before creating the PR, run the workspace preflight
|
||||
6. **Preflight Check**: Before creating the PR, run the workspace preflight
|
||||
script to ensure all build, lint, and test checks pass.
|
||||
```bash
|
||||
npm run preflight
|
||||
```
|
||||
If any checks fail, address the issues before proceeding to create the PR.
|
||||
|
||||
6. **Create PR**: Use the `gh` CLI to create the PR. To avoid shell escaping
|
||||
7. **Push Branch**: Push the current branch to the remote repository.
|
||||
**CRITICAL SAFETY RAIL:** Double-check your branch name before pushing.
|
||||
NEVER push if the current branch is `main`.
|
||||
```bash
|
||||
# Verify current branch is NOT main
|
||||
git branch --show-current
|
||||
# Push non-interactively
|
||||
git push -u origin HEAD
|
||||
```
|
||||
|
||||
8. **Create PR**: Use the `gh` CLI to create the PR. To avoid shell escaping
|
||||
issues with multi-line Markdown, write the description to a temporary file
|
||||
first.
|
||||
```bash
|
||||
@@ -68,6 +87,7 @@ Follow these steps to create a Pull Request:
|
||||
|
||||
## Principles
|
||||
|
||||
- **Safety First**: NEVER push to `main`. This is your highest priority.
|
||||
- **Compliance**: Never ignore the PR template. It exists for a reason.
|
||||
- **Completeness**: Fill out all relevant sections.
|
||||
- **Accuracy**: Don't check boxes for tasks you haven't done.
|
||||
|
||||
@@ -4,7 +4,7 @@ name: 'Generate Release Notes'
|
||||
|
||||
on:
|
||||
release:
|
||||
types: ['created']
|
||||
types: ['published']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
|
||||
@@ -106,6 +106,7 @@ available combinations.
|
||||
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
|
||||
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |
|
||||
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`<br />`Ctrl + S` |
|
||||
| Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl + O` |
|
||||
| Toggle current background shell visibility. | `Ctrl + B` |
|
||||
| Toggle background shell list. | `Ctrl + L` |
|
||||
| Kill the active background shell. | `Ctrl + K` |
|
||||
@@ -139,6 +140,7 @@ available combinations.
|
||||
single-line input, navigate backward or forward through prompt history.
|
||||
- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to
|
||||
the numbered radio option and confirm when the full number is entered.
|
||||
- `Double-click` on a paste placeholder (`[Pasted Text: X lines]`) in alternate
|
||||
buffer mode: Expand to view full content inline. Double-click again to
|
||||
collapse.
|
||||
- `Ctrl + O`: Expand or collapse paste placeholders (`[Pasted Text: X lines]`)
|
||||
inline when the cursor is over the placeholder.
|
||||
- `Double-click` on a paste placeholder (alternate buffer mode only): Expand to
|
||||
view full content inline. Double-click again to collapse.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Plan Mode (experimental) <!-- omit in toc -->
|
||||
# Plan Mode (experimental)
|
||||
|
||||
Plan Mode is a safe, read-only mode for researching and designing complex
|
||||
changes. It prevents modifications while you research, design and plan an
|
||||
@@ -36,7 +36,7 @@ implementation strategy.
|
||||
You can configure Gemini CLI to start directly in Plan Mode by default:
|
||||
|
||||
1. Type `/settings` in the CLI.
|
||||
2. Search for `Approval Mode`.
|
||||
2. Search for `Default Approval Mode`.
|
||||
3. Set the value to `Plan`.
|
||||
|
||||
Other ways to start in Plan Mode:
|
||||
@@ -46,8 +46,8 @@ Other ways to start in Plan Mode:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"approvalMode": "plan"
|
||||
"general": {
|
||||
"defaultApprovalMode": "plan"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
+16
-15
@@ -22,13 +22,14 @@ they appear in the UI.
|
||||
|
||||
### General
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------ | ---------------------------------- | ------------------------------------------------------------- | ------- |
|
||||
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
|
||||
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
|
||||
| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
|
||||
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
|
||||
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
|
||||
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
|
||||
| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
|
||||
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
|
||||
| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
|
||||
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
|
||||
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
|
||||
|
||||
### Output
|
||||
|
||||
@@ -43,6 +44,7 @@ they appear in the UI.
|
||||
| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
|
||||
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
|
||||
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
|
||||
| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` |
|
||||
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
|
||||
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
|
||||
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
||||
@@ -95,14 +97,13 @@ they appear in the UI.
|
||||
|
||||
### Tools
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
|
||||
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
|
||||
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
|
||||
| Approval Mode | `tools.approvalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
|
||||
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
|
||||
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation. | `40000` |
|
||||
| Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `true` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
|
||||
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
|
||||
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
|
||||
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation. | `40000` |
|
||||
| Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `true` |
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -106,6 +106,17 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Enable Vim keybindings
|
||||
- **Default:** `false`
|
||||
|
||||
- **`general.defaultApprovalMode`** (enum):
|
||||
- **Description:** The default approval mode for tool execution. 'default'
|
||||
prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is
|
||||
read-only mode. 'yolo' is not supported yet.
|
||||
- **Default:** `"default"`
|
||||
- **Values:** `"default"`, `"auto_edit"`, `"plan"`
|
||||
|
||||
- **`general.devtools`** (boolean):
|
||||
- **Description:** Enable DevTools inspector on launch.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`general.enableAutoUpdate`** (boolean):
|
||||
- **Description:** Enable automatic updates.
|
||||
- **Default:** `true`
|
||||
@@ -184,6 +195,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`ui.inlineThinkingMode`** (enum):
|
||||
- **Description:** Display model thinking inline: off or full.
|
||||
- **Default:** `"off"`
|
||||
- **Values:** `"off"`, `"full"`
|
||||
|
||||
- **`ui.showStatusInTitle`** (boolean):
|
||||
- **Description:** Show Gemini CLI model thoughts in the terminal window title
|
||||
during the working phase
|
||||
@@ -672,13 +688,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
performance.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`tools.approvalMode`** (enum):
|
||||
- **Description:** The default approval mode for tool execution. 'default'
|
||||
prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is
|
||||
read-only mode. 'yolo' is not supported yet.
|
||||
- **Default:** `"default"`
|
||||
- **Values:** `"default"`, `"auto_edit"`, `"plan"`
|
||||
|
||||
- **`tools.core`** (array):
|
||||
- **Description:** Restrict the set of built-in tools with an allowlist. Match
|
||||
semantics mirror tools.allowed; see the built-in tools documentation for
|
||||
|
||||
@@ -63,6 +63,7 @@ const external = [
|
||||
'@lydell/node-pty-win32-arm64',
|
||||
'@lydell/node-pty-win32-x64',
|
||||
'keytar',
|
||||
'gemini-cli-devtools',
|
||||
];
|
||||
|
||||
const baseConfig = {
|
||||
|
||||
@@ -192,6 +192,14 @@ export default tseslint.config(
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Rules that only apply to product code
|
||||
files: ['packages/*/src/**/*.{ts,tsx}'],
|
||||
ignores: ['**/*.test.ts', '**/*.test.tsx'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unsafe-type-assertion': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Allow os.homedir() in tests and paths.ts where it is used to implement the helper
|
||||
files: [
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
import {
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from '../integration-tests/test-helper.js';
|
||||
|
||||
describe('Hierarchical Memory', () => {
|
||||
const TEST_PREFIX = 'Hierarchical memory test: ';
|
||||
|
||||
const conflictResolutionTest =
|
||||
'Agent follows hierarchy for contradictory instructions';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: conflictResolutionTest,
|
||||
params: {
|
||||
settings: {
|
||||
security: {
|
||||
folderTrust: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
// We simulate the hierarchical memory by including the tags in the prompt
|
||||
// since setting up real global/extension/project files in the eval rig is complex.
|
||||
// The system prompt logic will append these tags when it finds them in userMemory.
|
||||
prompt: `
|
||||
<global_context>
|
||||
When asked for my favorite fruit, always say "Apple".
|
||||
</global_context>
|
||||
|
||||
<extension_context>
|
||||
When asked for my favorite fruit, always say "Banana".
|
||||
</extension_context>
|
||||
|
||||
<project_context>
|
||||
When asked for my favorite fruit, always say "Cherry".
|
||||
</project_context>
|
||||
|
||||
What is my favorite fruit? Tell me just the name of the fruit.`,
|
||||
assert: async (_rig, result) => {
|
||||
assertModelHasOutput(result);
|
||||
expect(result).toMatch(/Cherry/i);
|
||||
expect(result).not.toMatch(/Apple/i);
|
||||
expect(result).not.toMatch(/Banana/i);
|
||||
},
|
||||
});
|
||||
|
||||
const provenanceAwarenessTest = 'Agent is aware of memory provenance';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: provenanceAwarenessTest,
|
||||
params: {
|
||||
settings: {
|
||||
security: {
|
||||
folderTrust: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: `
|
||||
<global_context>
|
||||
Instruction A: Always be helpful.
|
||||
</global_context>
|
||||
|
||||
<extension_context>
|
||||
Instruction B: Use a professional tone.
|
||||
</extension_context>
|
||||
|
||||
<project_context>
|
||||
Instruction C: Adhere to the project's coding style.
|
||||
</project_context>
|
||||
|
||||
Which instruction came from the global context, which from the extension context, and which from the project context?
|
||||
Provide the answer as an XML block like this:
|
||||
<results>
|
||||
<global>Instruction ...</global>
|
||||
<extension>Instruction ...</extension>
|
||||
<project>Instruction ...</project>
|
||||
</results>`,
|
||||
assert: async (_rig, result) => {
|
||||
assertModelHasOutput(result);
|
||||
expect(result).toMatch(/<global>.*Instruction A/i);
|
||||
expect(result).toMatch(/<extension>.*Instruction B/i);
|
||||
expect(result).toMatch(/<project>.*Instruction C/i);
|
||||
},
|
||||
});
|
||||
|
||||
const extensionVsGlobalTest = 'Extension memory wins over Global memory';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: extensionVsGlobalTest,
|
||||
params: {
|
||||
settings: {
|
||||
security: {
|
||||
folderTrust: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: `
|
||||
<global_context>
|
||||
Set the theme to "Light".
|
||||
</global_context>
|
||||
|
||||
<extension_context>
|
||||
Set the theme to "Dark".
|
||||
</extension_context>
|
||||
|
||||
What theme should I use?`,
|
||||
assert: async (_rig, result) => {
|
||||
assertModelHasOutput(result);
|
||||
expect(result).toMatch(/Dark/i);
|
||||
expect(result).not.toMatch(/Light/i);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('Shell Efficiency', () => {
|
||||
const getCommand = (call: any): string | undefined => {
|
||||
let args = call.toolRequest.args;
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
args = JSON.parse(args);
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
return typeof args === 'string' ? args : (args as any)['command'];
|
||||
};
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should use --silent/--quiet flags when installing packages',
|
||||
prompt: 'Install the "lodash" package using npm.',
|
||||
assert: async (rig) => {
|
||||
const toolCalls = rig.readToolLogs();
|
||||
const shellCalls = toolCalls.filter(
|
||||
(call) => call.toolRequest.name === 'run_shell_command',
|
||||
);
|
||||
|
||||
const hasEfficiencyFlag = shellCalls.some((call) => {
|
||||
const cmd = getCommand(call);
|
||||
return (
|
||||
cmd &&
|
||||
cmd.includes('npm install') &&
|
||||
(cmd.includes('--silent') ||
|
||||
cmd.includes('--quiet') ||
|
||||
cmd.includes('-q'))
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
hasEfficiencyFlag,
|
||||
`Expected agent to use efficiency flags for npm install. Commands used: ${shellCalls
|
||||
.map(getCommand)
|
||||
.join(', ')}`,
|
||||
).toBe(true);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should use --no-pager with git commands',
|
||||
prompt: 'Show the git log.',
|
||||
assert: async (rig) => {
|
||||
const toolCalls = rig.readToolLogs();
|
||||
const shellCalls = toolCalls.filter(
|
||||
(call) => call.toolRequest.name === 'run_shell_command',
|
||||
);
|
||||
|
||||
const hasNoPager = shellCalls.some((call) => {
|
||||
const cmd = getCommand(call);
|
||||
return cmd && cmd.includes('git') && cmd.includes('--no-pager');
|
||||
});
|
||||
|
||||
expect(
|
||||
hasNoPager,
|
||||
`Expected agent to use --no-pager with git. Commands used: ${shellCalls
|
||||
.map(getCommand)
|
||||
.join(', ')}`,
|
||||
).toBe(true);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should NOT use efficiency flags when enableShellOutputEfficiency is disabled',
|
||||
params: {
|
||||
settings: {
|
||||
tools: {
|
||||
shell: {
|
||||
enableShellOutputEfficiency: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: 'Install the "lodash" package using npm.',
|
||||
assert: async (rig) => {
|
||||
const toolCalls = rig.readToolLogs();
|
||||
const shellCalls = toolCalls.filter(
|
||||
(call) => call.toolRequest.name === 'run_shell_command',
|
||||
);
|
||||
|
||||
const hasEfficiencyFlag = shellCalls.some((call) => {
|
||||
const cmd = getCommand(call);
|
||||
return (
|
||||
cmd &&
|
||||
cmd.includes('npm install') &&
|
||||
(cmd.includes('--silent') ||
|
||||
cmd.includes('--quiet') ||
|
||||
cmd.includes('-q'))
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
hasEfficiencyFlag,
|
||||
'Agent used efficiency flags even though enableShellOutputEfficiency was disabled',
|
||||
).toBe(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -20,5 +20,8 @@ export default defineConfig({
|
||||
maxThreads: 16,
|
||||
},
|
||||
},
|
||||
env: {
|
||||
GEMINI_TEST_TYPE: 'integration',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Generated
+13
@@ -76,6 +76,7 @@
|
||||
"@lydell/node-pty-linux-x64": "1.1.0",
|
||||
"@lydell/node-pty-win32-arm64": "1.1.0",
|
||||
"@lydell/node-pty-win32-x64": "1.1.0",
|
||||
"gemini-cli-devtools": "^0.2.1",
|
||||
"keytar": "^7.9.0",
|
||||
"node-pty": "^1.0.0"
|
||||
}
|
||||
@@ -9605,6 +9606,18 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/gemini-cli-devtools": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/gemini-cli-devtools/-/gemini-cli-devtools-0.2.1.tgz",
|
||||
"integrity": "sha512-PcqPL9ZZjgjsp3oYhcXnUc6yNeLvdZuU/UQp0aT+DA8pt3BZzPzXthlOmIrRRqHBdLjMLPwN5GD29zR5bASXtQ==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"ws": "^8.16.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/gemini-cli-vscode-ide-companion": {
|
||||
"resolved": "packages/vscode-ide-companion",
|
||||
"link": true
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
"@lydell/node-pty-linux-x64": "1.1.0",
|
||||
"@lydell/node-pty-win32-arm64": "1.1.0",
|
||||
"@lydell/node-pty-win32-x64": "1.1.0",
|
||||
"gemini-cli-devtools": "^0.2.1",
|
||||
"keytar": "^7.9.0",
|
||||
"node-pty": "^1.0.0"
|
||||
},
|
||||
|
||||
@@ -117,6 +117,7 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
const agentSettings = persistedState._agentSettings;
|
||||
const config = await this.getConfig(agentSettings, sdkTask.id);
|
||||
const contextId: string =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(metadata['_contextId'] as string) || sdkTask.contextId;
|
||||
const runtimeTask = await Task.create(
|
||||
sdkTask.id,
|
||||
@@ -140,6 +141,7 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
agentSettingsInput?: AgentSettings,
|
||||
eventBus?: ExecutionEventBus,
|
||||
): Promise<TaskWrapper> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const agentSettings = agentSettingsInput || ({} as AgentSettings);
|
||||
const config = await this.getConfig(agentSettings, taskId);
|
||||
const runtimeTask = await Task.create(
|
||||
@@ -290,6 +292,7 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
const contextId: string =
|
||||
userMessage.contextId ||
|
||||
sdkTask?.contextId ||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(sdkTask?.metadata?.['_contextId'] as string) ||
|
||||
uuidv4();
|
||||
|
||||
@@ -385,6 +388,7 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
}
|
||||
} else {
|
||||
logger.info(`[CoderAgentExecutor] Creating new task ${taskId}.`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const agentSettings = userMessage.metadata?.[
|
||||
'coderAgent'
|
||||
] as AgentSettings;
|
||||
|
||||
@@ -378,6 +378,7 @@ export class Task {
|
||||
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
|
||||
this.pendingToolConfirmationDetails.set(
|
||||
tc.request.callId,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
tc.confirmationDetails as ToolCallConfirmationDetails,
|
||||
);
|
||||
}
|
||||
@@ -411,7 +412,7 @@ export class Task {
|
||||
);
|
||||
toolCalls.forEach((tc: ToolCall) => {
|
||||
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises, @typescript-eslint/no-unsafe-type-assertion
|
||||
(tc.confirmationDetails as ToolCallConfirmationDetails).onConfirm(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
);
|
||||
@@ -465,12 +466,14 @@ export class Task {
|
||||
T extends ToolCall | AnyDeclarativeTool,
|
||||
K extends UnionKeys<T>,
|
||||
>(from: T, ...fields: K[]): Partial<T> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const ret = {} as Pick<T, K>;
|
||||
for (const field of fields) {
|
||||
if (field in from) {
|
||||
ret[field] = from[field];
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return ret as Partial<T>;
|
||||
}
|
||||
|
||||
@@ -493,6 +496,7 @@ export class Task {
|
||||
);
|
||||
|
||||
if (tc.tool) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
serializableToolCall.tool = this._pickFields(
|
||||
tc.tool,
|
||||
'name',
|
||||
@@ -622,8 +626,11 @@ export class Task {
|
||||
request.args['new_string']
|
||||
) {
|
||||
const newContent = await this.getProposedContent(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
request.args['file_path'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
request.args['old_string'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
request.args['new_string'] as string,
|
||||
);
|
||||
return { ...request, args: { ...request.args, newContent } };
|
||||
@@ -719,6 +726,7 @@ export class Task {
|
||||
case GeminiEventType.Error:
|
||||
default: {
|
||||
// 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';
|
||||
@@ -807,6 +815,7 @@ export class Task {
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
const payload = part.data['newContent']
|
||||
? ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
newContent: part.data['newContent'] as string,
|
||||
} as ToolConfirmationPayload)
|
||||
: undefined;
|
||||
|
||||
@@ -85,6 +85,7 @@ export class InitCommand implements Command {
|
||||
if (!context.agentExecutor) {
|
||||
throw new Error('Agent executor not found in context.');
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const agentExecutor = context.agentExecutor as CoderAgentExecutor;
|
||||
|
||||
const agentSettings: AgentSettings = {
|
||||
|
||||
@@ -41,9 +41,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
};
|
||||
return mockConfig;
|
||||
}),
|
||||
loadServerHierarchicalMemory: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ memoryContent: '', fileCount: 0, filePaths: [] }),
|
||||
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
|
||||
memoryContent: { global: '', extension: '', project: '' },
|
||||
fileCount: 0,
|
||||
filePaths: [],
|
||||
}),
|
||||
startupProfiler: {
|
||||
flush: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -77,6 +77,7 @@ export async function loadConfig(
|
||||
cwd: workspaceDir,
|
||||
telemetry: {
|
||||
enabled: settings.telemetry?.enabled,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
target: settings.telemetry?.target as TelemetryTarget,
|
||||
otlpEndpoint:
|
||||
process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ??
|
||||
|
||||
@@ -93,6 +93,7 @@ function loadExtension(extensionDir: string): GeminiCLIExtension | null {
|
||||
|
||||
try {
|
||||
const configContent = fs.readFileSync(configFilePath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const config = JSON.parse(configContent) as ExtensionConfig;
|
||||
if (!config.name || !config.version) {
|
||||
logger.error(
|
||||
@@ -107,6 +108,7 @@ function loadExtension(extensionDir: string): GeminiCLIExtension | null {
|
||||
.map((contextFileName) => path.join(extensionDir, contextFileName))
|
||||
.filter((contextFilePath) => fs.existsSync(contextFilePath));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
name: config.name,
|
||||
version: config.version,
|
||||
@@ -140,6 +142,7 @@ export function loadInstallMetadata(
|
||||
const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME);
|
||||
try {
|
||||
const configContent = fs.readFileSync(metadataFilePath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const metadata = JSON.parse(configContent) as ExtensionInstallMetadata;
|
||||
return metadata;
|
||||
} catch (e) {
|
||||
|
||||
@@ -67,6 +67,7 @@ export function loadSettings(workspaceDir: string): Settings {
|
||||
try {
|
||||
if (fs.existsSync(USER_SETTINGS_PATH)) {
|
||||
const userContent = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const parsedUserSettings = JSON.parse(
|
||||
stripJsonComments(userContent),
|
||||
) as Settings;
|
||||
@@ -89,6 +90,7 @@ export function loadSettings(workspaceDir: string): Settings {
|
||||
try {
|
||||
if (fs.existsSync(workspaceSettingsPath)) {
|
||||
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const parsedWorkspaceSettings = JSON.parse(
|
||||
stripJsonComments(projectContent),
|
||||
) as Settings;
|
||||
@@ -139,10 +141,12 @@ function resolveEnvVarsInObject<T>(obj: T): T {
|
||||
}
|
||||
|
||||
if (typeof obj === 'string') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return resolveEnvVarsInString(obj) as unknown as T;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return obj.map((item) => resolveEnvVarsInObject(item)) as unknown as T;
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ async function handleExecuteCommand(
|
||||
const eventHandler = (event: AgentExecutionEvent) => {
|
||||
const jsonRpcResponse = {
|
||||
jsonrpc: '2.0',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
id: 'taskId' in event ? event.taskId : (event as Message).messageId,
|
||||
result: event,
|
||||
};
|
||||
@@ -206,6 +207,7 @@ export async function createApp() {
|
||||
expressApp.post('/tasks', async (req, res) => {
|
||||
try {
|
||||
const taskId = uuidv4();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const agentSettings = req.body.agentSettings as
|
||||
| AgentSettings
|
||||
| undefined;
|
||||
|
||||
@@ -95,6 +95,7 @@ export class GCSTaskStore implements TaskStore {
|
||||
await this.ensureBucketInitialized();
|
||||
const taskId = task.id;
|
||||
const persistedState = getPersistedState(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
task.metadata as PersistedTaskMetadata,
|
||||
);
|
||||
|
||||
|
||||
@@ -125,6 +125,7 @@ export const METADATA_KEY = '__persistedState';
|
||||
export function getPersistedState(
|
||||
metadata: PersistedTaskMetadata,
|
||||
): PersistedStateMetadata | undefined {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return metadata?.[METADATA_KEY] as PersistedStateMetadata | undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import type {
|
||||
Task as SDKTask,
|
||||
TaskStatusUpdateEvent,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
GeminiClient,
|
||||
HookSystem,
|
||||
PolicyDecision,
|
||||
tmpdir,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
|
||||
import type { Config, Storage } from '@google/gemini-cli-core';
|
||||
@@ -24,6 +26,8 @@ import { expect, vi } from 'vitest';
|
||||
export function createMockConfig(
|
||||
overrides: Partial<Config> = {},
|
||||
): Partial<Config> {
|
||||
const tmpDir = tmpdir();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const mockConfig = {
|
||||
getToolRegistry: vi.fn().mockReturnValue({
|
||||
getTool: vi.fn(),
|
||||
@@ -38,11 +42,12 @@ export function createMockConfig(
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
isPathWithinWorkspace: () => true,
|
||||
}),
|
||||
getTargetDir: () => '/test',
|
||||
getTargetDir: () => tmpDir,
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
storage: {
|
||||
getProjectTempDir: () => '/tmp',
|
||||
getProjectTempCheckpointsDir: () => '/tmp/checkpoints',
|
||||
getProjectTempDir: () => tmpDir,
|
||||
getProjectTempCheckpointsDir: () => path.join(tmpDir, 'checkpoints'),
|
||||
} as Storage,
|
||||
getTruncateToolOutputThreshold: () =>
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
@@ -145,6 +150,7 @@ export function assertUniqueFinalEventIsLast(
|
||||
events: SendStreamingMessageSuccessResponse[],
|
||||
) {
|
||||
// Final event is input-required & final
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const finalEvent = events[events.length - 1].result as TaskStatusUpdateEvent;
|
||||
expect(finalEvent.metadata?.['coderAgent']).toMatchObject({
|
||||
kind: 'state-change',
|
||||
@@ -154,9 +160,11 @@ export function assertUniqueFinalEventIsLast(
|
||||
|
||||
// There is only one event with final and its the last
|
||||
expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
events.filter((e) => (e.result as TaskStatusUpdateEvent).final).length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
events.findIndex((e) => (e.result as TaskStatusUpdateEvent).final),
|
||||
).toBe(events.length - 1);
|
||||
}
|
||||
@@ -165,11 +173,13 @@ export function assertTaskCreationAndWorkingStatus(
|
||||
events: SendStreamingMessageSuccessResponse[],
|
||||
) {
|
||||
// Initial task creation event
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const taskEvent = events[0].result as SDKTask;
|
||||
expect(taskEvent.kind).toBe('task');
|
||||
expect(taskEvent.status.state).toBe('submitted');
|
||||
|
||||
// Status update: working
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const workingEvent = events[1].result as TaskStatusUpdateEvent;
|
||||
expect(workingEvent.kind).toBe('status-update');
|
||||
expect(workingEvent.status.state).toBe('working');
|
||||
|
||||
@@ -71,6 +71,7 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
|
||||
extensionManager,
|
||||
name,
|
||||
setting,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope as ExtensionSettingScope,
|
||||
);
|
||||
}
|
||||
@@ -79,6 +80,7 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
|
||||
await configureExtension(
|
||||
extensionManager,
|
||||
name,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope as ExtensionSettingScope,
|
||||
);
|
||||
}
|
||||
@@ -86,6 +88,7 @@ export const configureCommand: CommandModule<object, ConfigureArgs> = {
|
||||
else {
|
||||
await configureAllExtensions(
|
||||
extensionManager,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope as ExtensionSettingScope,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,7 +79,9 @@ export const disableCommand: CommandModule = {
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleDisable({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
name: argv['name'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope: argv['scope'] as string,
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -105,7 +105,9 @@ export const enableCommand: CommandModule = {
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleEnable({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
name: argv['name'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope: argv['scope'] as string,
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -99,10 +99,15 @@ export const installCommand: CommandModule = {
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleInstall({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
source: argv['source'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
ref: argv['ref'] as string | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
autoUpdate: argv['auto-update'] as boolean | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
allowPreRelease: argv['pre-release'] as boolean | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
consent: argv['consent'] as boolean | undefined,
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -79,7 +79,9 @@ export const linkCommand: CommandModule = {
|
||||
.check((_) => true),
|
||||
handler: async (argv) => {
|
||||
await handleLink({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
path: argv['path'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
consent: argv['consent'] as boolean | undefined,
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -62,6 +62,7 @@ export const listCommand: CommandModule = {
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleList({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
outputFormat: argv['output-format'] as 'text' | 'json',
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -98,7 +98,9 @@ export const newCommand: CommandModule = {
|
||||
},
|
||||
handler: async (args) => {
|
||||
await handleNew({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
path: args['path'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
template: args['template'] as string | undefined,
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -71,6 +71,7 @@ export const uninstallCommand: CommandModule = {
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleUninstall({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
names: argv['names'] as string[],
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -155,7 +155,9 @@ export const updateCommand: CommandModule = {
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleUpdate({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
name: argv['name'] as string | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
all: argv['all'] as boolean | undefined,
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -100,6 +100,7 @@ export const validateCommand: CommandModule = {
|
||||
}),
|
||||
handler: async (args) => {
|
||||
await handleValidate({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
path: args['path'] as string,
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -70,6 +70,7 @@ function migrateClaudeHook(claudeHook: unknown): unknown {
|
||||
return claudeHook;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const hook = claudeHook as Record<string, unknown>;
|
||||
const migrated: Record<string, unknown> = {};
|
||||
|
||||
@@ -107,10 +108,12 @@ function migrateClaudeHooks(claudeConfig: unknown): Record<string, unknown> {
|
||||
return {};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const config = claudeConfig as Record<string, unknown>;
|
||||
const geminiHooks: Record<string, unknown> = {};
|
||||
|
||||
// Check if there's a hooks section
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const hooksSection = config['hooks'] as Record<string, unknown> | undefined;
|
||||
if (!hooksSection || typeof hooksSection !== 'object') {
|
||||
return {};
|
||||
@@ -130,6 +133,7 @@ function migrateClaudeHooks(claudeConfig: unknown): Record<string, unknown> {
|
||||
return def;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const definition = def as Record<string, unknown>;
|
||||
const migratedDef: Record<string, unknown> = {};
|
||||
|
||||
@@ -179,6 +183,7 @@ export async function handleMigrateFromClaude() {
|
||||
sourceFile = claudeLocalSettingsPath;
|
||||
try {
|
||||
const content = fs.readFileSync(claudeLocalSettingsPath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
claudeSettings = JSON.parse(stripJsonComments(content)) as Record<
|
||||
string,
|
||||
unknown
|
||||
@@ -192,6 +197,7 @@ export async function handleMigrateFromClaude() {
|
||||
sourceFile = claudeSettingsPath;
|
||||
try {
|
||||
const content = fs.readFileSync(claudeSettingsPath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
claudeSettings = JSON.parse(stripJsonComments(content)) as Record<
|
||||
string,
|
||||
unknown
|
||||
@@ -259,6 +265,7 @@ export const migrateCommand: CommandModule = {
|
||||
default: false,
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const args = argv as unknown as MigrateArgs;
|
||||
if (args.fromClaude) {
|
||||
await handleMigrateFromClaude();
|
||||
|
||||
@@ -219,24 +219,38 @@ export const addCommand: CommandModule = {
|
||||
.middleware((argv) => {
|
||||
// Handle -- separator args as server args if present
|
||||
if (argv['--']) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const existingArgs = (argv['args'] as Array<string | number>) || [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
argv['args'] = [...existingArgs, ...(argv['--'] as string[])];
|
||||
}
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await addMcpServer(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
argv['name'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
argv['commandOrUrl'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
argv['args'] as Array<string | number>,
|
||||
{
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope: argv['scope'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
transport: argv['transport'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
env: argv['env'] as string[],
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
header: argv['header'] as string[],
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
timeout: argv['timeout'] as number | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
trust: argv['trust'] as boolean | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
description: argv['description'] as string | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
includeTools: argv['includeTools'] as string[] | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
excludeTools: argv['excludeTools'] as string[] | undefined,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -55,7 +55,9 @@ export const removeCommand: CommandModule = {
|
||||
choices: ['user', 'project'],
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
await removeMcpServer(argv['name'] as string, {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope: argv['scope'] as string,
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -53,6 +53,7 @@ export const disableCommand: CommandModule = {
|
||||
? SettingScope.Workspace
|
||||
: SettingScope.User;
|
||||
await handleDisable({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
name: argv['name'] as string,
|
||||
scope,
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ export const enableCommand: CommandModule = {
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleEnable({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
name: argv['name'] as string,
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -102,9 +102,13 @@ export const installCommand: CommandModule = {
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleInstall({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
source: argv['source'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope: argv['scope'] as 'user' | 'workspace',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
path: argv['path'] as string | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
consent: argv['consent'] as boolean | undefined,
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -84,8 +84,11 @@ export const linkCommand: CommandModule = {
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleLink({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
path: argv['path'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope: argv['scope'] as 'user' | 'workspace',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
consent: argv['consent'] as boolean | undefined,
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -18,6 +18,7 @@ export async function handleList(args: { all?: boolean }) {
|
||||
const config = await loadCliConfig(
|
||||
settings.merged,
|
||||
'skills-list-session',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
{
|
||||
debug: false,
|
||||
} as Partial<CliArgs> as CliArgs,
|
||||
@@ -72,6 +73,7 @@ export const listCommand: CommandModule = {
|
||||
default: false,
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
await handleList({ all: argv['all'] as boolean });
|
||||
await exitCli();
|
||||
},
|
||||
|
||||
@@ -64,7 +64,9 @@ export const uninstallCommand: CommandModule = {
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleUninstall({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
name: argv['name'] as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
scope: argv['scope'] as 'user' | 'workspace',
|
||||
});
|
||||
await exitCli();
|
||||
|
||||
@@ -141,6 +141,22 @@ vi.mock('@google/gemini-cli-core', async () => {
|
||||
defaultDecision: ServerConfig.PolicyDecision.ASK_USER,
|
||||
approvalMode: ServerConfig.ApprovalMode.DEFAULT,
|
||||
})),
|
||||
isHeadlessMode: vi.fn((opts) => {
|
||||
if (process.env['VITEST'] === 'true') {
|
||||
return (
|
||||
!!opts?.prompt ||
|
||||
(!!process.stdin && !process.stdin.isTTY) ||
|
||||
(!!process.stdout && !process.stdout.isTTY)
|
||||
);
|
||||
}
|
||||
return (
|
||||
!!opts?.prompt ||
|
||||
process.env['CI'] === 'true' ||
|
||||
process.env['GITHUB_ACTIONS'] === 'true' ||
|
||||
(!!process.stdin && !process.stdin.isTTY) ||
|
||||
(!!process.stdout && !process.stdout.isTTY)
|
||||
);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -154,6 +170,8 @@ vi.mock('./extension-manager.js', () => {
|
||||
// Global setup to ensure clean environment for all tests in this file
|
||||
const originalArgv = process.argv;
|
||||
const originalGeminiModel = process.env['GEMINI_MODEL'];
|
||||
const originalStdoutIsTTY = process.stdout.isTTY;
|
||||
const originalStdinIsTTY = process.stdin.isTTY;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env['GEMINI_MODEL'];
|
||||
@@ -162,6 +180,18 @@ beforeEach(() => {
|
||||
ExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
// Default to interactive mode for tests unless otherwise specified
|
||||
Object.defineProperty(process.stdout, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -171,6 +201,16 @@ afterEach(() => {
|
||||
} else {
|
||||
delete process.env['GEMINI_MODEL'];
|
||||
}
|
||||
Object.defineProperty(process.stdout, 'isTTY', {
|
||||
value: originalStdoutIsTTY,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: originalStdinIsTTY,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseArguments', () => {
|
||||
@@ -249,6 +289,16 @@ describe('parseArguments', () => {
|
||||
});
|
||||
|
||||
describe('positional arguments and @commands', () => {
|
||||
beforeEach(() => {
|
||||
// Default to headless mode for these tests as they mostly expect one-shot behavior
|
||||
process.stdin.isTTY = false;
|
||||
Object.defineProperty(process.stdout, 'isTTY', {
|
||||
value: false,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
description:
|
||||
@@ -379,8 +429,12 @@ describe('parseArguments', () => {
|
||||
);
|
||||
|
||||
it('should include a startup message when converting positional query to interactive prompt', async () => {
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
process.stdin.isTTY = true;
|
||||
Object.defineProperty(process.stdout, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
process.argv = ['node', 'script.js', 'hello'];
|
||||
|
||||
try {
|
||||
@@ -389,7 +443,7 @@ describe('parseArguments', () => {
|
||||
'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.',
|
||||
);
|
||||
} finally {
|
||||
process.stdin.isTTY = originalIsTTY;
|
||||
// beforeEach handles resetting
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1732,14 +1786,29 @@ describe('loadCliConfig model selection', () => {
|
||||
});
|
||||
|
||||
describe('loadCliConfig folderTrust', () => {
|
||||
let originalVitest: string | undefined;
|
||||
let originalIntegrationTest: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
|
||||
|
||||
originalVitest = process.env['VITEST'];
|
||||
originalIntegrationTest = process.env['GEMINI_CLI_INTEGRATION_TEST'];
|
||||
delete process.env['VITEST'];
|
||||
delete process.env['GEMINI_CLI_INTEGRATION_TEST'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalVitest !== undefined) {
|
||||
process.env['VITEST'] = originalVitest;
|
||||
}
|
||||
if (originalIntegrationTest !== undefined) {
|
||||
process.env['GEMINI_CLI_INTEGRATION_TEST'] = originalIntegrationTest;
|
||||
}
|
||||
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -2555,7 +2624,7 @@ describe('loadCliConfig approval mode', () => {
|
||||
it('should use approvalMode from settings when no CLI flags are set', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { approvalMode: 'auto_edit' },
|
||||
general: { defaultApprovalMode: 'auto_edit' },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
@@ -2567,7 +2636,7 @@ describe('loadCliConfig approval mode', () => {
|
||||
it('should prioritize --approval-mode flag over settings', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit'];
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { approvalMode: 'default' },
|
||||
general: { defaultApprovalMode: 'default' },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
@@ -2579,7 +2648,7 @@ describe('loadCliConfig approval mode', () => {
|
||||
it('should prioritize --yolo flag over settings', async () => {
|
||||
process.argv = ['node', 'script.js', '--yolo'];
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { approvalMode: 'auto_edit' },
|
||||
general: { defaultApprovalMode: 'auto_edit' },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
@@ -2589,7 +2658,7 @@ describe('loadCliConfig approval mode', () => {
|
||||
it('should respect plan mode from settings when experimental.plan is enabled', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { approvalMode: 'plan' },
|
||||
general: { defaultApprovalMode: 'plan' },
|
||||
experimental: { plan: true },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
@@ -2600,7 +2669,7 @@ describe('loadCliConfig approval mode', () => {
|
||||
it('should throw error if plan mode is in settings but experimental.plan is disabled', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { approvalMode: 'plan' },
|
||||
general: { defaultApprovalMode: 'plan' },
|
||||
experimental: { plan: false },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
@@ -2779,6 +2848,16 @@ describe('Output format', () => {
|
||||
describe('parseArguments with positional prompt', () => {
|
||||
const originalArgv = process.argv;
|
||||
|
||||
beforeEach(() => {
|
||||
// Default to headless mode for these tests as they mostly expect one-shot behavior
|
||||
process.stdin.isTTY = false;
|
||||
Object.defineProperty(process.stdout, 'isTTY', {
|
||||
value: false,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv;
|
||||
});
|
||||
|
||||
@@ -32,17 +32,17 @@ import {
|
||||
ASK_USER_TOOL_NAME,
|
||||
getVersion,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
type HierarchicalMemory,
|
||||
coreEvents,
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
getAdminErrorMessage,
|
||||
isHeadlessMode,
|
||||
Config,
|
||||
applyAdminAllowlist,
|
||||
getAdminBlockedMcpServersMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
HookDefinition,
|
||||
HookEventName,
|
||||
OutputFormat,
|
||||
type HookDefinition,
|
||||
type HookEventName,
|
||||
type OutputFormat,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
type Settings,
|
||||
@@ -280,6 +280,7 @@ export async function parseArguments(
|
||||
.check((argv) => {
|
||||
// The 'query' positional can be a string (for one arg) or string[] (for multiple).
|
||||
// This guard safely checks if any positional argument was provided.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const query = argv['query'] as string | string[] | undefined;
|
||||
const hasPositionalQuery = Array.isArray(query)
|
||||
? query.length > 0
|
||||
@@ -297,6 +298,7 @@ export async function parseArguments(
|
||||
if (
|
||||
argv['outputFormat'] &&
|
||||
!['text', 'json', 'stream-json'].includes(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
argv['outputFormat'] as string,
|
||||
)
|
||||
) {
|
||||
@@ -345,6 +347,7 @@ export async function parseArguments(
|
||||
}
|
||||
|
||||
// Normalize query args: handle both quoted "@path file" and unquoted @path file
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const queryArg = (result as { query?: string | string[] | undefined }).query;
|
||||
const q: string | undefined = Array.isArray(queryArg)
|
||||
? queryArg.join(' ')
|
||||
@@ -352,7 +355,7 @@ export async function parseArguments(
|
||||
|
||||
// -p/--prompt forces non-interactive mode; positional args default to interactive in TTY
|
||||
if (q && !result['prompt']) {
|
||||
if (process.stdin.isTTY) {
|
||||
if (!isHeadlessMode()) {
|
||||
startupMessages.push(
|
||||
'Positional arguments now default to interactive mode. To run in non-interactive mode, use the --prompt (-p) flag.',
|
||||
);
|
||||
@@ -368,6 +371,7 @@ export async function parseArguments(
|
||||
|
||||
// The import format is now only controlled by settings.memoryImportFormat
|
||||
// We no longer accept it as a CLI argument
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return result as unknown as CliArgs;
|
||||
}
|
||||
|
||||
@@ -436,7 +440,11 @@ export async function loadCliConfig(
|
||||
|
||||
const ideMode = settings.ide?.enabled ?? false;
|
||||
|
||||
const folderTrust = settings.security?.folderTrust?.enabled ?? false;
|
||||
const folderTrust =
|
||||
process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true' ||
|
||||
process.env['VITEST'] === 'true'
|
||||
? false
|
||||
: (settings.security?.folderTrust?.enabled ?? false);
|
||||
const trustedFolder = isWorkspaceTrusted(settings, cwd)?.isTrusted ?? false;
|
||||
|
||||
// Set the context filename in the server's memoryTool module BEFORE loading memory
|
||||
@@ -472,6 +480,7 @@ export async function loadCliConfig(
|
||||
requestSetting: promptForSetting,
|
||||
workspaceDir: cwd,
|
||||
enabledExtensionOverrides: argv.extensions,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
|
||||
clientVersion: await getVersion(),
|
||||
});
|
||||
@@ -479,7 +488,7 @@ export async function loadCliConfig(
|
||||
|
||||
const experimentalJitContext = settings.experimental?.jitContext ?? false;
|
||||
|
||||
let memoryContent = '';
|
||||
let memoryContent: string | HierarchicalMemory = '';
|
||||
let fileCount = 0;
|
||||
let filePaths: string[] = [];
|
||||
|
||||
@@ -510,8 +519,8 @@ export async function loadCliConfig(
|
||||
const rawApprovalMode =
|
||||
argv.approvalMode ||
|
||||
(argv.yolo ? 'yolo' : undefined) ||
|
||||
((settings.tools?.approvalMode as string) !== 'yolo'
|
||||
? settings.tools.approvalMode
|
||||
((settings.general?.defaultApprovalMode as string) !== 'yolo'
|
||||
? settings.general?.defaultApprovalMode
|
||||
: undefined);
|
||||
|
||||
if (rawApprovalMode) {
|
||||
@@ -575,6 +584,7 @@ export async function loadCliConfig(
|
||||
let telemetrySettings;
|
||||
try {
|
||||
telemetrySettings = await resolveTelemetrySettings({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
env: process.env as unknown as Record<string, string | undefined>,
|
||||
settings: settings.telemetry,
|
||||
});
|
||||
@@ -592,7 +602,9 @@ export async function loadCliConfig(
|
||||
const interactive =
|
||||
!!argv.promptInteractive ||
|
||||
!!argv.experimentalAcp ||
|
||||
(process.stdin.isTTY && !argv.query && !argv.prompt && !argv.isCommand);
|
||||
(!isHeadlessMode({ prompt: argv.prompt }) &&
|
||||
!argv.query &&
|
||||
!argv.isCommand);
|
||||
|
||||
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
|
||||
const allowedToolsSet = new Set(allowedTools);
|
||||
@@ -802,6 +814,7 @@ export async function loadCliConfig(
|
||||
eventEmitter: coreEvents,
|
||||
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
|
||||
output: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
|
||||
},
|
||||
fakeResponses: argv.fakeResponses,
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { createExtension } from '../test-utils/createExtension.js';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
|
||||
import { GEMINI_DIR, type Config } from '@google/gemini-cli-core';
|
||||
import { GEMINI_DIR, type Config, tmpdir } from '@google/gemini-cli-core';
|
||||
import { createTestMergedSettings, SettingScope } from './settings.js';
|
||||
|
||||
describe('ExtensionManager theme loading', () => {
|
||||
@@ -29,7 +29,7 @@ describe('ExtensionManager theme loading', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
tempHomeDir = await fs.promises.mkdtemp(
|
||||
path.join(fs.realpathSync('/tmp'), 'gemini-cli-test-'),
|
||||
path.join(tmpdir(), 'gemini-cli-test-'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -85,6 +85,7 @@ describe('ExtensionManager theme loading', () => {
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const mockConfig = {
|
||||
getEnableExtensionReloading: () => false,
|
||||
getMcpClientManager: () => ({
|
||||
@@ -170,6 +171,7 @@ describe('ExtensionManager theme loading', () => {
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const mockConfig = {
|
||||
getWorkingDir: () => tempHomeDir,
|
||||
shouldLoadMemoryFromIncludeDirectories: () => false,
|
||||
|
||||
@@ -730,6 +730,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
|
||||
if (Object.keys(hookEnv).length > 0) {
|
||||
for (const eventName of Object.keys(hooks)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const eventHooks = hooks[eventName as HookEventName];
|
||||
if (eventHooks) {
|
||||
for (const definition of eventHooks) {
|
||||
@@ -826,13 +827,16 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
}
|
||||
try {
|
||||
const configContent = await fs.promises.readFile(configFilePath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const rawConfig = JSON.parse(configContent) as ExtensionConfig;
|
||||
if (!rawConfig.name || !rawConfig.version) {
|
||||
throw new Error(
|
||||
`Invalid configuration in ${configFilePath}: missing ${!rawConfig.name ? '"name"' : '"version"'}`,
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const config = recursivelyHydrateStrings(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
rawConfig as unknown as JsonObject,
|
||||
{
|
||||
extensionPath: extensionDir,
|
||||
@@ -878,6 +882,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
|
||||
// Hydrate variables in the hooks configuration
|
||||
const hydratedHooks = recursivelyHydrateStrings(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
rawHooks.hooks as unknown as JsonObject,
|
||||
{
|
||||
...context,
|
||||
@@ -888,6 +893,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
|
||||
return hydratedHooks;
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return undefined; // File not found is not an error here.
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export function loadInstallMetadata(
|
||||
const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME);
|
||||
try {
|
||||
const configContent = fs.readFileSync(metadataFilePath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const metadata = JSON.parse(configContent) as ExtensionInstallMetadata;
|
||||
return metadata;
|
||||
} catch (_e) {
|
||||
|
||||
@@ -105,6 +105,7 @@ export class ExtensionRegistryClient {
|
||||
throw new Error(`Failed to fetch extensions: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return (await response.json()) as RegistryExtension[];
|
||||
} catch (error) {
|
||||
// Clear the promise on failure so that subsequent calls can try again
|
||||
|
||||
@@ -45,6 +45,7 @@ export async function fetchJson<T>(
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const data = Buffer.concat(chunks).toString();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
resolve(JSON.parse(data) as T);
|
||||
});
|
||||
})
|
||||
|
||||
@@ -52,9 +52,11 @@ export function recursivelyHydrateStrings<T>(
|
||||
values: VariableContext,
|
||||
): T {
|
||||
if (typeof obj === 'string') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return hydrateString(obj, values) as unknown as T;
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return obj.map((item) =>
|
||||
recursivelyHydrateStrings(item, values),
|
||||
) as unknown as T;
|
||||
@@ -64,11 +66,13 @@ export function recursivelyHydrateStrings<T>(
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
newObj[key] = recursivelyHydrateStrings(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(obj as Record<string, unknown>)[key],
|
||||
values,
|
||||
);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return newObj as T;
|
||||
}
|
||||
return obj;
|
||||
|
||||
@@ -91,6 +91,7 @@ export enum Command {
|
||||
TOGGLE_YOLO = 'app.toggleYolo',
|
||||
CYCLE_APPROVAL_MODE = 'app.cycleApprovalMode',
|
||||
SHOW_MORE_LINES = 'app.showMoreLines',
|
||||
EXPAND_PASTE = 'app.expandPaste',
|
||||
FOCUS_SHELL_INPUT = 'app.focusShellInput',
|
||||
UNFOCUS_SHELL_INPUT = 'app.unfocusShellInput',
|
||||
CLEAR_SCREEN = 'app.clearScreen',
|
||||
@@ -289,6 +290,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
{ key: 'o', ctrl: true },
|
||||
{ key: 's', ctrl: true },
|
||||
],
|
||||
[Command.EXPAND_PASTE]: [{ key: 'o', ctrl: true }],
|
||||
[Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }],
|
||||
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }],
|
||||
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
|
||||
@@ -399,6 +401,7 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
Command.TOGGLE_YOLO,
|
||||
Command.CYCLE_APPROVAL_MODE,
|
||||
Command.SHOW_MORE_LINES,
|
||||
Command.EXPAND_PASTE,
|
||||
Command.TOGGLE_BACKGROUND_SHELL,
|
||||
Command.TOGGLE_BACKGROUND_SHELL_LIST,
|
||||
Command.KILL_BACKGROUND_SHELL,
|
||||
@@ -499,6 +502,8 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
|
||||
[Command.SHOW_MORE_LINES]:
|
||||
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
|
||||
[Command.EXPAND_PASTE]:
|
||||
'Expand or collapse a paste placeholder when cursor is over placeholder.',
|
||||
[Command.BACKGROUND_SHELL_SELECT]:
|
||||
'Confirm selection in background shell list.',
|
||||
[Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.',
|
||||
|
||||
@@ -358,6 +358,7 @@ export class McpServerEnablementManager {
|
||||
private async readConfig(): Promise<McpServerEnablementConfig> {
|
||||
try {
|
||||
const content = await fs.readFile(this.configFilePath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return JSON.parse(content) as McpServerEnablementConfig;
|
||||
} catch (error) {
|
||||
if (
|
||||
|
||||
@@ -23,6 +23,7 @@ function buildZodSchemaFromJsonSchema(def: any): z.ZodTypeAny {
|
||||
}
|
||||
|
||||
if (def.type === 'string') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
if (def.enum) return z.enum(def.enum as [string, ...string[]]);
|
||||
return z.string();
|
||||
}
|
||||
@@ -40,7 +41,7 @@ function buildZodSchemaFromJsonSchema(def: any): z.ZodTypeAny {
|
||||
let schema;
|
||||
if (def.properties) {
|
||||
const shape: Record<string, z.ZodTypeAny> = {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
for (const [key, propDef] of Object.entries(def.properties) as any) {
|
||||
let propSchema = buildZodSchemaFromJsonSchema(propDef);
|
||||
if (
|
||||
@@ -86,9 +87,11 @@ function buildEnumSchema(
|
||||
}
|
||||
const values = options.map((opt) => opt.value);
|
||||
if (values.every((v) => typeof v === 'string')) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return z.enum(values as [string, ...string[]]);
|
||||
} else if (values.every((v) => typeof v === 'number')) {
|
||||
return z.union(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
values.map((v) => z.literal(v)) as [
|
||||
z.ZodLiteral<number>,
|
||||
z.ZodLiteral<number>,
|
||||
@@ -97,6 +100,7 @@ function buildEnumSchema(
|
||||
);
|
||||
} else {
|
||||
return z.union(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
values.map((v) => z.literal(v)) as [
|
||||
z.ZodLiteral<unknown>,
|
||||
z.ZodLiteral<unknown>,
|
||||
|
||||
@@ -1936,6 +1936,40 @@ describe('Settings Loading and Merging', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should migrate tools.approvalMode to general.defaultApprovalMode', () => {
|
||||
const userSettingsContent = {
|
||||
tools: {
|
||||
approvalMode: 'plan',
|
||||
},
|
||||
};
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const setValueSpy = vi.spyOn(LoadedSettings.prototype, 'setValue');
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
migrateDeprecatedSettings(loadedSettings, true);
|
||||
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'general',
|
||||
expect.objectContaining({ defaultApprovalMode: 'plan' }),
|
||||
);
|
||||
|
||||
// Verify removal
|
||||
expect(setValueSpy).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'tools',
|
||||
expect.not.objectContaining({ approvalMode: 'plan' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should migrate all 4 inverted boolean settings', () => {
|
||||
const userSettingsContent = {
|
||||
general: {
|
||||
|
||||
@@ -213,6 +213,7 @@ function setNestedProperty(
|
||||
}
|
||||
const next = current[key];
|
||||
if (typeof next === 'object' && next !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
current = next as Record<string, unknown>;
|
||||
} else {
|
||||
// This path is invalid, so we stop.
|
||||
@@ -254,6 +255,7 @@ export function mergeSettings(
|
||||
// 3. User Settings
|
||||
// 4. Workspace Settings
|
||||
// 5. System Settings (as overrides)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return customDeepMerge(
|
||||
getMergeStrategyForPath,
|
||||
schemaDefaults,
|
||||
@@ -274,6 +276,7 @@ export function mergeSettings(
|
||||
export function createTestMergedSettings(
|
||||
overrides: Partial<Settings> = {},
|
||||
): MergedSettings {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return customDeepMerge(
|
||||
getMergeStrategyForPath,
|
||||
getDefaultsFromSchema(),
|
||||
@@ -355,6 +358,7 @@ export class LoadedSettings {
|
||||
|
||||
// The final admin settings are the defaults overridden by remote settings.
|
||||
// Any admin settings from files are ignored.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
merged.admin = customDeepMerge(
|
||||
(path: string[]) => getMergeStrategyForPath(['admin', ...path]),
|
||||
adminDefaults,
|
||||
@@ -617,6 +621,7 @@ export function loadSettings(
|
||||
return { settings: {} };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const settingsObject = rawSettings as Record<string, unknown>;
|
||||
|
||||
// Validate settings structure with Zod
|
||||
@@ -850,6 +855,7 @@ export function migrateDeprecatedSettings(
|
||||
const uiSettings = settings.ui as Record<string, unknown> | undefined;
|
||||
if (uiSettings) {
|
||||
const newUi = { ...uiSettings };
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const accessibilitySettings = newUi['accessibility'] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
@@ -880,6 +886,7 @@ export function migrateDeprecatedSettings(
|
||||
| undefined;
|
||||
if (contextSettings) {
|
||||
const newContext = { ...contextSettings };
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const fileFilteringSettings = newContext['fileFiltering'] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
@@ -904,6 +911,36 @@ export function migrateDeprecatedSettings(
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate tools settings
|
||||
const toolsSettings = settings.tools as Record<string, unknown> | undefined;
|
||||
if (toolsSettings) {
|
||||
if (toolsSettings['approvalMode'] !== undefined) {
|
||||
foundDeprecated.push('tools.approvalMode');
|
||||
|
||||
const generalSettings =
|
||||
(settings.general as Record<string, unknown> | undefined) || {};
|
||||
const newGeneral = { ...generalSettings };
|
||||
|
||||
// Only set defaultApprovalMode if it's not already set
|
||||
if (newGeneral['defaultApprovalMode'] === undefined) {
|
||||
newGeneral['defaultApprovalMode'] = toolsSettings['approvalMode'];
|
||||
loadedSettings.setValue(scope, 'general', newGeneral);
|
||||
if (!settingsFile.readOnly) {
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (removeDeprecated) {
|
||||
const newTools = { ...toolsSettings };
|
||||
delete newTools['approvalMode'];
|
||||
loadedSettings.setValue(scope, 'tools', newTools);
|
||||
if (!settingsFile.readOnly) {
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate experimental agent settings
|
||||
const experimentalModified = migrateExperimentalSettings(
|
||||
settings,
|
||||
@@ -1000,6 +1037,7 @@ function migrateExperimentalSettings(
|
||||
...(settings.agents as Record<string, unknown> | undefined),
|
||||
};
|
||||
const agentsOverrides = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
...((agentsSettings['overrides'] as Record<string, unknown>) || {}),
|
||||
};
|
||||
let modified = false;
|
||||
@@ -1011,6 +1049,7 @@ function migrateExperimentalSettings(
|
||||
const old = experimentalSettings[oldKey];
|
||||
if (old) {
|
||||
foundDeprecated?.push(`experimental.${oldKey}`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
migrateFn(old as Record<string, unknown>);
|
||||
modified = true;
|
||||
}
|
||||
@@ -1019,6 +1058,7 @@ function migrateExperimentalSettings(
|
||||
// Migrate codebaseInvestigatorSettings -> agents.overrides.codebase_investigator
|
||||
migrateExperimental('codebaseInvestigatorSettings', (old) => {
|
||||
const override = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
...(agentsOverrides['codebase_investigator'] as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
@@ -1027,6 +1067,7 @@ function migrateExperimentalSettings(
|
||||
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
|
||||
|
||||
const runConfig = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
...(override['runConfig'] as Record<string, unknown> | undefined),
|
||||
};
|
||||
if (old['maxNumTurns'] !== undefined)
|
||||
@@ -1037,16 +1078,19 @@ function migrateExperimentalSettings(
|
||||
|
||||
if (old['model'] !== undefined || old['thinkingBudget'] !== undefined) {
|
||||
const modelConfig = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
...(override['modelConfig'] as Record<string, unknown> | undefined),
|
||||
};
|
||||
if (old['model'] !== undefined) modelConfig['model'] = old['model'];
|
||||
if (old['thinkingBudget'] !== undefined) {
|
||||
const generateContentConfig = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
...(modelConfig['generateContentConfig'] as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
};
|
||||
const thinkingConfig = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
...(generateContentConfig['thinkingConfig'] as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
@@ -1064,6 +1108,7 @@ function migrateExperimentalSettings(
|
||||
// Migrate cliHelpAgentSettings -> agents.overrides.cli_help
|
||||
migrateExperimental('cliHelpAgentSettings', (old) => {
|
||||
const override = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
...(agentsOverrides['cli_help'] as Record<string, unknown> | undefined),
|
||||
};
|
||||
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
|
||||
|
||||
@@ -179,6 +179,33 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Enable Vim keybindings',
|
||||
showInDialog: true,
|
||||
},
|
||||
defaultApprovalMode: {
|
||||
type: 'enum',
|
||||
label: 'Default Approval Mode',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: 'default',
|
||||
description: oneLine`
|
||||
The default approval mode for tool execution.
|
||||
'default' prompts for approval, 'auto_edit' auto-approves edit tools,
|
||||
and 'plan' is read-only mode. 'yolo' is not supported yet.
|
||||
`,
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'auto_edit', label: 'Auto Edit' },
|
||||
{ value: 'plan', label: 'Plan' },
|
||||
],
|
||||
},
|
||||
devtools: {
|
||||
type: 'boolean',
|
||||
label: 'DevTools',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Enable DevTools inspector on launch.',
|
||||
showInDialog: false,
|
||||
},
|
||||
enableAutoUpdate: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Auto Update',
|
||||
@@ -383,6 +410,19 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Hide the window title bar',
|
||||
showInDialog: true,
|
||||
},
|
||||
inlineThinkingMode: {
|
||||
type: 'enum',
|
||||
label: 'Inline Thinking',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: 'off',
|
||||
description: 'Display model thinking inline: off or full.',
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'off', label: 'Off' },
|
||||
{ value: 'full', label: 'Full' },
|
||||
],
|
||||
},
|
||||
showStatusInTitle: {
|
||||
type: 'boolean',
|
||||
label: 'Show Thoughts in Title',
|
||||
@@ -1061,24 +1101,7 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
approvalMode: {
|
||||
type: 'enum',
|
||||
label: 'Approval Mode',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: 'default',
|
||||
description: oneLine`
|
||||
The default approval mode for tool execution.
|
||||
'default' prompts for approval, 'auto_edit' auto-approves edit tools,
|
||||
and 'plan' is read-only mode. 'yolo' is not supported yet.
|
||||
`,
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'auto_edit', label: 'Auto Edit' },
|
||||
{ value: 'plan', label: 'Plan' },
|
||||
],
|
||||
},
|
||||
|
||||
core: {
|
||||
type: 'array',
|
||||
label: 'Core Tools',
|
||||
|
||||
@@ -32,6 +32,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
homedir: () => '/mock/home/user',
|
||||
isHeadlessMode: vi.fn(() => false),
|
||||
coreEvents: {
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
@@ -280,6 +281,26 @@ describe('Trusted Folders', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should return true for a child of a trusted folder', () => {
|
||||
const config = { '/projectA': TrustLevel.TRUST_FOLDER };
|
||||
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
|
||||
|
||||
expect(isWorkspaceTrusted(mockSettings, '/projectA/src')).toEqual({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return true for a child of a trusted parent folder', () => {
|
||||
const config = { '/projectB/somefile.txt': TrustLevel.TRUST_PARENT };
|
||||
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
|
||||
|
||||
expect(isWorkspaceTrusted(mockSettings, '/projectB')).toEqual({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false for a directly untrusted folder', () => {
|
||||
const config = { '/untrusted': TrustLevel.DO_NOT_TRUST };
|
||||
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
|
||||
@@ -290,6 +311,15 @@ describe('Trusted Folders', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false for a child of an untrusted folder', () => {
|
||||
const config = { '/untrusted': TrustLevel.DO_NOT_TRUST };
|
||||
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
|
||||
|
||||
expect(isWorkspaceTrusted(mockSettings, '/untrusted/src').isTrusted).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return undefined when no rules match', () => {
|
||||
fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8');
|
||||
expect(
|
||||
@@ -297,6 +327,47 @@ describe('Trusted Folders', () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should prioritize specific distrust over parent trust', () => {
|
||||
const config = {
|
||||
'/projectA': TrustLevel.TRUST_FOLDER,
|
||||
'/projectA/untrusted': TrustLevel.DO_NOT_TRUST,
|
||||
};
|
||||
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
|
||||
|
||||
expect(isWorkspaceTrusted(mockSettings, '/projectA/untrusted')).toEqual({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use workspaceDir instead of process.cwd() when provided', () => {
|
||||
const config = {
|
||||
'/projectA': TrustLevel.TRUST_FOLDER,
|
||||
'/untrusted': TrustLevel.DO_NOT_TRUST,
|
||||
};
|
||||
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
|
||||
|
||||
vi.spyOn(process, 'cwd').mockImplementation(() => '/untrusted');
|
||||
|
||||
// process.cwd() is untrusted, but workspaceDir is trusted
|
||||
expect(isWorkspaceTrusted(mockSettings, '/projectA')).toEqual({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle path normalization', () => {
|
||||
const config = { '/home/user/projectA': TrustLevel.TRUST_FOLDER };
|
||||
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
|
||||
|
||||
expect(
|
||||
isWorkspaceTrusted(mockSettings, '/home/user/../user/projectA'),
|
||||
).toEqual({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
});
|
||||
|
||||
it('should prioritize IDE override over file config', () => {
|
||||
const config = { '/projectA': TrustLevel.DO_NOT_TRUST };
|
||||
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
|
||||
@@ -313,6 +384,30 @@ describe('Trusted Folders', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should return false when IDE override is false', () => {
|
||||
const config = { '/projectA': TrustLevel.TRUST_FOLDER };
|
||||
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
|
||||
|
||||
ideContextStore.set({ workspaceState: { isTrusted: false } });
|
||||
|
||||
try {
|
||||
expect(isWorkspaceTrusted(mockSettings, '/projectA')).toEqual({
|
||||
isTrusted: false,
|
||||
source: 'ide',
|
||||
});
|
||||
} finally {
|
||||
ideContextStore.clear();
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw FatalConfigError when the config file is invalid', () => {
|
||||
fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8');
|
||||
|
||||
expect(() => isWorkspaceTrusted(mockSettings, '/any')).toThrow(
|
||||
FatalConfigError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should always return true if folderTrust setting is disabled', () => {
|
||||
const disabledSettings: Settings = {
|
||||
security: { folderTrust: { enabled: false } },
|
||||
@@ -324,7 +419,75 @@ describe('Trusted Folders', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWorkspaceTrusted headless mode', () => {
|
||||
const mockSettings: Settings = {
|
||||
security: {
|
||||
folderTrust: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('should return true when isHeadlessMode is true, ignoring config', async () => {
|
||||
const geminiCore = await import('@google/gemini-cli-core');
|
||||
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true);
|
||||
|
||||
expect(isWorkspaceTrusted(mockSettings)).toEqual({
|
||||
isTrusted: true,
|
||||
source: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to config when isHeadlessMode is false', async () => {
|
||||
const geminiCore = await import('@google/gemini-cli-core');
|
||||
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(false);
|
||||
|
||||
const config = { '/projectA': TrustLevel.DO_NOT_TRUST };
|
||||
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
|
||||
|
||||
expect(isWorkspaceTrusted(mockSettings, '/projectA').isTrusted).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Trusted Folders Caching', () => {
|
||||
it('should cache the loaded folders object', () => {
|
||||
// First call should load and cache
|
||||
const folders1 = loadTrustedFolders();
|
||||
|
||||
// Second call should return the same instance from cache
|
||||
const folders2 = loadTrustedFolders();
|
||||
expect(folders1).toBe(folders2);
|
||||
|
||||
// Resetting should clear the cache
|
||||
resetTrustedFoldersForTesting();
|
||||
|
||||
// Third call should return a new instance
|
||||
const folders3 = loadTrustedFolders();
|
||||
expect(folders3).not.toBe(folders1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid trust levels', () => {
|
||||
it('should create a comprehensive error message for invalid trust level', () => {
|
||||
const config = { '/user/folder': 'INVALID_TRUST_LEVEL' };
|
||||
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
|
||||
|
||||
const { errors } = loadTrustedFolders();
|
||||
const possibleValues = Object.values(TrustLevel).join(', ');
|
||||
expect(errors.length).toBe(1);
|
||||
expect(errors[0].message).toBe(
|
||||
`Invalid trust level "INVALID_TRUST_LEVEL" for path "/user/folder". Possible values are: ${possibleValues}.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Symlinks Support', () => {
|
||||
const mockSettings: Settings = {
|
||||
security: { folderTrust: { enabled: true } },
|
||||
};
|
||||
|
||||
it('should trust a folder if the rule matches the realpath', () => {
|
||||
// Create a real directory and a symlink
|
||||
const realDir = path.join(tempDir, 'real');
|
||||
@@ -339,10 +502,6 @@ describe('Trusted Folders', () => {
|
||||
// Check against symlink path
|
||||
expect(isWorkspaceTrusted(mockSettings, symlinkDir).isTrusted).toBe(true);
|
||||
});
|
||||
|
||||
const mockSettings: Settings = {
|
||||
security: { folderTrust: { enabled: true } },
|
||||
};
|
||||
});
|
||||
|
||||
describe('Verification: Auth and Trust Interaction', () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ideContextStore,
|
||||
GEMINI_DIR,
|
||||
homedir,
|
||||
isHeadlessMode,
|
||||
coreEvents,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Settings } from './settings.js';
|
||||
@@ -46,6 +47,7 @@ export function isTrustLevel(
|
||||
): value is TrustLevel {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
Object.values(TrustLevel).includes(value as TrustLevel)
|
||||
);
|
||||
}
|
||||
@@ -196,6 +198,7 @@ export class LoadedTrustedFolders {
|
||||
const content = await fsPromises.readFile(this.user.path, 'utf-8');
|
||||
let config: Record<string, TrustLevel>;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
config = parseTrustedFoldersJson(content) as Record<string, TrustLevel>;
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback(
|
||||
@@ -250,6 +253,7 @@ export function loadTrustedFolders(): LoadedTrustedFolders {
|
||||
try {
|
||||
if (fs.existsSync(userPath)) {
|
||||
const content = fs.readFileSync(userPath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const parsed = parseTrustedFoldersJson(content) as Record<string, string>;
|
||||
|
||||
if (
|
||||
@@ -354,6 +358,10 @@ export function isWorkspaceTrusted(
|
||||
workspaceDir: string = process.cwd(),
|
||||
trustConfig?: Record<string, TrustLevel>,
|
||||
): TrustResult {
|
||||
if (isHeadlessMode()) {
|
||||
return { isTrusted: true, source: undefined };
|
||||
}
|
||||
|
||||
if (!isFolderTrustEnabled(settings)) {
|
||||
return { isTrusted: true, source: undefined };
|
||||
}
|
||||
|
||||
@@ -86,9 +86,11 @@ export function defer<T = object, U = object>(
|
||||
...commandModule,
|
||||
handler: (argv: ArgumentsCamelCase<U>) => {
|
||||
setDeferredCommand({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
handler: commandModule.handler as (
|
||||
argv: ArgumentsCamelCase,
|
||||
) => void | Promise<void>,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
argv: argv as unknown as ArgumentsCamelCase,
|
||||
commandName: parentCommandName || 'unknown',
|
||||
});
|
||||
|
||||
@@ -518,11 +518,11 @@ export async function main() {
|
||||
|
||||
adminControlsListner.setConfig(config);
|
||||
|
||||
if (config.isInteractive() && config.getDebugMode()) {
|
||||
if (config.isInteractive() && settings.merged.general.devtools) {
|
||||
const { registerActivityLogger } = await import(
|
||||
'./utils/activityLogger.js'
|
||||
'./utils/devtoolsService.js'
|
||||
);
|
||||
registerActivityLogger(config);
|
||||
await registerActivityLogger(config);
|
||||
}
|
||||
|
||||
// Register config for telemetry shutdown
|
||||
@@ -819,6 +819,7 @@ function setupAdminControlsListener() {
|
||||
let config: Config | undefined;
|
||||
|
||||
const messageHandler = (msg: unknown) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const message = msg as {
|
||||
type?: string;
|
||||
settings?: AdminControlsSettings;
|
||||
|
||||
@@ -39,7 +39,7 @@ import type { LoadedSettings } from './config/settings.js';
|
||||
vi.mock('./ui/hooks/atCommandProcessor.js');
|
||||
|
||||
const mockRegisterActivityLogger = vi.hoisted(() => vi.fn());
|
||||
vi.mock('./utils/activityLogger.js', () => ({
|
||||
vi.mock('./utils/devtoolsService.js', () => ({
|
||||
registerActivityLogger: mockRegisterActivityLogger,
|
||||
}));
|
||||
|
||||
|
||||
@@ -73,9 +73,9 @@ export async function runNonInteractive({
|
||||
|
||||
if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) {
|
||||
const { registerActivityLogger } = await import(
|
||||
'./utils/activityLogger.js'
|
||||
'./utils/devtoolsService.js'
|
||||
);
|
||||
registerActivityLogger(config);
|
||||
await registerActivityLogger(config);
|
||||
}
|
||||
|
||||
const { stdout: workingStdout } = createWorkingStdio();
|
||||
@@ -250,6 +250,7 @@ export async function runNonInteractive({
|
||||
// Otherwise, slashCommandResult falls through to the default prompt
|
||||
// handling.
|
||||
if (slashCommandResult) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
query = slashCommandResult as Part[];
|
||||
}
|
||||
}
|
||||
@@ -271,6 +272,7 @@ export async function runNonInteractive({
|
||||
error || 'Exiting due to an error processing the @ command.',
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
query = processedQuery as Part[];
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,7 @@ export class FileCommandLoader implements ICommandLoader {
|
||||
} catch (error) {
|
||||
if (
|
||||
!signal.aborted &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(error as { code?: string })?.code !== 'ENOENT'
|
||||
) {
|
||||
coreEvents.emitFeedback(
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { act } from 'react';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// The waitFor from vitest doesn't properly wrap in act(), so we have to
|
||||
// implement our own like the one in @testing-library/react
|
||||
@@ -13,7 +14,7 @@ import { act } from 'react';
|
||||
// for React state updates.
|
||||
export async function waitFor(
|
||||
assertion: () => void,
|
||||
{ timeout = 1000, interval = 50 } = {},
|
||||
{ timeout = 2000, interval = 50 } = {},
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -27,7 +28,11 @@ export async function waitFor(
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
if (vi.isFakeTimers()) {
|
||||
await vi.advanceTimersByTimeAsync(interval);
|
||||
} else {
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { TextBuffer } from '../ui/components/shared/text-buffer.js';
|
||||
const invalidCharsRegex = /[\b\x1b]/;
|
||||
|
||||
function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
const { isNot } = this as any;
|
||||
let pass = true;
|
||||
const invalidLines: Array<{ line: number; content: string }> = [];
|
||||
@@ -50,6 +50,7 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
expect.extend({
|
||||
toHaveOnlyValidCharacters,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -38,12 +38,14 @@ export const createMockCommandContext = (
|
||||
},
|
||||
services: {
|
||||
config: null,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
settings: {
|
||||
merged: defaultMergedSettings,
|
||||
setValue: vi.fn(),
|
||||
forScope: vi.fn().mockReturnValue({ settings: {} }),
|
||||
} as unknown as LoadedSettings,
|
||||
git: undefined as GitService | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
logger: {
|
||||
log: vi.fn(),
|
||||
logMessage: vi.fn(),
|
||||
@@ -52,6 +54,7 @@ export const createMockCommandContext = (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any, // Cast because Logger is a class.
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
ui: {
|
||||
addItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
@@ -70,6 +73,7 @@ export const createMockCommandContext = (
|
||||
} as any,
|
||||
session: {
|
||||
sessionShellAllowlist: new Set<string>(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
stats: {
|
||||
sessionStartTime: new Date(),
|
||||
lastPromptTokenCount: 0,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createTestMergedSettings } from '../config/settings.js';
|
||||
* Creates a mocked Config object with default values and allows overrides.
|
||||
*/
|
||||
export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
({
|
||||
getSandbox: vi.fn(() => undefined),
|
||||
getQuestion: vi.fn(() => ''),
|
||||
@@ -152,6 +153,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getBlockedMcpServers: vi.fn().mockReturnValue([]),
|
||||
getExperiments: vi.fn().mockReturnValue(undefined),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
validatePathAccess: vi.fn().mockReturnValue(null),
|
||||
...overrides,
|
||||
}) as unknown as Config;
|
||||
|
||||
@@ -162,9 +164,11 @@ export function createMockSettings(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): LoadedSettings {
|
||||
const merged = createTestMergedSettings(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(overrides['merged'] as Partial<Settings>) || {},
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
system: { settings: {} },
|
||||
systemDefaults: { settings: {} },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -44,6 +44,7 @@ vi.mock('../ui/utils/terminalUtils.js', () => ({
|
||||
isLowColorDepth: vi.fn(() => false),
|
||||
getColorDepth: vi.fn(() => 24),
|
||||
isITerm2: vi.fn(() => false),
|
||||
shouldUseEmoji: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
// Wrapper around ink-testing-library's render that ensures act() is called
|
||||
@@ -52,6 +53,7 @@ export const render = (
|
||||
terminalWidth?: number,
|
||||
): ReturnType<typeof inkRender> => {
|
||||
let renderResult: ReturnType<typeof inkRender> =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
undefined as unknown as ReturnType<typeof inkRender>;
|
||||
act(() => {
|
||||
renderResult = inkRender(tree);
|
||||
@@ -113,14 +115,19 @@ const getMockConfigInternal = (): Config => {
|
||||
return mockConfigInternal;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const configProxy = new Proxy({} as Config, {
|
||||
get(_target, prop) {
|
||||
if (prop === 'getTargetDir') {
|
||||
return () =>
|
||||
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long';
|
||||
}
|
||||
if (prop === 'getUseBackgroundColor') {
|
||||
return () => true;
|
||||
}
|
||||
const internal = getMockConfigInternal();
|
||||
if (prop in internal) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return internal[prop as keyof typeof internal];
|
||||
}
|
||||
throw new Error(`mockConfig does not have property ${String(prop)}`);
|
||||
@@ -148,6 +155,12 @@ const baseMockUiState = {
|
||||
activePtyId: undefined,
|
||||
backgroundShells: new Map(),
|
||||
backgroundShellHeight: 0,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: undefined,
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const mockAppState: AppState = {
|
||||
@@ -197,7 +210,6 @@ const mockUIActions: UIActions = {
|
||||
setActiveBackgroundShellPid: vi.fn(),
|
||||
setIsBackgroundShellListOpen: vi.fn(),
|
||||
setAuthContext: vi.fn(),
|
||||
handleWarning: vi.fn(),
|
||||
handleRestart: vi.fn(),
|
||||
handleNewAgentsSelect: vi.fn(),
|
||||
};
|
||||
@@ -210,6 +222,7 @@ export const renderWithProviders = (
|
||||
uiState: providedUiState,
|
||||
width,
|
||||
mouseEventsEnabled = false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
config = configProxy as unknown as Config,
|
||||
useAlternateBuffer = true,
|
||||
uiActions,
|
||||
@@ -231,17 +244,20 @@ export const renderWithProviders = (
|
||||
appState?: AppState;
|
||||
} = {},
|
||||
): ReturnType<typeof render> & { simulateClick: typeof simulateClick } => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const baseState: UIState = new Proxy(
|
||||
{ ...baseMockUiState, ...providedUiState },
|
||||
{
|
||||
get(target, prop) {
|
||||
if (prop in target) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return target[prop as keyof typeof target];
|
||||
}
|
||||
// For properties not in the base mock or provided state,
|
||||
// we'll check the original proxy to see if it's a defined but
|
||||
// unprovided property, and if not, throw.
|
||||
if (prop in baseMockUiState) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return baseMockUiState[prop as keyof typeof baseMockUiState];
|
||||
}
|
||||
throw new Error(`mockUiState does not have property ${String(prop)}`);
|
||||
@@ -347,7 +363,9 @@ export function renderHook<Result, Props>(
|
||||
rerender: (props?: Props) => void;
|
||||
unmount: () => void;
|
||||
} {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const result = { current: undefined as unknown as Result };
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
let currentProps = options?.initialProps as Props;
|
||||
|
||||
function TestComponent({
|
||||
@@ -378,6 +396,7 @@ export function renderHook<Result, Props>(
|
||||
|
||||
function rerender(props?: Props) {
|
||||
if (arguments.length > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
currentProps = props as Props;
|
||||
}
|
||||
act(() => {
|
||||
@@ -411,6 +430,7 @@ export function renderHookWithProviders<Result, Props>(
|
||||
rerender: (props?: Props) => void;
|
||||
unmount: () => void;
|
||||
} {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const result = { current: undefined as unknown as Result };
|
||||
|
||||
let setPropsFn: ((props: Props) => void) | undefined;
|
||||
@@ -432,6 +452,7 @@ export function renderHookWithProviders<Result, Props>(
|
||||
act(() => {
|
||||
renderResult = renderWithProviders(
|
||||
<Wrapper>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion */}
|
||||
<TestComponent initialProps={options.initialProps as Props} />
|
||||
</Wrapper>,
|
||||
options,
|
||||
@@ -441,6 +462,7 @@ export function renderHookWithProviders<Result, Props>(
|
||||
function rerender(newProps?: Props) {
|
||||
act(() => {
|
||||
if (arguments.length > 0 && setPropsFn) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
setPropsFn(newProps as Props);
|
||||
} else if (forceUpdateFn) {
|
||||
forceUpdateFn();
|
||||
|
||||
@@ -51,13 +51,17 @@ export const createMockSettings = (
|
||||
} = overrides;
|
||||
|
||||
const loaded = new LoadedSettings(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(system as any) || { path: '', settings: {}, originalSettings: {} },
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(systemDefaults as any) || { path: '', settings: {}, originalSettings: {} },
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(user as any) || {
|
||||
path: '',
|
||||
settings: settingsOverrides,
|
||||
originalSettings: settingsOverrides,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(workspace as any) || { path: '', settings: {}, originalSettings: {} },
|
||||
isTrusted ?? true,
|
||||
errors || [],
|
||||
@@ -71,6 +75,7 @@ export const createMockSettings = (
|
||||
// Assign any function overrides (e.g., vi.fn() for methods)
|
||||
for (const key in overrides) {
|
||||
if (typeof overrides[key] === 'function') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(loaded as any)[key] = overrides[key];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -145,13 +145,30 @@ vi.mock('./contexts/SessionContext.js');
|
||||
vi.mock('./components/shared/text-buffer.js');
|
||||
vi.mock('./hooks/useLogger.js');
|
||||
vi.mock('./hooks/useInputHistoryStore.js');
|
||||
vi.mock('./hooks/atCommandProcessor.js');
|
||||
vi.mock('./hooks/useHookDisplayState.js');
|
||||
vi.mock('./hooks/useBanner.js', () => ({
|
||||
useBanner: vi.fn((bannerData) => ({
|
||||
bannerText: (
|
||||
bannerData.warningText ||
|
||||
bannerData.defaultText ||
|
||||
''
|
||||
).replace(/\\n/g, '\n'),
|
||||
})),
|
||||
}));
|
||||
vi.mock('./hooks/useShellInactivityStatus.js', () => ({
|
||||
useShellInactivityStatus: vi.fn(() => ({
|
||||
shouldShowFocusHint: false,
|
||||
inactivityStatus: 'none',
|
||||
})),
|
||||
}));
|
||||
vi.mock('./hooks/useTerminalTheme.js', () => ({
|
||||
useTerminalTheme: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
||||
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
|
||||
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
|
||||
|
||||
// Mock external utilities
|
||||
vi.mock('../utils/events.js');
|
||||
@@ -255,6 +272,7 @@ describe('AppContainer State Management', () => {
|
||||
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
|
||||
const mockedUseHookDisplayState = useHookDisplayState as Mock;
|
||||
const mockedUseTerminalTheme = useTerminalTheme as Mock;
|
||||
const mockedUseShellInactivityStatus = useShellInactivityStatus as Mock;
|
||||
|
||||
const DEFAULT_GEMINI_STREAM_MOCK = {
|
||||
streamingState: 'idle',
|
||||
@@ -384,6 +402,10 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
mockedUseHookDisplayState.mockReturnValue([]);
|
||||
mockedUseTerminalTheme.mockReturnValue(undefined);
|
||||
mockedUseShellInactivityStatus.mockReturnValue({
|
||||
shouldShowFocusHint: false,
|
||||
inactivityStatus: 'none',
|
||||
});
|
||||
|
||||
// Mock Config
|
||||
mockConfig = makeFakeConfig();
|
||||
@@ -950,7 +972,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
await waitFor(() => {
|
||||
// Assert that the context value is as expected
|
||||
expect(capturedUIState.proQuotaRequest).toBeNull();
|
||||
expect(capturedUIState.quota.proQuotaRequest).toBeNull();
|
||||
});
|
||||
unmount!();
|
||||
});
|
||||
@@ -975,7 +997,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
await waitFor(() => {
|
||||
// Assert: The mock request is correctly passed through the context
|
||||
expect(capturedUIState.proQuotaRequest).toEqual(mockRequest);
|
||||
expect(capturedUIState.quota.proQuotaRequest).toEqual(mockRequest);
|
||||
});
|
||||
unmount!();
|
||||
});
|
||||
@@ -1246,8 +1268,15 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('Shell Focus Action Required', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
// Use real implementation for these tests to verify title updates
|
||||
const actual = await vi.importActual<
|
||||
typeof import('./hooks/useShellInactivityStatus.js')
|
||||
>('./hooks/useShellInactivityStatus.js');
|
||||
mockedUseShellInactivityStatus.mockImplementation(
|
||||
actual.useShellInactivityStatus,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -2734,4 +2763,67 @@ describe('AppContainer State Management', () => {
|
||||
compUnmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Permission Handling', () => {
|
||||
it('shows permission dialog when checkPermissions returns paths', async () => {
|
||||
const { checkPermissions } = await import(
|
||||
'./hooks/atCommandProcessor.js'
|
||||
);
|
||||
vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']);
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => (unmount = renderAppContainer().unmount));
|
||||
|
||||
await waitFor(() => expect(capturedUIActions).toBeTruthy());
|
||||
|
||||
await act(async () =>
|
||||
capturedUIActions.handleFinalSubmit('read @file.txt'),
|
||||
);
|
||||
|
||||
expect(capturedUIState.permissionConfirmationRequest).not.toBeNull();
|
||||
expect(capturedUIState.permissionConfirmationRequest?.files).toEqual([
|
||||
'/test/file.txt',
|
||||
]);
|
||||
await act(async () => unmount!());
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
'handles permissions when allowed is %s',
|
||||
async (allowed) => {
|
||||
const { checkPermissions } = await import(
|
||||
'./hooks/atCommandProcessor.js'
|
||||
);
|
||||
vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']);
|
||||
const addReadOnlyPathSpy = vi.spyOn(
|
||||
mockConfig.getWorkspaceContext(),
|
||||
'addReadOnlyPath',
|
||||
);
|
||||
const { submitQuery } = mockedUseGeminiStream();
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => (unmount = renderAppContainer().unmount));
|
||||
|
||||
await waitFor(() => expect(capturedUIActions).toBeTruthy());
|
||||
|
||||
await act(async () =>
|
||||
capturedUIActions.handleFinalSubmit('read @file.txt'),
|
||||
);
|
||||
|
||||
await act(async () =>
|
||||
capturedUIState.permissionConfirmationRequest?.onComplete({
|
||||
allowed,
|
||||
}),
|
||||
);
|
||||
|
||||
if (allowed) {
|
||||
expect(addReadOnlyPathSpy).toHaveBeenCalledWith('/test/file.txt');
|
||||
} else {
|
||||
expect(addReadOnlyPathSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(submitQuery).toHaveBeenCalledWith('read @file.txt');
|
||||
expect(capturedUIState.permissionConfirmationRequest).toBeNull();
|
||||
await act(async () => unmount!());
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
type HistoryItemToolGroup,
|
||||
AuthState,
|
||||
type ConfirmationRequest,
|
||||
type PermissionConfirmationRequest,
|
||||
type QuotaStats,
|
||||
} from './types.js';
|
||||
import { checkPermissions } from './hooks/atCommandProcessor.js';
|
||||
import { MessageType, StreamingState } from './types.js';
|
||||
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
|
||||
import {
|
||||
@@ -53,6 +56,7 @@ import {
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
refreshServerHierarchicalMemory,
|
||||
flattenMemory,
|
||||
type MemoryChangedPayload,
|
||||
writeToStdout,
|
||||
disableMouseEvents,
|
||||
@@ -86,7 +90,6 @@ import { calculatePromptWidths } from './components/InputPrompt.js';
|
||||
import { useApp, useStdout, useStdin } from 'ink';
|
||||
import { calculateMainAreaWidth } from './utils/ui-sizing.js';
|
||||
import ansiEscapes from 'ansi-escapes';
|
||||
import * as fs from 'node:fs';
|
||||
import { basename } from 'node:path';
|
||||
import { computeTerminalTitle } from '../utils/windowTitle.js';
|
||||
import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||
@@ -104,7 +107,7 @@ import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
|
||||
import { useFolderTrust } from './hooks/useFolderTrust.js';
|
||||
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
|
||||
import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
|
||||
import { appEvents, AppEvent } from '../utils/events.js';
|
||||
import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js';
|
||||
import { type UpdateObject } from './utils/updateCheck.js';
|
||||
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
|
||||
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
|
||||
@@ -141,6 +144,7 @@ import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialo
|
||||
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
|
||||
import { isSlashCommand } from './utils/commandUtils.js';
|
||||
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
|
||||
import { useTimedMessage } from './hooks/useTimedMessage.js';
|
||||
import { isITerm2 } from './utils/terminalUtils.js';
|
||||
|
||||
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
||||
@@ -248,6 +252,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
const { bannerText } = useBanner(bannerData);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const extensionManager = config.getExtensionLoader() as ExtensionManager;
|
||||
// We are in the interactive CLI, update how we request consent and settings.
|
||||
extensionManager.setRequestConsent((description) =>
|
||||
@@ -319,6 +324,16 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const [currentModel, setCurrentModel] = useState(config.getModel());
|
||||
|
||||
const [userTier, setUserTier] = useState<UserTierId | undefined>(undefined);
|
||||
const [quotaStats, setQuotaStats] = useState<QuotaStats | undefined>(() => {
|
||||
const remaining = config.getQuotaRemaining();
|
||||
const limit = config.getQuotaLimit();
|
||||
const resetTime = config.getQuotaResetTime();
|
||||
return remaining !== undefined ||
|
||||
limit !== undefined ||
|
||||
resetTime !== undefined
|
||||
? { remaining, limit, resetTime }
|
||||
: undefined;
|
||||
});
|
||||
|
||||
const [isConfigInitialized, setConfigInitialized] = useState(false);
|
||||
|
||||
@@ -421,9 +436,23 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
setCurrentModel(config.getModel());
|
||||
};
|
||||
|
||||
const handleQuotaChanged = (payload: {
|
||||
remaining: number | undefined;
|
||||
limit: number | undefined;
|
||||
resetTime?: string;
|
||||
}) => {
|
||||
setQuotaStats({
|
||||
remaining: payload.remaining,
|
||||
limit: payload.limit,
|
||||
resetTime: payload.resetTime,
|
||||
});
|
||||
};
|
||||
|
||||
coreEvents.on(CoreEvent.ModelChanged, handleModelChanged);
|
||||
coreEvents.on(CoreEvent.QuotaChanged, handleQuotaChanged);
|
||||
return () => {
|
||||
coreEvents.off(CoreEvent.ModelChanged, handleModelChanged);
|
||||
coreEvents.off(CoreEvent.QuotaChanged, handleQuotaChanged);
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
@@ -466,15 +495,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100);
|
||||
|
||||
const isValidPath = useCallback((filePath: string): boolean => {
|
||||
try {
|
||||
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getPreferredEditor = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
() => settings.merged.general.preferredEditor as EditorType,
|
||||
[settings.merged.general.preferredEditor],
|
||||
);
|
||||
@@ -484,7 +506,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
viewport: { height: 10, width: inputWidth },
|
||||
stdin,
|
||||
setRawMode,
|
||||
isValidPath,
|
||||
escapePastedPaths: true,
|
||||
shellModeActive,
|
||||
getPreferredEditor,
|
||||
});
|
||||
@@ -844,6 +866,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const [authConsentRequest, setAuthConsentRequest] =
|
||||
useState<ConfirmationRequest | null>(null);
|
||||
const [permissionConfirmationRequest, setPermissionConfirmationRequest] =
|
||||
useState<PermissionConfirmationRequest | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleConsentRequest = (payload: ConsentRequestPayload) => {
|
||||
@@ -874,12 +898,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const { memoryContent, fileCount } =
|
||||
await refreshServerHierarchicalMemory(config);
|
||||
|
||||
const flattenedMemory = flattenMemory(memoryContent);
|
||||
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Memory refreshed successfully. ${
|
||||
memoryContent.length > 0
|
||||
? `Loaded ${memoryContent.length} characters from ${fileCount} file(s).`
|
||||
flattenedMemory.length > 0
|
||||
? `Loaded ${flattenedMemory.length} characters from ${fileCount} file(s).`
|
||||
: 'No memory content found.'
|
||||
}`,
|
||||
},
|
||||
@@ -887,7 +913,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
);
|
||||
if (config.getDebugMode()) {
|
||||
debugLogger.log(
|
||||
`[DEBUG] Refreshed memory content in config: ${memoryContent.substring(
|
||||
`[DEBUG] Refreshed memory content in config: ${flattenedMemory.substring(
|
||||
0,
|
||||
200,
|
||||
)}...`,
|
||||
@@ -1078,11 +1104,30 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
);
|
||||
|
||||
const handleFinalSubmit = useCallback(
|
||||
(submittedValue: string) => {
|
||||
async (submittedValue: string) => {
|
||||
const isSlash = isSlashCommand(submittedValue.trim());
|
||||
const isIdle = streamingState === StreamingState.Idle;
|
||||
|
||||
if (isSlash || (isIdle && isMcpReady)) {
|
||||
if (!isSlash) {
|
||||
const permissions = await checkPermissions(submittedValue, config);
|
||||
if (permissions.length > 0) {
|
||||
setPermissionConfirmationRequest({
|
||||
files: permissions,
|
||||
onComplete: (result) => {
|
||||
setPermissionConfirmationRequest(null);
|
||||
if (result.allowed) {
|
||||
permissions.forEach((p) =>
|
||||
config.getWorkspaceContext().addReadOnlyPath(p),
|
||||
);
|
||||
}
|
||||
void submitQuery(submittedValue);
|
||||
},
|
||||
});
|
||||
addInput(submittedValue);
|
||||
return;
|
||||
}
|
||||
}
|
||||
void submitQuery(submittedValue);
|
||||
} else {
|
||||
// Check messageQueue.length === 0 to only notify on the first queued item
|
||||
@@ -1103,6 +1148,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isMcpReady,
|
||||
streamingState,
|
||||
messageQueue.length,
|
||||
config,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1137,11 +1183,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
useLayoutEffect(() => {
|
||||
if (mainControlsRef.current) {
|
||||
const fullFooterMeasurement = measureElement(mainControlsRef.current);
|
||||
if (
|
||||
fullFooterMeasurement.height > 0 &&
|
||||
fullFooterMeasurement.height !== controlsHeight
|
||||
) {
|
||||
setControlsHeight(fullFooterMeasurement.height);
|
||||
const roundedHeight = Math.round(fullFooterMeasurement.height);
|
||||
if (roundedHeight > 0 && roundedHeight !== controlsHeight) {
|
||||
setControlsHeight(roundedHeight);
|
||||
}
|
||||
}
|
||||
}, [buffer, terminalWidth, terminalHeight, controlsHeight]);
|
||||
@@ -1221,7 +1265,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
!showPrivacyNotice &&
|
||||
geminiClient?.isInitialized?.()
|
||||
) {
|
||||
handleFinalSubmit(initialPrompt);
|
||||
void handleFinalSubmit(initialPrompt);
|
||||
initialPromptSubmitted.current = true;
|
||||
}
|
||||
}, [
|
||||
@@ -1269,7 +1313,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
>();
|
||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||
const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false);
|
||||
const [warningMessage, setWarningMessage] = useState<string | null>(null);
|
||||
|
||||
const [transientMessage, showTransientMessage] = useTimedMessage<{
|
||||
text: string;
|
||||
type: TransientMessageType;
|
||||
}>(WARNING_PROMPT_DURATION_MS);
|
||||
|
||||
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
|
||||
useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem);
|
||||
@@ -1281,41 +1329,42 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog);
|
||||
|
||||
const warningTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const tabFocusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleWarning = useCallback((message: string) => {
|
||||
setWarningMessage(message);
|
||||
if (warningTimeoutRef.current) {
|
||||
clearTimeout(warningTimeoutRef.current);
|
||||
}
|
||||
warningTimeoutRef.current = setTimeout(() => {
|
||||
setWarningMessage(null);
|
||||
}, WARNING_PROMPT_DURATION_MS);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const handleTransientMessage = (payload: {
|
||||
message: string;
|
||||
type: TransientMessageType;
|
||||
}) => {
|
||||
showTransientMessage({ text: payload.message, type: payload.type });
|
||||
};
|
||||
|
||||
// Handle timeout cleanup on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (warningTimeoutRef.current) {
|
||||
clearTimeout(warningTimeoutRef.current);
|
||||
}
|
||||
const handleSelectionWarning = () => {
|
||||
showTransientMessage({
|
||||
text: 'Press Ctrl-S to enter selection mode to copy text.',
|
||||
type: TransientMessageType.Warning,
|
||||
});
|
||||
};
|
||||
const handlePasteTimeout = () => {
|
||||
showTransientMessage({
|
||||
text: 'Paste Timed out. Possibly due to slow connection.',
|
||||
type: TransientMessageType.Warning,
|
||||
});
|
||||
};
|
||||
|
||||
appEvents.on(AppEvent.TransientMessage, handleTransientMessage);
|
||||
appEvents.on(AppEvent.SelectionWarning, handleSelectionWarning);
|
||||
appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||
|
||||
return () => {
|
||||
appEvents.off(AppEvent.TransientMessage, handleTransientMessage);
|
||||
appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning);
|
||||
appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||
if (tabFocusTimeoutRef.current) {
|
||||
clearTimeout(tabFocusTimeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePasteTimeout = () => {
|
||||
handleWarning('Paste Timed out. Possibly due to slow connection.');
|
||||
};
|
||||
appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||
return () => {
|
||||
appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||
};
|
||||
}, [handleWarning]);
|
||||
}, [showTransientMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ideNeedsRestart) {
|
||||
@@ -1483,7 +1532,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const undoMessage = isITerm2()
|
||||
? 'Undo has been moved to Option + Z'
|
||||
: 'Undo has been moved to Alt/Option + Z or Cmd + Z';
|
||||
handleWarning(undoMessage);
|
||||
showTransientMessage({
|
||||
text: undoMessage,
|
||||
type: TransientMessageType.Warning,
|
||||
});
|
||||
return true;
|
||||
} else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) {
|
||||
setShowFullTodos((prev) => !prev);
|
||||
@@ -1523,7 +1575,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
if (lastOutputTimeRef.current === capturedTime) {
|
||||
setEmbeddedShellFocused(false);
|
||||
} else {
|
||||
handleWarning('Use Shift+Tab to unfocus');
|
||||
showTransientMessage({
|
||||
text: 'Use Shift+Tab to unfocus',
|
||||
type: TransientMessageType.Warning,
|
||||
});
|
||||
}
|
||||
}, 150);
|
||||
return false;
|
||||
@@ -1603,7 +1658,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setIsBackgroundShellListOpen,
|
||||
lastOutputTimeRef,
|
||||
tabFocusTimeoutRef,
|
||||
handleWarning,
|
||||
showTransientMessage,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1714,6 +1769,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
adminSettingsChanged ||
|
||||
!!commandConfirmationRequest ||
|
||||
!!authConsentRequest ||
|
||||
!!permissionConfirmationRequest ||
|
||||
!!customDialog ||
|
||||
confirmUpdateExtensionRequests.length > 0 ||
|
||||
!!loopDetectionConfirmationRequest ||
|
||||
@@ -1819,6 +1875,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
authConsentRequest,
|
||||
confirmUpdateExtensionRequests,
|
||||
loopDetectionConfirmationRequest,
|
||||
permissionConfirmationRequest,
|
||||
geminiMdFileCount,
|
||||
streamingState,
|
||||
initError,
|
||||
@@ -1853,9 +1910,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
queueErrorMessage,
|
||||
showApprovalModeIndicator,
|
||||
currentModel,
|
||||
userTier,
|
||||
proQuotaRequest,
|
||||
validationRequest,
|
||||
quota: {
|
||||
userTier,
|
||||
stats: quotaStats,
|
||||
proQuotaRequest,
|
||||
validationRequest,
|
||||
},
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
availableTerminalHeight,
|
||||
@@ -1884,7 +1944,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
showDebugProfiler,
|
||||
customDialog,
|
||||
copyModeEnabled,
|
||||
warningMessage,
|
||||
transientMessage,
|
||||
bannerData,
|
||||
bannerVisible,
|
||||
terminalBackgroundColor: config.getTerminalBackground(),
|
||||
@@ -1925,6 +1985,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
authConsentRequest,
|
||||
confirmUpdateExtensionRequests,
|
||||
loopDetectionConfirmationRequest,
|
||||
permissionConfirmationRequest,
|
||||
geminiMdFileCount,
|
||||
streamingState,
|
||||
initError,
|
||||
@@ -1959,6 +2020,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
queueErrorMessage,
|
||||
showApprovalModeIndicator,
|
||||
userTier,
|
||||
quotaStats,
|
||||
proQuotaRequest,
|
||||
validationRequest,
|
||||
contextFileNames,
|
||||
@@ -1993,7 +2055,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
apiKeyDefaultValue,
|
||||
authState,
|
||||
copyModeEnabled,
|
||||
warningMessage,
|
||||
transientMessage,
|
||||
bannerData,
|
||||
bannerVisible,
|
||||
config,
|
||||
@@ -2050,7 +2112,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleApiKeyCancel,
|
||||
setBannerVisible,
|
||||
setShortcutsHelpVisible,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
@@ -2127,7 +2188,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleApiKeyCancel,
|
||||
setBannerVisible,
|
||||
setShortcutsHelpVisible,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
|
||||
@@ -49,7 +49,6 @@ export function ApiAuthDialog({
|
||||
width: viewportWidth,
|
||||
height: 4,
|
||||
},
|
||||
isValidPath: () => false, // No path validation needed for API key
|
||||
inputFilter: (text) =>
|
||||
text.replace(/[^a-zA-Z0-9_-]/g, '').replace(/[\r\n]/g, ''),
|
||||
singleLine: true,
|
||||
|
||||
@@ -88,8 +88,10 @@ export function AuthDialog({
|
||||
const defaultAuthTypeEnv = process.env['GEMINI_DEFAULT_AUTH_TYPE'];
|
||||
if (
|
||||
defaultAuthTypeEnv &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
Object.values(AuthType).includes(defaultAuthTypeEnv as AuthType)
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
defaultAuthType = defaultAuthTypeEnv as AuthType;
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ export const useAuthCommand = (
|
||||
const defaultAuthType = process.env['GEMINI_DEFAULT_AUTH_TYPE'];
|
||||
if (
|
||||
defaultAuthType &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
!Object.values(AuthType).includes(defaultAuthType as AuthType)
|
||||
) {
|
||||
onAuthError(
|
||||
|
||||
@@ -213,6 +213,7 @@ const resumeCommand: SlashCommand = {
|
||||
continue;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
uiHistory.push({
|
||||
type: (item.role && rolemap[item.role]) || MessageType.GEMINI,
|
||||
text,
|
||||
|
||||
@@ -49,6 +49,7 @@ async function finishAddingDirectories(
|
||||
text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`,
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
errors.push(`Error refreshing memory: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ export const initCommand: SlashCommand = {
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return result as SlashCommandActionReturn;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
showMemory,
|
||||
addMemory,
|
||||
listMemoryFiles,
|
||||
flattenMemory,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
@@ -33,7 +34,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
refreshMemory: vi.fn(async (config) => {
|
||||
if (config.isJitContextEnabled()) {
|
||||
await config.getContextManager()?.refresh();
|
||||
const memoryContent = config.getUserMemory() || '';
|
||||
const memoryContent = original.flattenMemory(config.getUserMemory());
|
||||
const fileCount = config.getGeminiMdFileCount() || 0;
|
||||
return {
|
||||
type: 'message',
|
||||
@@ -85,7 +86,7 @@ describe('memoryCommand', () => {
|
||||
mockGetGeminiMdFileCount = vi.fn();
|
||||
|
||||
vi.mocked(showMemory).mockImplementation((config) => {
|
||||
const memoryContent = config.getUserMemory() || '';
|
||||
const memoryContent = flattenMemory(config.getUserMemory());
|
||||
const fileCount = config.getGeminiMdFileCount() || 0;
|
||||
let content;
|
||||
if (memoryContent.length > 0) {
|
||||
|
||||
@@ -93,6 +93,7 @@ export const memoryCommand: SlashCommand = {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
text: `Error refreshing memory: ${(error as Error).message}`,
|
||||
},
|
||||
Date.now(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -54,6 +54,7 @@ describe('statsCommand', () => {
|
||||
selectedAuthType: '',
|
||||
tier: undefined,
|
||||
userEmail: 'mock@example.com',
|
||||
currentModel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,9 +64,20 @@ describe('statsCommand', () => {
|
||||
const mockQuota = { buckets: [] };
|
||||
const mockRefreshUserQuota = vi.fn().mockResolvedValue(mockQuota);
|
||||
const mockGetUserTierName = vi.fn().mockReturnValue('Basic');
|
||||
const mockGetModel = vi.fn().mockReturnValue('gemini-pro');
|
||||
const mockGetQuotaRemaining = vi.fn().mockReturnValue(85);
|
||||
const mockGetQuotaLimit = vi.fn().mockReturnValue(100);
|
||||
const mockGetQuotaResetTime = vi
|
||||
.fn()
|
||||
.mockReturnValue('2025-01-01T12:00:00Z');
|
||||
|
||||
mockContext.services.config = {
|
||||
refreshUserQuota: mockRefreshUserQuota,
|
||||
getUserTierName: mockGetUserTierName,
|
||||
getModel: mockGetModel,
|
||||
getQuotaRemaining: mockGetQuotaRemaining,
|
||||
getQuotaLimit: mockGetQuotaLimit,
|
||||
getQuotaResetTime: mockGetQuotaResetTime,
|
||||
} as unknown as Config;
|
||||
|
||||
await statsCommand.action(mockContext, '');
|
||||
@@ -75,6 +87,10 @@ describe('statsCommand', () => {
|
||||
expect.objectContaining({
|
||||
quotas: mockQuota,
|
||||
tier: 'Basic',
|
||||
currentModel: 'gemini-pro',
|
||||
pooledRemaining: 85,
|
||||
pooledLimit: 100,
|
||||
pooledResetTime: '2025-01-01T12:00:00Z',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -93,6 +109,9 @@ describe('statsCommand', () => {
|
||||
selectedAuthType: '',
|
||||
tier: undefined,
|
||||
userEmail: 'mock@example.com',
|
||||
currentModel: undefined,
|
||||
pooledRemaining: undefined,
|
||||
pooledLimit: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -44,6 +44,7 @@ async function defaultSessionView(context: CommandContext) {
|
||||
const wallDuration = now.getTime() - sessionStartTime.getTime();
|
||||
|
||||
const { selectedAuthType, userEmail, tier } = getUserIdentity(context);
|
||||
const currentModel = context.services.config?.getModel();
|
||||
|
||||
const statsItem: HistoryItemStats = {
|
||||
type: MessageType.STATS,
|
||||
@@ -51,12 +52,16 @@ async function defaultSessionView(context: CommandContext) {
|
||||
selectedAuthType,
|
||||
userEmail,
|
||||
tier,
|
||||
currentModel,
|
||||
};
|
||||
|
||||
if (context.services.config) {
|
||||
const quota = await context.services.config.refreshUserQuota();
|
||||
if (quota) {
|
||||
statsItem.quotas = quota;
|
||||
statsItem.pooledRemaining = context.services.config.getQuotaRemaining();
|
||||
statsItem.pooledLimit = context.services.config.getQuotaLimit();
|
||||
statsItem.pooledResetTime = context.services.config.getQuotaResetTime();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,11 +94,19 @@ export const statsCommand: SlashCommand = {
|
||||
autoExecute: true,
|
||||
action: (context: CommandContext) => {
|
||||
const { selectedAuthType, userEmail, tier } = getUserIdentity(context);
|
||||
const currentModel = context.services.config?.getModel();
|
||||
const pooledRemaining = context.services.config?.getQuotaRemaining();
|
||||
const pooledLimit = context.services.config?.getQuotaLimit();
|
||||
const pooledResetTime = context.services.config?.getQuotaResetTime();
|
||||
context.ui.addItem({
|
||||
type: MessageType.MODEL_STATS,
|
||||
selectedAuthType,
|
||||
userEmail,
|
||||
tier,
|
||||
currentModel,
|
||||
pooledRemaining,
|
||||
pooledLimit,
|
||||
pooledResetTime,
|
||||
} as HistoryItemModelStats);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -123,6 +123,7 @@ function getNestedValue(
|
||||
for (const key of path) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
if (typeof current !== 'object') return undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return current;
|
||||
@@ -144,8 +145,10 @@ function setNestedValue(
|
||||
if (current[key] === undefined || current[key] === null) {
|
||||
current[key] = {};
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
current[key] = { ...(current[key] as Record<string, unknown>) };
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
current = current[key] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -265,6 +268,7 @@ export function AgentConfigDialog({
|
||||
() =>
|
||||
AGENT_CONFIG_FIELDS.map((field) => {
|
||||
const currentValue = getNestedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
field.path,
|
||||
);
|
||||
@@ -300,6 +304,7 @@ export function AgentConfigDialog({
|
||||
displayValue,
|
||||
isGreyedOut: currentValue === undefined,
|
||||
scopeMessage: undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
rawValue: rawValue as string | number | boolean | undefined,
|
||||
};
|
||||
}),
|
||||
@@ -320,6 +325,7 @@ export function AgentConfigDialog({
|
||||
if (!field || field.type !== 'boolean') return;
|
||||
|
||||
const currentValue = getNestedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
field.path,
|
||||
);
|
||||
@@ -329,6 +335,7 @@ export function AgentConfigDialog({
|
||||
const newValue = !effectiveValue;
|
||||
|
||||
const newOverride = setNestedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
field.path,
|
||||
newValue,
|
||||
@@ -369,6 +376,7 @@ export function AgentConfigDialog({
|
||||
|
||||
// Update pending override locally
|
||||
const newOverride = setNestedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
field.path,
|
||||
parsed,
|
||||
@@ -391,6 +399,7 @@ export function AgentConfigDialog({
|
||||
|
||||
// Remove the override (set to undefined)
|
||||
const newOverride = setNestedValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
pendingOverride as Record<string, unknown>,
|
||||
field.path,
|
||||
undefined,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
||||
import { QuittingDisplay } from './QuittingDisplay.js';
|
||||
@@ -15,15 +16,18 @@ import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { ToolStatusIndicator, ToolInfo } from './messages/ToolShared.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
||||
|
||||
export const AlternateBufferQuittingDisplay = () => {
|
||||
const { version } = useAppContext();
|
||||
const uiState = useUIState();
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
|
||||
const confirmingTool = useConfirmingTool();
|
||||
const showPromptedTool =
|
||||
config.isEventDrivenSchedulerEnabled() && confirmingTool !== null;
|
||||
const inlineThinkingMode = getInlineThinkingMode(settings);
|
||||
|
||||
// We render the entire chat history and header here to ensure that the
|
||||
// conversation history is visible to the user after the app quits and the
|
||||
@@ -47,6 +51,7 @@ export const AlternateBufferQuittingDisplay = () => {
|
||||
item={h}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
inlineThinkingMode={inlineThinkingMode}
|
||||
/>
|
||||
))}
|
||||
{uiState.pendingHistoryItems.map((item, i) => (
|
||||
@@ -59,6 +64,7 @@ export const AlternateBufferQuittingDisplay = () => {
|
||||
isFocused={false}
|
||||
activeShellPtyId={uiState.activePtyId}
|
||||
embeddedShellFocused={uiState.embeddedShellFocused}
|
||||
inlineThinkingMode={inlineThinkingMode}
|
||||
/>
|
||||
))}
|
||||
{showPromptedTool && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ describe('ApprovalModeIndicator', () => {
|
||||
<ApprovalModeIndicator approvalMode={ApprovalMode.AUTO_EDIT} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('auto-edit');
|
||||
expect(output).toContain('shift + tab to enter default mode');
|
||||
expect(output).toContain('auto-accept edits');
|
||||
expect(output).toContain('shift+tab to manual');
|
||||
});
|
||||
|
||||
it('renders correctly for AUTO_EDIT mode with plan enabled', () => {
|
||||
@@ -27,8 +27,8 @@ describe('ApprovalModeIndicator', () => {
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('auto-edit');
|
||||
expect(output).toContain('shift + tab to enter default mode');
|
||||
expect(output).toContain('auto-accept edits');
|
||||
expect(output).toContain('shift+tab to manual');
|
||||
});
|
||||
|
||||
it('renders correctly for PLAN mode', () => {
|
||||
@@ -37,7 +37,7 @@ describe('ApprovalModeIndicator', () => {
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('plan');
|
||||
expect(output).toContain('shift + tab to enter auto-edit mode');
|
||||
expect(output).toContain('shift+tab to accept edits');
|
||||
});
|
||||
|
||||
it('renders correctly for YOLO mode', () => {
|
||||
@@ -46,7 +46,7 @@ describe('ApprovalModeIndicator', () => {
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('YOLO');
|
||||
expect(output).toContain('shift + tab to enter auto-edit mode');
|
||||
expect(output).toContain('ctrl+y');
|
||||
});
|
||||
|
||||
it('renders correctly for DEFAULT mode', () => {
|
||||
@@ -54,7 +54,7 @@ describe('ApprovalModeIndicator', () => {
|
||||
<ApprovalModeIndicator approvalMode={ApprovalMode.DEFAULT} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('shift + tab to enter auto-edit mode');
|
||||
expect(output).toContain('shift+tab to accept edits');
|
||||
});
|
||||
|
||||
it('renders correctly for DEFAULT mode with plan enabled', () => {
|
||||
@@ -65,6 +65,6 @@ describe('ApprovalModeIndicator', () => {
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('shift + tab to enter plan mode');
|
||||
expect(output).toContain('shift+tab to plan');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,26 +25,26 @@ export const ApprovalModeIndicator: React.FC<ApprovalModeIndicatorProps> = ({
|
||||
switch (approvalMode) {
|
||||
case ApprovalMode.AUTO_EDIT:
|
||||
textColor = theme.status.warning;
|
||||
textContent = 'auto-edit';
|
||||
subText = 'shift + tab to enter default mode';
|
||||
textContent = 'auto-accept edits';
|
||||
subText = 'shift+tab to manual';
|
||||
break;
|
||||
case ApprovalMode.PLAN:
|
||||
textColor = theme.status.success;
|
||||
textContent = 'plan';
|
||||
subText = 'shift + tab to enter auto-edit mode';
|
||||
subText = 'shift+tab to accept edits';
|
||||
break;
|
||||
case ApprovalMode.YOLO:
|
||||
textColor = theme.status.error;
|
||||
textContent = 'YOLO';
|
||||
subText = 'shift + tab to enter auto-edit mode';
|
||||
subText = 'ctrl+y';
|
||||
break;
|
||||
case ApprovalMode.DEFAULT:
|
||||
default:
|
||||
textColor = theme.text.accent;
|
||||
textContent = '';
|
||||
subText = isPlanEnabled
|
||||
? 'shift + tab to enter plan mode'
|
||||
: 'shift + tab to enter auto-edit mode';
|
||||
? 'shift+tab to plan'
|
||||
: 'shift+tab to accept edits';
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -285,7 +285,6 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
initialText: initialAnswer,
|
||||
viewport: { width: Math.max(1, bufferWidth), height: 1 },
|
||||
singleLine: true,
|
||||
isValidPath: () => false,
|
||||
});
|
||||
|
||||
const { text: textValue } = buffer;
|
||||
@@ -564,7 +563,6 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
initialText: initialCustomText,
|
||||
viewport: { width: Math.max(1, bufferWidth), height: 1 },
|
||||
singleLine: true,
|
||||
isValidPath: () => false,
|
||||
});
|
||||
|
||||
const customOptionText = customBuffer.text;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { BackgroundShellDisplay } from './BackgroundShellDisplay.js';
|
||||
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
||||
import { ShellExecutionService } from '@google/gemini-cli-core';
|
||||
@@ -20,16 +20,12 @@ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const mockDismissBackgroundShell = vi.fn();
|
||||
const mockSetActiveBackgroundShellPid = vi.fn();
|
||||
const mockSetIsBackgroundShellListOpen = vi.fn();
|
||||
const mockHandleWarning = vi.fn();
|
||||
const mockSetEmbeddedShellFocused = vi.fn();
|
||||
|
||||
vi.mock('../contexts/UIActionsContext.js', () => ({
|
||||
useUIActions: () => ({
|
||||
dismissBackgroundShell: mockDismissBackgroundShell,
|
||||
setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen,
|
||||
handleWarning: mockHandleWarning,
|
||||
setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -103,6 +99,10 @@ vi.mock('./shared/ScrollableList.js', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const createMockKey = (overrides: Partial<Key>): Key => ({
|
||||
name: '',
|
||||
ctrl: false,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Composer } from './Composer.js';
|
||||
@@ -24,13 +24,41 @@ vi.mock('../contexts/VimModeContext.js', () => ({
|
||||
})),
|
||||
}));
|
||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { StreamingState, ToolCallStatus } from '../types.js';
|
||||
import { TransientMessageType } from '../../utils/events.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import type { SessionMetrics } from '../contexts/SessionContext.js';
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./LoadingIndicator.js', () => ({
|
||||
LoadingIndicator: ({ thought }: { thought?: string }) => (
|
||||
<Text>LoadingIndicator{thought ? `: ${thought}` : ''}</Text>
|
||||
),
|
||||
LoadingIndicator: ({
|
||||
thought,
|
||||
thoughtLabel,
|
||||
}: {
|
||||
thought?: { subject?: string } | string;
|
||||
thoughtLabel?: string;
|
||||
}) => {
|
||||
const fallbackText =
|
||||
typeof thought === 'string' ? thought : thought?.subject;
|
||||
const text = thoughtLabel ?? fallbackText;
|
||||
return <Text>LoadingIndicator{text ? `: ${text}` : ''}</Text>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./StatusDisplay.js', () => ({
|
||||
StatusDisplay: () => <Text>StatusDisplay</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./ToastDisplay.js', () => ({
|
||||
ToastDisplay: () => <Text>ToastDisplay</Text>,
|
||||
shouldShowToast: (uiState: UIState) =>
|
||||
uiState.ctrlCPressedOnce ||
|
||||
Boolean(uiState.transientMessage) ||
|
||||
uiState.ctrlDPressedOnce ||
|
||||
(uiState.showEscapePrompt &&
|
||||
(uiState.buffer.text.length > 0 || uiState.history.length > 0)) ||
|
||||
Boolean(uiState.queueErrorMessage),
|
||||
}));
|
||||
|
||||
vi.mock('./ContextSummaryDisplay.js', () => ({
|
||||
@@ -145,6 +173,12 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
activeHooks: [],
|
||||
isBackgroundShellVisible: false,
|
||||
embeddedShellFocused: false,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: undefined,
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
},
|
||||
...overrides,
|
||||
}) as UIState;
|
||||
|
||||
@@ -155,31 +189,30 @@ const createMockUIActions = (): UIActions =>
|
||||
setShellModeActive: vi.fn(),
|
||||
onEscapePromptChange: vi.fn(),
|
||||
vimHandleInput: vi.fn(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any;
|
||||
}) as Partial<UIActions> as UIActions;
|
||||
|
||||
const createMockConfig = (overrides = {}) => ({
|
||||
getModel: vi.fn(() => 'gemini-1.5-pro'),
|
||||
getTargetDir: vi.fn(() => '/test/dir'),
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getAccessibility: vi.fn(() => ({})),
|
||||
getMcpServers: vi.fn(() => ({})),
|
||||
isPlanEnabled: vi.fn(() => false),
|
||||
getToolRegistry: () => ({
|
||||
getTool: vi.fn(),
|
||||
}),
|
||||
getSkillManager: () => ({
|
||||
getSkills: () => [],
|
||||
getDisplayableSkills: () => [],
|
||||
}),
|
||||
getMcpClientManager: () => ({
|
||||
getMcpServers: () => ({}),
|
||||
getBlockedMcpServers: () => [],
|
||||
}),
|
||||
...overrides,
|
||||
});
|
||||
const createMockConfig = (overrides = {}): Config =>
|
||||
({
|
||||
getModel: vi.fn(() => 'gemini-1.5-pro'),
|
||||
getTargetDir: vi.fn(() => '/test/dir'),
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getAccessibility: vi.fn(() => ({})),
|
||||
getMcpServers: vi.fn(() => ({})),
|
||||
isPlanEnabled: vi.fn(() => false),
|
||||
getToolRegistry: () => ({
|
||||
getTool: vi.fn(),
|
||||
}),
|
||||
getSkillManager: () => ({
|
||||
getSkills: () => [],
|
||||
getDisplayableSkills: () => [],
|
||||
}),
|
||||
getMcpClientManager: () => ({
|
||||
getMcpServers: () => ({}),
|
||||
getBlockedMcpServers: () => [],
|
||||
}),
|
||||
...overrides,
|
||||
}) as unknown as Config;
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const renderComposer = (
|
||||
uiState: UIState,
|
||||
settings = createMockSettings(),
|
||||
@@ -187,8 +220,8 @@ const renderComposer = (
|
||||
uiActions = createMockUIActions(),
|
||||
) =>
|
||||
render(
|
||||
<ConfigContext.Provider value={config as any}>
|
||||
<SettingsContext.Provider value={settings as any}>
|
||||
<ConfigContext.Provider value={config as unknown as Config}>
|
||||
<SettingsContext.Provider value={settings as unknown as LoadedSettings}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<UIActionsContext.Provider value={uiActions}>
|
||||
<Composer />
|
||||
@@ -197,9 +230,12 @@ const renderComposer = (
|
||||
</SettingsContext.Provider>
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
describe('Composer', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Footer Display Settings', () => {
|
||||
it('renders Footer by default when hideFooter is false', () => {
|
||||
const uiState = createMockUIState();
|
||||
@@ -229,8 +265,11 @@ describe('Composer', () => {
|
||||
sessionStats: {
|
||||
sessionId: 'test-session',
|
||||
sessionStartTime: new Date(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
metrics: {} as any,
|
||||
metrics: {
|
||||
models: {},
|
||||
tools: {},
|
||||
files: {},
|
||||
} as SessionMetrics,
|
||||
lastPromptTokenCount: 150,
|
||||
promptCount: 5,
|
||||
},
|
||||
@@ -251,8 +290,9 @@ describe('Composer', () => {
|
||||
vi.mocked(useVimMode).mockReturnValueOnce({
|
||||
vimEnabled: true,
|
||||
vimMode: 'INSERT',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
toggleVimEnabled: vi.fn(),
|
||||
setVimMode: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useVimMode>);
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings, config);
|
||||
|
||||
@@ -276,7 +316,25 @@ describe('Composer', () => {
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
expect(output).toContain('LoadingIndicator: Processing');
|
||||
});
|
||||
|
||||
it('renders generic thinking text in loading indicator when full inline thinking is enabled', () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
thought: {
|
||||
subject: 'Detailed in-history thought',
|
||||
description: 'Full text is already in history',
|
||||
},
|
||||
});
|
||||
const settings = createMockSettings({
|
||||
ui: { inlineThinkingMode: 'full' },
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('LoadingIndicator: Thinking ...');
|
||||
});
|
||||
|
||||
it('keeps shortcuts hint visible while loading', () => {
|
||||
@@ -410,7 +468,7 @@ describe('Composer', () => {
|
||||
});
|
||||
|
||||
describe('Context and Status Display', () => {
|
||||
it('shows ContextSummaryDisplay in normal state', () => {
|
||||
it('shows StatusDisplay and ApprovalModeIndicator in normal state', () => {
|
||||
const uiState = createMockUIState({
|
||||
ctrlCPressedOnce: false,
|
||||
ctrlDPressedOnce: false,
|
||||
@@ -419,49 +477,38 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('ContextSummaryDisplay');
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('StatusDisplay');
|
||||
expect(output).toContain('ApprovalModeIndicator');
|
||||
expect(output).not.toContain('ToastDisplay');
|
||||
});
|
||||
|
||||
it('renders HookStatusDisplay instead of ContextSummaryDisplay with active hooks', () => {
|
||||
const uiState = createMockUIState({
|
||||
activeHooks: [{ name: 'test-hook', eventName: 'before-agent' }],
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('HookStatusDisplay');
|
||||
expect(lastFrame()).not.toContain('ContextSummaryDisplay');
|
||||
});
|
||||
|
||||
it('shows Ctrl+C exit prompt when ctrlCPressedOnce is true', () => {
|
||||
it('shows ToastDisplay and hides ApprovalModeIndicator when a toast is present', () => {
|
||||
const uiState = createMockUIState({
|
||||
ctrlCPressedOnce: true,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('Press Ctrl+C again to exit');
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('ToastDisplay');
|
||||
expect(output).not.toContain('ApprovalModeIndicator');
|
||||
expect(output).toContain('StatusDisplay');
|
||||
});
|
||||
|
||||
it('shows Ctrl+D exit prompt when ctrlDPressedOnce is true', () => {
|
||||
it('shows ToastDisplay for other toast types', () => {
|
||||
const uiState = createMockUIState({
|
||||
ctrlDPressedOnce: true,
|
||||
transientMessage: {
|
||||
text: 'Warning',
|
||||
type: TransientMessageType.Warning,
|
||||
},
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('Press Ctrl+D again to exit');
|
||||
});
|
||||
|
||||
it('shows escape prompt when showEscapePrompt is true', () => {
|
||||
const uiState = createMockUIState({
|
||||
showEscapePrompt: true,
|
||||
history: [{ id: 1, type: 'user', text: 'test' }],
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('Press Esc again to rewind');
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('ToastDisplay');
|
||||
expect(output).not.toContain('ApprovalModeIndicator');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -541,9 +588,12 @@ describe('Composer', () => {
|
||||
const uiState = createMockUIState({
|
||||
showErrorDetails: true,
|
||||
filteredConsoleMessages: [
|
||||
{ level: 'error', message: 'Test error', timestamp: new Date() },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any,
|
||||
{
|
||||
type: 'error',
|
||||
content: 'Test error',
|
||||
count: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { Box, useIsScreenReaderEnabled } from 'ink';
|
||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||
import { StatusDisplay } from './StatusDisplay.js';
|
||||
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
|
||||
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
|
||||
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
||||
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
|
||||
@@ -30,7 +31,7 @@ import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { StreamingState, ToolCallStatus } from '../types.js';
|
||||
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
|
||||
import { TodoTray } from './messages/Todo.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
||||
|
||||
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
const config = useConfig();
|
||||
@@ -39,7 +40,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
const uiState = useUIState();
|
||||
const uiActions = useUIActions();
|
||||
const { vimEnabled, vimMode } = useVimMode();
|
||||
const terminalWidth = process.stdout.columns;
|
||||
const inlineThinkingMode = getInlineThinkingMode(settings);
|
||||
const terminalWidth = uiState.terminalWidth;
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
|
||||
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
|
||||
@@ -60,18 +62,16 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
Boolean(uiState.authConsentRequest) ||
|
||||
(uiState.confirmUpdateExtensionRequests?.length ?? 0) > 0 ||
|
||||
Boolean(uiState.loopDetectionConfirmationRequest) ||
|
||||
Boolean(uiState.proQuotaRequest) ||
|
||||
Boolean(uiState.validationRequest) ||
|
||||
Boolean(uiState.quota.proQuotaRequest) ||
|
||||
Boolean(uiState.quota.validationRequest) ||
|
||||
Boolean(uiState.customDialog);
|
||||
const hasToast = shouldShowToast(uiState);
|
||||
const showLoadingIndicator =
|
||||
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
|
||||
uiState.streamingState === StreamingState.Responding &&
|
||||
!hasPendingActionRequired;
|
||||
const showApprovalIndicator = !uiState.shellModeActive;
|
||||
const showRawMarkdownIndicator = !uiState.renderMarkdown;
|
||||
const showEscToCancelHint =
|
||||
showLoadingIndicator &&
|
||||
uiState.streamingState !== StreamingState.WaitingForConfirmation;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -93,11 +93,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
<TodoTray />
|
||||
|
||||
<Box marginTop={1} width="100%" flexDirection="column">
|
||||
{showEscToCancelHint && (
|
||||
<Box marginLeft={3}>
|
||||
<Text color={theme.text.secondary}>esc to cancel</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
@@ -126,8 +121,10 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
? undefined
|
||||
: uiState.currentLoadingPhrase
|
||||
}
|
||||
thoughtLabel={
|
||||
inlineThinkingMode === 'full' ? 'Thinking ...' : undefined
|
||||
}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
showCancelAndTimer={false}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
@@ -158,44 +155,48 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
alignItems="center"
|
||||
flexGrow={1}
|
||||
>
|
||||
{!showLoadingIndicator && (
|
||||
<Box
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
{showApprovalIndicator && (
|
||||
<ApprovalModeIndicator
|
||||
approvalMode={showApprovalModeIndicator}
|
||||
isPlanEnabled={config.isPlanEnabled()}
|
||||
/>
|
||||
)}
|
||||
{uiState.shellModeActive && (
|
||||
<Box
|
||||
marginLeft={showApprovalIndicator && !isNarrow ? 1 : 0}
|
||||
marginTop={showApprovalIndicator && isNarrow ? 1 : 0}
|
||||
>
|
||||
<ShellModeIndicator />
|
||||
</Box>
|
||||
)}
|
||||
{showRawMarkdownIndicator && (
|
||||
<Box
|
||||
marginLeft={
|
||||
(showApprovalIndicator || uiState.shellModeActive) &&
|
||||
!isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
marginTop={
|
||||
(showApprovalIndicator || uiState.shellModeActive) &&
|
||||
isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
>
|
||||
<RawMarkdownIndicator />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{hasToast ? (
|
||||
<ToastDisplay />
|
||||
) : (
|
||||
!showLoadingIndicator && (
|
||||
<Box
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
{showApprovalIndicator && (
|
||||
<ApprovalModeIndicator
|
||||
approvalMode={showApprovalModeIndicator}
|
||||
isPlanEnabled={config.isPlanEnabled()}
|
||||
/>
|
||||
)}
|
||||
{uiState.shellModeActive && (
|
||||
<Box
|
||||
marginLeft={showApprovalIndicator && !isNarrow ? 1 : 0}
|
||||
marginTop={showApprovalIndicator && isNarrow ? 1 : 0}
|
||||
>
|
||||
<ShellModeIndicator />
|
||||
</Box>
|
||||
)}
|
||||
{showRawMarkdownIndicator && (
|
||||
<Box
|
||||
marginLeft={
|
||||
(showApprovalIndicator || uiState.shellModeActive) &&
|
||||
!isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
marginTop={
|
||||
(showApprovalIndicator || uiState.shellModeActive) &&
|
||||
isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
>
|
||||
<RawMarkdownIndicator />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ export const ConfigExtensionDialog: React.FC<ConfigExtensionDialogProps> = ({
|
||||
initialText: '',
|
||||
viewport: { width: 80, height: 1 },
|
||||
singleLine: true,
|
||||
isValidPath: () => true,
|
||||
escapePastedPaths: true,
|
||||
});
|
||||
|
||||
const mounted = useRef(true);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -25,7 +25,7 @@ export const ConsentPrompt = (props: ConsentPromptProps) => {
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
paddingY={1}
|
||||
paddingTop={1}
|
||||
paddingX={2}
|
||||
>
|
||||
{typeof prompt === 'string' ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -75,7 +75,12 @@ describe('DialogManager', () => {
|
||||
terminalWidth: 80,
|
||||
confirmUpdateExtensionRequests: [],
|
||||
showIdeRestartPrompt: false,
|
||||
proQuotaRequest: null,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: undefined,
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
},
|
||||
shouldShowIdePrompt: false,
|
||||
isFolderTrustDialogOpen: false,
|
||||
loopDetectionConfirmationRequest: null,
|
||||
@@ -99,8 +104,7 @@ describe('DialogManager', () => {
|
||||
it('renders nothing by default', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<DialogManager {...defaultProps} />,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ uiState: baseUiState as any },
|
||||
{ uiState: baseUiState as Partial<UIState> as UIState },
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
@@ -115,12 +119,17 @@ describe('DialogManager', () => {
|
||||
],
|
||||
[
|
||||
{
|
||||
proQuotaRequest: {
|
||||
failedModel: 'a',
|
||||
fallbackModel: 'b',
|
||||
message: 'c',
|
||||
isTerminalQuotaError: false,
|
||||
resolve: vi.fn(),
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: undefined,
|
||||
proQuotaRequest: {
|
||||
failedModel: 'a',
|
||||
fallbackModel: 'b',
|
||||
message: 'c',
|
||||
isTerminalQuotaError: false,
|
||||
resolve: vi.fn(),
|
||||
},
|
||||
validationRequest: null,
|
||||
},
|
||||
},
|
||||
'ProQuotaDialog',
|
||||
@@ -185,8 +194,10 @@ describe('DialogManager', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<DialogManager {...defaultProps} />,
|
||||
{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
uiState: { ...baseUiState, ...uiStateOverride } as any,
|
||||
uiState: {
|
||||
...baseUiState,
|
||||
...uiStateOverride,
|
||||
} as Partial<UIState> as UIState,
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toContain(expectedComponent);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -71,24 +71,30 @@ export const DialogManager = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.proQuotaRequest) {
|
||||
if (uiState.quota.proQuotaRequest) {
|
||||
return (
|
||||
<ProQuotaDialog
|
||||
failedModel={uiState.proQuotaRequest.failedModel}
|
||||
fallbackModel={uiState.proQuotaRequest.fallbackModel}
|
||||
message={uiState.proQuotaRequest.message}
|
||||
isTerminalQuotaError={uiState.proQuotaRequest.isTerminalQuotaError}
|
||||
isModelNotFoundError={!!uiState.proQuotaRequest.isModelNotFoundError}
|
||||
failedModel={uiState.quota.proQuotaRequest.failedModel}
|
||||
fallbackModel={uiState.quota.proQuotaRequest.fallbackModel}
|
||||
message={uiState.quota.proQuotaRequest.message}
|
||||
isTerminalQuotaError={
|
||||
uiState.quota.proQuotaRequest.isTerminalQuotaError
|
||||
}
|
||||
isModelNotFoundError={
|
||||
!!uiState.quota.proQuotaRequest.isModelNotFoundError
|
||||
}
|
||||
onChoice={uiActions.handleProQuotaChoice}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.validationRequest) {
|
||||
if (uiState.quota.validationRequest) {
|
||||
return (
|
||||
<ValidationDialog
|
||||
validationLink={uiState.validationRequest.validationLink}
|
||||
validationDescription={uiState.validationRequest.validationDescription}
|
||||
learnMoreUrl={uiState.validationRequest.learnMoreUrl}
|
||||
validationLink={uiState.quota.validationRequest.validationLink}
|
||||
validationDescription={
|
||||
uiState.quota.validationRequest.validationDescription
|
||||
}
|
||||
learnMoreUrl={uiState.quota.validationRequest.learnMoreUrl}
|
||||
onChoice={uiActions.handleValidationChoice}
|
||||
/>
|
||||
);
|
||||
@@ -117,6 +123,20 @@ export const DialogManager = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.permissionConfirmationRequest) {
|
||||
const files = uiState.permissionConfirmationRequest.files;
|
||||
const filesList = files.map((f) => `- ${f}`).join('\n');
|
||||
return (
|
||||
<ConsentPrompt
|
||||
prompt={`The following files are outside your workspace:\n\n${filesList}\n\nDo you want to allow this read?`}
|
||||
onConfirm={(allowed) => {
|
||||
uiState.permissionConfirmationRequest?.onComplete({ allowed });
|
||||
}}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// commandConfirmationRequest and authConsentRequest are kept separate
|
||||
// to avoid focus deadlocks and state race conditions between the
|
||||
// synchronous command loop and the asynchronous auth flow.
|
||||
|
||||
@@ -132,6 +132,7 @@ export function EditorSettingsDialog({
|
||||
) {
|
||||
mergedEditorName =
|
||||
EDITOR_DISPLAY_NAMES[
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
settings.merged.general.preferredEditor as EditorType
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { Footer } from './Footer.js';
|
||||
@@ -131,6 +131,69 @@ describe('<Footer />', () => {
|
||||
expect(lastFrame()).toMatch(/\(\d+% context left\)/);
|
||||
});
|
||||
|
||||
it('displays the usage indicator when usage is low', () => {
|
||||
const { lastFrame } = renderWithProviders(<Footer />, {
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: {
|
||||
remaining: 15,
|
||||
limit: 100,
|
||||
resetTime: undefined,
|
||||
},
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(lastFrame()).toContain('15%');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('hides the usage indicator when usage is not near limit', () => {
|
||||
const { lastFrame } = renderWithProviders(<Footer />, {
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: {
|
||||
remaining: 85,
|
||||
limit: 100,
|
||||
resetTime: undefined,
|
||||
},
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(lastFrame()).not.toContain('Usage remaining');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('displays "Limit reached" message when remaining is 0', () => {
|
||||
const { lastFrame } = renderWithProviders(<Footer />, {
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: {
|
||||
remaining: 0,
|
||||
limit: 100,
|
||||
resetTime: undefined,
|
||||
},
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(lastFrame()).toContain('Limit reached');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('displays the model name and abbreviated context percentage', () => {
|
||||
const { lastFrame } = renderWithProviders(<Footer />, {
|
||||
width: 99,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -17,6 +17,7 @@ import process from 'node:process';
|
||||
import { ThemedGradient } from './ThemedGradient.js';
|
||||
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
|
||||
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
||||
import { QuotaDisplay } from './QuotaDisplay.js';
|
||||
import { DebugProfiler } from './DebugProfiler.js';
|
||||
import { isDevelopment } from '../../utils/installationInfo.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
@@ -43,6 +44,7 @@ export const Footer: React.FC = () => {
|
||||
nightly,
|
||||
isTrustedFolder,
|
||||
terminalWidth,
|
||||
quotaStats,
|
||||
} = {
|
||||
model: uiState.currentModel,
|
||||
targetDir: config.getTargetDir(),
|
||||
@@ -56,6 +58,7 @@ export const Footer: React.FC = () => {
|
||||
nightly: uiState.nightly,
|
||||
isTrustedFolder: uiState.isTrustedFolder,
|
||||
terminalWidth: uiState.terminalWidth,
|
||||
quotaStats: uiState.quota.stats,
|
||||
};
|
||||
|
||||
const showMemoryUsage =
|
||||
@@ -159,6 +162,17 @@ export const Footer: React.FC = () => {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{quotaStats && (
|
||||
<>
|
||||
{' '}
|
||||
<QuotaDisplay
|
||||
remaining={quotaStats.remaining}
|
||||
limit={quotaStats.limit}
|
||||
resetTime={quotaStats.resetTime}
|
||||
terse={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
{showMemoryUsage && <MemoryUsageDisplay />}
|
||||
</Box>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user