mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 13:11:03 -07:00
Compare commits
61 Commits
v0.4.0
...
v0.5.0-preview
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fc6dce719 | |||
| a31830a3cb | |||
| 5032b27011 | |||
| 76182dbfbf | |||
| 7d77f0287d | |||
| ae20aee837 | |||
| de5a31c3b6 | |||
| da58b93026 | |||
| 8b40e000d6 | |||
| 54744958fa | |||
| 94187114e9 | |||
| 02f67d3c57 | |||
| ef9469a417 | |||
| 1eaf21f6a2 | |||
| 471cbcd450 | |||
| 2b05cf3bb4 | |||
| f0bbfe5f0a | |||
| 097b5c734f | |||
| c8dfab9ec6 | |||
| f6f2fff724 | |||
| 9c71d3dd64 | |||
| c244cc86dd | |||
| 7e56cc6515 | |||
| 78142eab55 | |||
| 1788f04f5c | |||
| 009c24a4b8 | |||
| 4693137b82 | |||
| 6e26c88c2c | |||
| d33defde68 | |||
| d30829116e | |||
| 6a9c2859b7 | |||
| 6b4c12eb04 | |||
| c031f538df | |||
| 127d9205b8 | |||
| 13d39ef746 | |||
| 885af07ddb | |||
| fe15b04f33 | |||
| d2b5b4f129 | |||
| a027010097 | |||
| ee1b395c5c | |||
| 81904005fc | |||
| dfd622e096 | |||
| 968e938949 | |||
| 77f795192e | |||
| dd23c77469 | |||
| 7239c5cd9a | |||
| d8dbe6271f | |||
| c1b8708ef5 | |||
| 0e284457be | |||
| 20d0de74e1 | |||
| 69da43eb70 | |||
| a7bfab4d96 | |||
| 5634be70f1 | |||
| 036f3a7f9c | |||
| 918ab3c2ec | |||
| af52b04e6f | |||
| 604a2335dd | |||
| 0bc0d23cb3 | |||
| aea6230bcc | |||
| 107537c341 | |||
| 34cd554a33 |
@@ -0,0 +1,30 @@
|
||||
description = "Find relevant documentation and output GitHub URLs."
|
||||
|
||||
prompt = """
|
||||
## Mission: Find Relevant Documentation
|
||||
|
||||
Your task is to find documentation files relevant to the user's question within the current git repository and provide a list of GitHub URLs to view them.
|
||||
|
||||
### Workflow:
|
||||
|
||||
1. **Identify Repository Details**:
|
||||
* You may use shell commands like `git` or `gh` to get the remote URL of the repository.
|
||||
* From the remote URL, parse and construct the base GitHub URL (e.g., `https://github.com/user/repo`). You must handle both HTTPS (`https://github.com/user/repo.git`) and SSH (`git@github.com:user/repo.git`) formats.
|
||||
* Determine the default branch name. You can assume `main` for this purpose, as it is the most common.
|
||||
|
||||
2. **Search for Documentation**:
|
||||
* First, perform a targeted search across the repository for documentation files (e.g., `.md`, `.mdx`) that seem directly related to the user's question.
|
||||
* If this initial search yields no relevant results, and a `docs/` directory exists, read the content of all files within the `docs/` directory to find relevant information.
|
||||
* If you still can't find a direct match, broaden your search to include related concepts and synonyms of the keywords in the user's question.
|
||||
* For each file you identify as potentially relevant, read its content to confirm it addresses the user's query.
|
||||
|
||||
3. **Construct and Output URLs**:
|
||||
* For each file you identify as relevant, construct the full GitHub URL by combining the base URL, branch, and file path. **Do not use shell commands for this step.**
|
||||
* The URL format should be: `{BASE_GITHUB_URL}/blob/{BRANCH_NAME}/{PATH_TO_FILE_FROM_REPO_ROOT}`.
|
||||
* Present the final list to the user as a markdown list. Each item in the list should be the URL to the document, followed by a short summary of its content.
|
||||
* If, after all search attempts, you cannot find any relevant documentation, ask the user clarifying questions to better understand their needs. Do not return any URLs in this case.
|
||||
|
||||
### QUESTION:
|
||||
|
||||
{{args}}
|
||||
"""
|
||||
@@ -0,0 +1,13 @@
|
||||
description = "Go back to main and clean up the branch."
|
||||
|
||||
prompt = """
|
||||
I'm done with the work on this branch, and I'm ready to go back to main and clean up.
|
||||
|
||||
Here is the workflow I'd like you to follow:
|
||||
|
||||
1. **Get Current Branch:** First, I need you to get the name of the current branch and save it.
|
||||
2. **Branch Check:** Check if the current branch is `main`. If it is, I need you to stop and let me know.
|
||||
3. **Go to Main:** Next, I need you to checkout the main branch.
|
||||
4. **Pull Latest:** Once you are on the main branch, I need you to pull down the latest changes to make sure I'm up to date.
|
||||
5. **Branch Cleanup:** Finally, I need you to delete the branch that you noted in the first step.
|
||||
"""
|
||||
@@ -0,0 +1,47 @@
|
||||
description = "Review a specific pull request"
|
||||
|
||||
prompt = """
|
||||
## Mission: Comprehensive Pull Request Review
|
||||
|
||||
Today, our mission is to meticulously review community pull requests (PRs) for this project. We will proceed systematically, evaluating each candidate PR for its quality, adherence to standards, and readiness for merging.
|
||||
|
||||
### Workflow:
|
||||
|
||||
1. **PR Preparation & Initial Assessment**:
|
||||
* **You will check out the designated PR {{args}}** into a temporary branch.
|
||||
* **Execute the preflight checks (`npm run preflight`)**. This includes building, linting, and running all unit tests.
|
||||
* Analyze the output of these preflight checks, noting any failures, warnings, or linting issues.
|
||||
|
||||
2. **In-Depth Code Review**:
|
||||
* **Your primary role is to conduct a thorough and in-depth code review** of the changes introduced in the PR. Focus your analysis on the following criteria:
|
||||
* **Correctness**: Does the code achieve its stated purpose without bugs or logical errors?
|
||||
* **Maintainability**: Is the code clean, well-structured, and easy to understand and modify in the future? Consider factors like code clarity, modularity, and adherence to established design patterns.
|
||||
* **Readability**: Is the code well-commented (where necessary) and consistently formatted according to our project's coding style guidelines?
|
||||
* **Efficiency**: Are there any obvious performance bottlenecks or resource inefficiencies introduced by the changes?
|
||||
* **Security**: Are there any potential security vulnerabilities or insecure coding practices?
|
||||
* **Edge Cases and Error Handling**: Does the code appropriately handle edge cases and potential errors?
|
||||
* **Testability**: Is the new or modified code adequately covered by tests (even if preflight checks pass)? Suggest additional test cases that would improve coverage or robustness.
|
||||
* Based on your analysis, you will determine if the PR is **safe to merge**.
|
||||
|
||||
3. **Reviewing Previous Feedback**:
|
||||
* **Access and examine the PR's history** to identify any **outstanding requests or unresolved comments from previous reviews**. Incorporate these into your current review and explicitly highlight if they have been adequately addressed in the current state of the PR.
|
||||
|
||||
4. **Decision and Output Generation**:
|
||||
* **If the PR is deemed safe to merge** (after your comprehensive review and considering previous feedback):
|
||||
* Draft a **friendly, concise, and professional approval message**.
|
||||
* **The approval message should:**
|
||||
* Clearly state that the PR is approved.
|
||||
* Briefly acknowledge the quality or value of the contribution (e.g., "Great work on X feature!" or "Appreciate the fix for Y issue!").
|
||||
* **Do NOT mention the preflight checks or unit testing**, as these are internal processes.
|
||||
* Be suitable for public display on GitHub.
|
||||
* **If the PR is NOT safe to merge**:
|
||||
* Provide a **clear, constructive, and detailed summary of the issues found**.
|
||||
* Suggest **specific actionable changes** required for the PR to become merge-ready.
|
||||
* Ensure the feedback is professional and encourages the contributor.
|
||||
|
||||
### Post-PR Action:
|
||||
|
||||
* After providing your review and decision for the current PR, I will wait for you to perform any manual testing you wish to do. Please let me know when you are finished.
|
||||
* Once you have confirmed that you are done, I will switch to the `main` branch, clean up the local branch, and perform a pull to ensure we are synchronized with the latest upstream changes for the next review.
|
||||
|
||||
"""
|
||||
@@ -375,6 +375,8 @@ jobs:
|
||||
# Check for changes in bundle size.
|
||||
bundle_size:
|
||||
name: 'Check Bundle Size'
|
||||
if: |-
|
||||
${{ github.event_name != 'merge_group' }}
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
contents: 'read' # For checkout
|
||||
@@ -392,3 +394,4 @@ jobs:
|
||||
pattern: './bundle/**/*.{js,sb}'
|
||||
minimum-change-threshold: '1000'
|
||||
compression: 'none'
|
||||
clean-script: 'clean'
|
||||
|
||||
@@ -31,6 +31,7 @@ jobs:
|
||||
os:
|
||||
- 'ubuntu-latest'
|
||||
- 'macos-latest'
|
||||
- 'gemini-cli-windows-16-core'
|
||||
sandbox:
|
||||
- 'sandbox:none'
|
||||
- 'sandbox:docker'
|
||||
@@ -40,6 +41,8 @@ jobs:
|
||||
# Docker tests are not supported on macOS or Windows
|
||||
- os: 'macos-latest'
|
||||
sandbox: 'sandbox:docker'
|
||||
- os: 'gemini-cli-windows-16-core'
|
||||
sandbox: 'sandbox:docker'
|
||||
|
||||
steps:
|
||||
- name: 'Checkout (fork)'
|
||||
@@ -57,7 +60,6 @@ jobs:
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4
|
||||
with:
|
||||
node-version: '${{ matrix.node-version }}'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Install dependencies'
|
||||
run: |-
|
||||
@@ -78,5 +80,6 @@ jobs:
|
||||
KEEP_OUTPUT: 'true'
|
||||
SANDBOX: '${{ matrix.sandbox }}'
|
||||
VERBOSE: 'true'
|
||||
shell: 'bash'
|
||||
run: |-
|
||||
npm run "test:integration:${SANDBOX}"
|
||||
|
||||
@@ -288,17 +288,19 @@ jobs:
|
||||
|
||||
const issueNumber = parseInt(process.env.ISSUE_NUMBER);
|
||||
const explanation = parsedLabels.explanation || '';
|
||||
const labelsToSet = parsedLabels.labels_to_set || [];
|
||||
labelsToSet.push('status/bot-triaged');
|
||||
|
||||
// Set labels based on triage result
|
||||
if (parsedLabels.labels_to_set && parsedLabels.labels_to_set.length > 0) {
|
||||
if (labelsToSet.length > 0) {
|
||||
await github.rest.issues.setLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
labels: parsedLabels.labels_to_set
|
||||
labels: labelsToSet
|
||||
});
|
||||
const explanationInfo = explanation ? ` - ${explanation}` : '';
|
||||
core.info(`Successfully set labels for #${issueNumber}: ${parsedLabels.labels_to_set.join(', ')}${explanationInfo}`);
|
||||
core.info(`Successfully set labels for #${issueNumber}: ${labelsToSet.join(', ')}${explanationInfo}`);
|
||||
} else {
|
||||
// If no labels to set, leave the issue as is
|
||||
const explanationInfo = explanation ? ` - ${explanation}` : '';
|
||||
|
||||
@@ -286,15 +286,18 @@ jobs:
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.labels_to_add && entry.labels_to_add.length > 0) {
|
||||
const labelsToAdd = entry.labels_to_add || [];
|
||||
labelsToAdd.push('status/bot-triaged');
|
||||
|
||||
if (labelsToAdd.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
labels: entry.labels_to_add
|
||||
labels: labelsToAdd
|
||||
});
|
||||
const explanation = entry.explanation ? ` - ${entry.explanation}` : '';
|
||||
core.info(`Successfully added labels for #${issueNumber}: ${entry.labels_to_add.join(', ')}${explanation}`);
|
||||
core.info(`Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}${explanation}`);
|
||||
}
|
||||
|
||||
if (entry.labels_to_remove && entry.labels_to_remove.length > 0) {
|
||||
|
||||
+8
-2
@@ -3,8 +3,14 @@
|
||||
.env~
|
||||
|
||||
# gemini-cli settings
|
||||
.gemini/
|
||||
!gemini/config.yaml
|
||||
# We want to keep the .gemini in the root of the repo and ignore any .gemini
|
||||
# in subdirectories. In our root .gemini we want to allow for version control
|
||||
# for subcommands.
|
||||
**/.gemini/
|
||||
!/.gemini/
|
||||
.gemini/*
|
||||
!.gemini/config.yaml
|
||||
!.gemini/commands/
|
||||
|
||||
# Note: .gemini-clipboard/ is NOT in gitignore so Gemini can access pasted images
|
||||
|
||||
|
||||
+84
-15
@@ -1,19 +1,88 @@
|
||||
# Gemini CLI Extensions
|
||||
|
||||
Gemini CLI supports extensions that can be used to configure and extend its functionality.
|
||||
_This documentation is up-to-date with the v0.4.0 release._
|
||||
|
||||
Gemini CLI extensions package prompts, MCP servers, and custom commands into a familiar and user-friendly format. With extensions, you can expand the capabilities of Gemini CLI and share those capabilities with others. They are designed to be easily installable and shareable.
|
||||
|
||||
## Extension management
|
||||
|
||||
We offer a suite of extension management tools using `gemini extensions` commands.
|
||||
|
||||
Note that these commands are not supported from within the CLI, although you can list installed extensions using the `/extensions list` subcommand.
|
||||
|
||||
Note that all of these commands will only be reflected in active CLI sessions on restart.
|
||||
|
||||
### Installing an extension
|
||||
|
||||
You can install an extension using `gemini extensions install` with either a GitHub URL source or `--path=some/local/path`.
|
||||
|
||||
Note that we create a copy of the installed extension, so you will need to run `gemini extensions update` to pull in changes from both locally-defined extensions and those on GitHub.
|
||||
|
||||
```
|
||||
gemini extensions install https://github.com/google-gemini/gemini-cli-security
|
||||
```
|
||||
|
||||
This will install the Gemini CLI Security extension, which offers support for a `/security:analyze` command.
|
||||
|
||||
### Uninstalling an extension
|
||||
|
||||
To uninstall, run `gemini extensions uninstall extension-name`, so, in the case of the install example:
|
||||
|
||||
```
|
||||
gemini extensions uninstall gemini-cli-security
|
||||
```
|
||||
|
||||
### Disabling an extension
|
||||
|
||||
Extensions are, by default, enabled across all workspaces. You can disable an extension entirely or for specific workspace.
|
||||
|
||||
For example, `gemini extensions disable extension-name` will disable the extension at the user level, so it will be disabled everywhere. `gemini extensions disable extension-name --scope=Workspace` will only disable the extension in the current workspace.
|
||||
|
||||
### Enabling an extension
|
||||
|
||||
You can re-enable extensions using `gemini extensions enable extension-name`. Note that if an extension is disabled at the user-level, enabling it at the workspace level will not do anything.
|
||||
|
||||
### Updating an extension
|
||||
|
||||
For extensions installed from a local path or a git repository, you can explicitly update to the latest version (as reflected in the `gemini-extension.json` `version` field) with `gemini extensions update extension-name`.
|
||||
|
||||
You can update all extensions with:
|
||||
|
||||
```
|
||||
gemini extensions update --all
|
||||
```
|
||||
|
||||
## Extension creation
|
||||
|
||||
We offer commands to make extension development easier.
|
||||
|
||||
### Create a boilerplate extension
|
||||
|
||||
We offer several example extensions `context`, `custom-commands`, `exclude-tools` and `mcp-server`. You can view these examples [here](https://github.com/google-gemini/gemini-cli/tree/main/packages/cli/src/commands/extensions/examples).
|
||||
|
||||
To copy one of these examples into a development directory using the type of your choosing, run:
|
||||
|
||||
```
|
||||
gemini extensions new --path=path/to/directory --type=custom-commands
|
||||
```
|
||||
|
||||
### Link a local extension
|
||||
|
||||
The `gemini extensions link` command will create a symbolic link from the extension installation directory to the development path.
|
||||
|
||||
This is useful so you don't have to run `gemini extensions update` every time you make changes you'd like to test.
|
||||
|
||||
```
|
||||
gemini extensions link path/to/directory
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
On startup, Gemini CLI looks for extensions in two locations:
|
||||
On startup, Gemini CLI looks for extensions in `<home>/.gemini/extensions`
|
||||
|
||||
1. `<workspace>/.gemini/extensions`
|
||||
2. `<home>/.gemini/extensions`
|
||||
Extensions exist as a directory that contains a `gemini-extension.json` file. For example:
|
||||
|
||||
Gemini CLI loads all extensions from both locations. If an extension with the same name exists in both locations, the extension in the workspace directory takes precedence.
|
||||
|
||||
Within each location, individual extensions exist as a directory that contains a `gemini-extension.json` file. For example:
|
||||
|
||||
`<workspace>/.gemini/extensions/my-extension/gemini-extension.json`
|
||||
`<home>/.gemini/extensions/my-extension/gemini-extension.json`
|
||||
|
||||
### `gemini-extension.json`
|
||||
|
||||
@@ -33,19 +102,19 @@ The `gemini-extension.json` file contains the configuration for the extension. T
|
||||
}
|
||||
```
|
||||
|
||||
- `name`: The name of the extension. This is used to uniquely identify the extension and for conflict resolution when extension commands have the same name as user or project commands.
|
||||
- `name`: The name of the extension. This is used to uniquely identify the extension and for conflict resolution when extension commands have the same name as user or project commands. The name should be lowercase and use dashes instead of underscores or spaces. This is how users will refer to your extension in the CLI. Note that we expect this name to match the extension directory name.
|
||||
- `version`: The version of the extension.
|
||||
- `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence.
|
||||
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the workspace. If this property is not used but a `GEMINI.md` file is present in your extension directory, then that file will be loaded.
|
||||
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the extension directory. If this property is not used but a `GEMINI.md` file is present in your extension directory, then that file will be loaded.
|
||||
- `excludeTools`: An array of tool names to exclude from the model. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command.
|
||||
|
||||
When Gemini CLI starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence.
|
||||
|
||||
## Extension Commands
|
||||
### Custom commands
|
||||
|
||||
Extensions can provide [custom commands](./cli/commands.md#custom-commands) by placing TOML files in a `commands/` subdirectory within the extension directory. These commands follow the same format as user and project custom commands and use standard naming conventions.
|
||||
|
||||
### Example
|
||||
**Example**
|
||||
|
||||
An extension named `gcp` with the following structure:
|
||||
|
||||
@@ -63,7 +132,7 @@ Would provide these commands:
|
||||
- `/deploy` - Shows as `[gcp] Custom command from deploy.toml` in help
|
||||
- `/gcs:sync` - Shows as `[gcp] Custom command from sync.toml` in help
|
||||
|
||||
### Conflict Resolution
|
||||
### Conflict resolution
|
||||
|
||||
Extension commands have the lowest precedence. When a conflict occurs with user or project commands:
|
||||
|
||||
@@ -75,7 +144,7 @@ For example, if both a user and the `gcp` extension define a `deploy` command:
|
||||
- `/deploy` - Executes the user's deploy command
|
||||
- `/gcp.deploy` - Executes the extension's deploy command (marked with `[gcp]` tag)
|
||||
|
||||
# Variables
|
||||
## Variables
|
||||
|
||||
Gemini CLI extensions allow variable substitution in `gemini-extension.json`. This can be useful if e.g., you need the current directory to run an MCP server using `"cwd": "${extensionPath}${/}run.ts"`.
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
graph LR
|
||||
%% --- Style Definitions ---
|
||||
classDef new fill:#98fb98,color:#000
|
||||
classDef changed fill:#add8e6,color:#000
|
||||
classDef unchanged fill:#f0f0f0,color:#000
|
||||
|
||||
%% --- Subgraphs ---
|
||||
subgraph "Context Providers"
|
||||
direction TB
|
||||
A["gemini.tsx"]
|
||||
B["AppContainer.tsx"]
|
||||
end
|
||||
|
||||
subgraph "Contexts"
|
||||
direction TB
|
||||
CtxSession["SessionContext"]
|
||||
CtxVim["VimModeContext"]
|
||||
CtxSettings["SettingsContext"]
|
||||
CtxApp["AppContext"]
|
||||
CtxConfig["ConfigContext"]
|
||||
CtxUIState["UIStateContext"]
|
||||
CtxUIActions["UIActionsContext"]
|
||||
end
|
||||
|
||||
subgraph "Component Consumers"
|
||||
direction TB
|
||||
ConsumerApp["App"]
|
||||
ConsumerAppContainer["AppContainer"]
|
||||
ConsumerAppHeader["AppHeader"]
|
||||
ConsumerDialogManager["DialogManager"]
|
||||
ConsumerHistoryItem["HistoryItemDisplay"]
|
||||
ConsumerComposer["Composer"]
|
||||
ConsumerMainContent["MainContent"]
|
||||
ConsumerNotifications["Notifications"]
|
||||
end
|
||||
|
||||
%% --- Provider -> Context Connections ---
|
||||
A -.-> CtxSession
|
||||
A -.-> CtxVim
|
||||
A -.-> CtxSettings
|
||||
|
||||
B -.-> CtxApp
|
||||
B -.-> CtxConfig
|
||||
B -.-> CtxUIState
|
||||
B -.-> CtxUIActions
|
||||
B -.-> CtxSettings
|
||||
|
||||
%% --- Context -> Consumer Connections ---
|
||||
CtxSession -.-> ConsumerAppContainer
|
||||
CtxSession -.-> ConsumerApp
|
||||
|
||||
CtxVim -.-> ConsumerAppContainer
|
||||
CtxVim -.-> ConsumerComposer
|
||||
CtxVim -.-> ConsumerApp
|
||||
|
||||
CtxSettings -.-> ConsumerAppContainer
|
||||
CtxSettings -.-> ConsumerAppHeader
|
||||
CtxSettings -.-> ConsumerDialogManager
|
||||
CtxSettings -.-> ConsumerApp
|
||||
|
||||
CtxApp -.-> ConsumerAppHeader
|
||||
CtxApp -.-> ConsumerNotifications
|
||||
|
||||
CtxConfig -.-> ConsumerAppHeader
|
||||
CtxConfig -.-> ConsumerHistoryItem
|
||||
CtxConfig -.-> ConsumerComposer
|
||||
CtxConfig -.-> ConsumerDialogManager
|
||||
|
||||
|
||||
|
||||
CtxUIState -.-> ConsumerApp
|
||||
CtxUIState -.-> ConsumerMainContent
|
||||
CtxUIState -.-> ConsumerComposer
|
||||
CtxUIState -.-> ConsumerDialogManager
|
||||
|
||||
CtxUIActions -.-> ConsumerComposer
|
||||
CtxUIActions -.-> ConsumerDialogManager
|
||||
|
||||
%% --- Apply Styles ---
|
||||
%% New Elements (Green)
|
||||
class B,CtxApp,CtxConfig,CtxUIState,CtxUIActions,ConsumerAppHeader,ConsumerDialogManager,ConsumerComposer,ConsumerMainContent,ConsumerNotifications new
|
||||
|
||||
%% Heavily Changed Elements (Blue)
|
||||
class A,ConsumerApp,ConsumerAppContainer,ConsumerHistoryItem changed
|
||||
|
||||
%% Mostly Unchanged Elements (Gray)
|
||||
class CtxSession,CtxVim,CtxSettings unchanged
|
||||
|
||||
%% --- Link Styles ---
|
||||
%% CtxSession (Red)
|
||||
linkStyle 0,8,9 stroke:#e57373,stroke-width:2px
|
||||
%% CtxVim (Orange)
|
||||
linkStyle 1,10,11,12 stroke:#ffb74d,stroke-width:2px
|
||||
%% CtxSettings (Yellow)
|
||||
linkStyle 2,7,13,14,15,16 stroke:#fff176,stroke-width:2px
|
||||
%% CtxApp (Green)
|
||||
linkStyle 3,17,18 stroke:#81c784,stroke-width:2px
|
||||
%% CtxConfig (Blue)
|
||||
linkStyle 4,19,20,21,22 stroke:#64b5f6,stroke-width:2px
|
||||
%% CtxUIState (Indigo)
|
||||
linkStyle 5,23,24,25,26 stroke:#7986cb,stroke-width:2px
|
||||
%% CtxUIActions (Violet)
|
||||
linkStyle 6,27,28 stroke:#ba68c8,stroke-width:2px
|
||||
@@ -0,0 +1,64 @@
|
||||
graph TD
|
||||
%% --- Style Definitions ---
|
||||
classDef new fill:#98fb98,color:#000
|
||||
classDef changed fill:#add8e6,color:#000
|
||||
classDef unchanged fill:#f0f0f0,color:#000
|
||||
classDef dispatcher fill:#f9e79f,color:#000,stroke:#333,stroke-width:1px
|
||||
classDef container fill:#f5f5f5,color:#000,stroke:#ccc
|
||||
|
||||
%% --- Component Tree ---
|
||||
subgraph "Entry Point"
|
||||
A["gemini.tsx"]
|
||||
end
|
||||
|
||||
subgraph "State & Logic Wrapper"
|
||||
B["AppContainer.tsx"]
|
||||
end
|
||||
|
||||
subgraph "Primary Layout"
|
||||
C["App.tsx"]
|
||||
end
|
||||
|
||||
A -.-> B
|
||||
B -.-> C
|
||||
|
||||
subgraph "UI Containers"
|
||||
direction LR
|
||||
C -.-> D["MainContent"]
|
||||
C -.-> G["Composer"]
|
||||
C -.-> F["DialogManager"]
|
||||
C -.-> E["Notifications"]
|
||||
end
|
||||
|
||||
subgraph "MainContent"
|
||||
direction TB
|
||||
D -.-> H["AppHeader"]
|
||||
D -.-> I["HistoryItemDisplay"]:::dispatcher
|
||||
D -.-> L["ShowMoreLines"]
|
||||
end
|
||||
|
||||
subgraph "Composer"
|
||||
direction TB
|
||||
G -.-> K_Prompt["InputPrompt"]
|
||||
G -.-> K_Footer["Footer"]
|
||||
end
|
||||
|
||||
subgraph "DialogManager"
|
||||
F -.-> J["Various Dialogs<br>(Auth, Theme, Settings, etc.)"]
|
||||
end
|
||||
|
||||
%% --- Apply Styles ---
|
||||
class B,D,E,F,G,H,J,K_Prompt,L new
|
||||
class A,C,I changed
|
||||
class K_Footer unchanged
|
||||
|
||||
%% --- Link Styles ---
|
||||
%% MainContent Branch (Blue)
|
||||
linkStyle 2,6,7,8 stroke:#64b5f6,stroke-width:2px
|
||||
%% Composer Branch (Green)
|
||||
linkStyle 3,9,10 stroke:#81c784,stroke-width:2px
|
||||
%% DialogManager Branch (Orange)
|
||||
linkStyle 4,11 stroke:#ffb74d,stroke-width:2px
|
||||
%% Notifications Branch (Violet)
|
||||
linkStyle 5 stroke:#ba68c8,stroke-width:2px
|
||||
|
||||
@@ -23,7 +23,6 @@ const external = [
|
||||
'@lydell/node-pty-linux-x64',
|
||||
'@lydell/node-pty-win32-arm64',
|
||||
'@lydell/node-pty-win32-x64',
|
||||
'node-fetch',
|
||||
];
|
||||
|
||||
esbuild
|
||||
|
||||
@@ -96,8 +96,8 @@ describe('ShellExecutionService programmatic integration tests', () => {
|
||||
);
|
||||
|
||||
it('should abort a running process', async () => {
|
||||
// A command that runs for a bit. 'sleep' on unix, 'timeout' on windows.
|
||||
const command = process.platform === 'win32' ? 'timeout /t 20' : 'sleep 20';
|
||||
// A command that runs for a bit.
|
||||
const command = 'node -e "setTimeout(() => {}, 20000)"';
|
||||
const onOutputEvent = vi.fn();
|
||||
const abortController = new AbortController();
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import { execSync, spawn } from 'node:child_process';
|
||||
import { parse } from 'shell-quote';
|
||||
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -182,7 +181,7 @@ export class TestRig {
|
||||
| { prompt?: string; stdin?: string; stdinDoesNotEnd?: boolean },
|
||||
...args: string[]
|
||||
): Promise<string> {
|
||||
let command = `node ${this.bundlePath} --yolo`;
|
||||
const commandArgs = [this.bundlePath, '--yolo'];
|
||||
const execOptions: {
|
||||
cwd: string;
|
||||
encoding: 'utf-8';
|
||||
@@ -193,25 +192,22 @@ export class TestRig {
|
||||
};
|
||||
|
||||
if (typeof promptOrOptions === 'string') {
|
||||
command += ` --prompt ${JSON.stringify(promptOrOptions)}`;
|
||||
commandArgs.push('--prompt', promptOrOptions);
|
||||
} else if (
|
||||
typeof promptOrOptions === 'object' &&
|
||||
promptOrOptions !== null
|
||||
) {
|
||||
if (promptOrOptions.prompt) {
|
||||
command += ` --prompt ${JSON.stringify(promptOrOptions.prompt)}`;
|
||||
commandArgs.push('--prompt', promptOrOptions.prompt);
|
||||
}
|
||||
if (promptOrOptions.stdin) {
|
||||
execOptions.input = promptOrOptions.stdin;
|
||||
}
|
||||
}
|
||||
|
||||
command += ` ${args.join(' ')}`;
|
||||
commandArgs.push(...args);
|
||||
|
||||
const commandArgs = parse(command);
|
||||
const node = commandArgs.shift() as string;
|
||||
|
||||
const child = spawn(node, commandArgs as string[], {
|
||||
const child = spawn('node', commandArgs, {
|
||||
cwd: this.testDir!,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
Generated
+38
-123
@@ -1,17 +1,16 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-preview",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-preview",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@lvce-editor/ripgrep": "^1.6.0",
|
||||
"simple-git": "^3.28.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1210,13 +1209,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/plugin-kit": {
|
||||
"version": "0.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz",
|
||||
"integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==",
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz",
|
||||
"integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^0.15.1",
|
||||
"@eslint/core": "^0.15.2",
|
||||
"levn": "^0.4.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1224,9 +1223,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
|
||||
"version": "0.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz",
|
||||
"integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==",
|
||||
"version": "0.15.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz",
|
||||
"integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -1684,6 +1683,28 @@
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@joshua.litt/get-ripgrep": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@joshua.litt/get-ripgrep/-/get-ripgrep-0.0.2.tgz",
|
||||
"integrity": "sha512-cSHA+H+HEkOXeiCxrNvGj/pgv2Y0bfp4GbH3R87zr7Vob2pDUZV3BkUL9ucHMoDFID4GteSy5z5niN/lF9QeuQ==",
|
||||
"dependencies": {
|
||||
"@lvce-editor/verror": "^1.6.0",
|
||||
"execa": "^9.5.2",
|
||||
"extract-zip": "^2.0.1",
|
||||
"fs-extra": "^11.3.0",
|
||||
"got": "^14.4.5",
|
||||
"path-exists": "^5.0.0",
|
||||
"xdg-basedir": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@joshua.litt/get-ripgrep/node_modules/path-exists": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
|
||||
"integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
|
||||
@@ -1819,32 +1840,6 @@
|
||||
"integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lvce-editor/ripgrep": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@lvce-editor/ripgrep/-/ripgrep-1.6.0.tgz",
|
||||
"integrity": "sha512-880taWBVULNXmcPHXdxnFUI0FvLErBOjY9OigMXEsLZ2Q1rjcm6LixOkaccKWC8qFMpzm/ldkO7WOMK+ZRfk5Q==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lvce-editor/verror": "^1.6.0",
|
||||
"execa": "^9.5.2",
|
||||
"extract-zip": "^2.0.1",
|
||||
"fs-extra": "^11.3.0",
|
||||
"got": "^14.4.5",
|
||||
"path-exists": "^5.0.0",
|
||||
"tempy": "^3.1.0",
|
||||
"xdg-basedir": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lvce-editor/ripgrep/node_modules/path-exists": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
|
||||
"integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lvce-editor/verror": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@lvce-editor/verror/-/verror-1.7.0.tgz",
|
||||
@@ -6475,33 +6470,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-random-string": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz",
|
||||
"integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"type-fest": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-random-string/node_modules/type-fest": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz",
|
||||
"integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
@@ -14353,45 +14321,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/temp-dir": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz",
|
||||
"integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/tempy": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tempy/-/tempy-3.1.0.tgz",
|
||||
"integrity": "sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-stream": "^3.0.0",
|
||||
"temp-dir": "^3.0.0",
|
||||
"type-fest": "^2.12.2",
|
||||
"unique-string": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/tempy/node_modules/is-stream": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
|
||||
"integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/terminal-link": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz",
|
||||
@@ -15027,21 +14956,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/unique-string": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz",
|
||||
"integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crypto-random-string": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
|
||||
@@ -16107,7 +16021,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-preview",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.2",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -16378,7 +16292,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-preview",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
"@google/genai": "1.16.0",
|
||||
@@ -16569,10 +16483,10 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-preview",
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
"@lvce-editor/ripgrep": "^1.6.0",
|
||||
"@joshua.litt/get-ripgrep": "^0.0.2",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0",
|
||||
@@ -16619,6 +16533,7 @@
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/picomatch": "^4.0.1",
|
||||
"@types/ws": "^8.5.10",
|
||||
"msw": "^2.3.4",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
@@ -16709,7 +16624,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-preview",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3"
|
||||
@@ -16720,7 +16635,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-preview",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||
|
||||
+5
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-preview",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.3.1"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.5.0-preview"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node scripts/start.js",
|
||||
@@ -36,9 +36,9 @@
|
||||
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",
|
||||
"test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none",
|
||||
"test:integration:all": "npm run test:integration:sandbox:none && npm run test:integration:sandbox:docker && npm run test:integration:sandbox:podman",
|
||||
"test:integration:sandbox:none": "GEMINI_SANDBOX=false vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:docker": "GEMINI_SANDBOX=docker npm run build:sandbox && GEMINI_SANDBOX=docker vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:podman": "GEMINI_SANDBOX=podman vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && cross-env GEMINI_SANDBOX=docker vitest run --root ./integration-tests",
|
||||
"test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests",
|
||||
"lint": "eslint . --ext .ts,.tsx && eslint integration-tests",
|
||||
"lint:fix": "eslint . --fix && eslint integration-tests --fix",
|
||||
"lint:ci": "eslint . --ext .ts,.tsx --max-warnings 0 && eslint integration-tests --max-warnings 0",
|
||||
@@ -91,7 +91,6 @@
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lvce-editor/ripgrep": "^1.6.0",
|
||||
"simple-git": "^3.28.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
# RFC: Gemini CLI A2A Development-Tool Extension
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
### 1.1 Overview
|
||||
|
||||
To standardize client integrations with the Gemini CLI agent, this document proposes the `development-tool` extension for the A2A protocol.
|
||||
|
||||
Rather than creating a new protocol, this specification builds upon the existing A2A protocol. As an open-source standard recently adopted by the Linux Foundation, A2A provides a robust foundation for core concepts like tasks, messages, and streaming events. This extension-based approach allows us to leverage A2A's proven architecture while defining the specific capabilities required for rich, interactive workflows with the Gemini CLI agent.
|
||||
|
||||
### 1.2 Motivation
|
||||
|
||||
Recent work integrating Gemini CLI with clients like Zed and Gemini Code Assist’s agent mode has highlighted the need for a robust, standard communication protocol. Standardizing on A2A provides several key advantages:
|
||||
|
||||
- **Solid Foundation**: Provides a robust, open standard that ensures a stable, predictable, and consistent integration experience across different IDEs and client surfaces.
|
||||
- **Extensibility**: Creates a flexible foundation to support new tools and workflows as they emerge.
|
||||
- **Ecosystem Alignment**: Aligns Gemini CLI with a growing industry standard, fostering broader interoperability.
|
||||
|
||||
## 2. Communication Flow
|
||||
|
||||
The interaction follows A2A’s task-based, streaming pattern. The client sends a `message/stream` request and the agent responds with a `contextId` / `taskId` and a stream of events. `TaskStatusUpdateEvent` events are used to convey the overall state of the task. The task is complete when the agent sends a final `TaskStatusUpdateEvent` with `final: true` and a terminal status like `completed` or `failed`.
|
||||
|
||||
### 2.1 Asynchronous Responses and Notifications
|
||||
|
||||
Clients that may disconnect from the agent should supply a `PushNotificationConfig` to the agent with the initial `message/stream` method or subsequently with the `tasks/pushNotificationConfig/set` method so that the agent can call back when updates are ready.
|
||||
|
||||
## 3. The `development-tool` extension
|
||||
|
||||
### 3.1 Overview
|
||||
|
||||
The `development-tool` extension establishes a communication contract for workflows between a client and the Gemini CLI agent. It consists of a specialized set of schemas, embedded within core A2A data structures, that enable the agent to stream real-time updates on its state and thought process. These schemas also provide the mechanism for the agent to request user permission before executing tools.
|
||||
|
||||
**Sample Agent Card**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Gemini CLI Agent",
|
||||
"description": "An agent that generates code based on natural language instructions.",
|
||||
"capabilities": {
|
||||
"streaming": true,
|
||||
"extensions": [
|
||||
{
|
||||
"uri": "https://github.com/google-gemini/gemini-cli/blob/main/docs/a2a/developer-profile/v0/spec.md",
|
||||
"description": "An extension for interactive development tasks, enabling features like code generation, tool usage, and real-time status updates.",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Versioning**
|
||||
|
||||
The agent card `uri` field contains an embedded semantic version. The client must extract this version to determine compatibility with the agent extension using the compatibility logic defined in Semantic Versioning 2.0.0 spec.
|
||||
|
||||
### 3.2 Schema Definitions
|
||||
|
||||
This section defines the schemas for the `development-tool` A2A extension, organized by their function within the communication flow. Note that all custom objects included in the `metadata` field (e.g. `Message.metadata`) must be keyed by the unique URI that points to that extension’s spec to prevent naming collisions with other extensions.
|
||||
|
||||
**Initialization & Configuration**
|
||||
|
||||
The first message in a session must contain an `AgentSettings` object in its metadata. This object provides the agent with the necessary configuration information for proper initialization. Additional configuration settings (ex. MCP servers, allowed tools, etc.) can be added to this message.
|
||||
|
||||
**Schema**
|
||||
|
||||
```proto
|
||||
syntax = "proto3";
|
||||
|
||||
// Configuration settings for the Gemini CLI agent.
|
||||
message AgentSettings {
|
||||
// The absolute path to the workspace directory where the agent will execute.
|
||||
string workspace_path = 1;
|
||||
}
|
||||
```
|
||||
|
||||
**Agent-to-Client Messages**
|
||||
|
||||
All real-time updates from the agent (including its thoughts, tool calls, and simple text replies) are streamed to the client as `TaskStatusUpdateEvents`.
|
||||
|
||||
Each Event contains a `Message` object, which holds the content in one of two formats:
|
||||
|
||||
- **TextPart**: Used for standard text messages. This part requires no custom schema.
|
||||
- **DataPart**: Used for complex, structured objects. Tool Calls and Thoughts are sent this way, each using their respective schemas defined below.
|
||||
|
||||
**Tool Calls**
|
||||
|
||||
The `ToolCall` schema is designed to provide a structured representation of a tool’s execution lifecycle. This protocol defines a clear state machine and provides detailed schemas for common development tasks (file edits, shell commands, MCP Tool), ensuring clients can build reliable UIs without being tied to a specific agent implementation.
|
||||
|
||||
The core principle is that the agent sends a `ToolCall` object on every update. This makes client-side logic stateless and simple.
|
||||
|
||||
**Tool Call Lifecycle**
|
||||
|
||||
1. **Creation**: The agent sends a `ToolCall` object with `status: PENDING`. If user permission is required, the `confirmation_request` field will be populated.
|
||||
2. **Confirmation**: If the client needs to confirm the message, the client will send a `ToolCallConfirmation`. If the client responds with a cancellation, execution will be skipped.
|
||||
3. **Execution**: Once approved (or if no approval is required), the agent sends an update with `status: EXECUTING`. It can stream real-time progress by updating the `live_content` field.
|
||||
4. **Completion**: The agent sends a final update with the status set to `SUCCEEDED`, `FAILED`, or `CANCELLED` and populates the appropriate result field.
|
||||
|
||||
**Schema**
|
||||
|
||||
```proto
|
||||
syntax = "proto3";
|
||||
|
||||
import "google/protobuf/struct.proto";
|
||||
|
||||
// ToolCall is the central message represeting a tool's execution lifecycle.
|
||||
// The entire object is sent from the agent to client on every update.
|
||||
message ToolCall {
|
||||
// A unique identifier, assigned by the agent
|
||||
string tool_call_id = 1;
|
||||
|
||||
// The current state of the tool call in its lifecycle
|
||||
ToolCallStatus status = 2;
|
||||
|
||||
// Name of the tool being called (e.g. 'Edit', 'ShellTool')
|
||||
string tool_name = 3;
|
||||
|
||||
// An optional description of the tool call's purpose to show the user
|
||||
optional string description = 4;
|
||||
|
||||
// The structured input params provided by the LLM for tool invocation.
|
||||
google.protobuf.Struct input_parameters = 5;
|
||||
|
||||
// String containing the real-time output from the tool as it executes (primarily designed for shell output).
|
||||
// During streaming the entire string is replaced on each update
|
||||
optional string live_content = 6;
|
||||
|
||||
// The final result of the tool (used to replace live_content when applicable)
|
||||
oneof result {
|
||||
// The output on tool success
|
||||
ToolOutput output = 7;
|
||||
// The error details if the tool failed
|
||||
ErrorDetails error = 8;
|
||||
}
|
||||
|
||||
// If the tool requires user confirmation, this field will be populated while status is PENDING
|
||||
optional ConfirmationRequest confirmation_request = 9;
|
||||
}
|
||||
|
||||
// Possible execution status of a ToolCall
|
||||
enum ToolCallStatus {
|
||||
STATUS_UNSPECIFIED = 0;
|
||||
PENDING = 1;
|
||||
EXECUTING = 2;
|
||||
SUCCEEDED = 3;
|
||||
FAILED = 4;
|
||||
CANCELLED = 5;
|
||||
}
|
||||
|
||||
// ToolOuput represents the final, successful, output of a tool
|
||||
message ToolOutput {
|
||||
oneof result {
|
||||
string text = 1;
|
||||
// For ToolCalls which resulted in a file modification
|
||||
FileDiff diff = 2;
|
||||
// A generic fallback for any other structured JSON data
|
||||
google.protobuf.Struct structured_data = 3;
|
||||
}
|
||||
}
|
||||
|
||||
// A structured representation of an error
|
||||
message ErrorDetails {
|
||||
// User facing error message
|
||||
string message = 1;
|
||||
// Optional agent-specific error type or category (e.g. read_content_failure, grep_execution_error, mcp_tool_error)
|
||||
optional string type = 2;
|
||||
// Optional status code
|
||||
optional int32 status_code = 3;
|
||||
}
|
||||
|
||||
// ConfirmationRequest is sent from the agent to client to request user permission for a ToolCall
|
||||
message ConfirmationRequest {
|
||||
// A list of choices for the user to select from
|
||||
repeated ConfirmationOption options = 1;
|
||||
// Specific details of the action requiring user confirmation
|
||||
oneof details {
|
||||
ExecuteDetails execute_details = 2;
|
||||
FileDiff file_edit_details = 3;
|
||||
McpDetails mcp_details = 4;
|
||||
GenericDetails generic_details = 5;
|
||||
}
|
||||
}
|
||||
|
||||
// A single choice presented to the user during a confirmation request
|
||||
message ConfirmationOption {
|
||||
// Unique ID for the choice (e.g. proceed_once, cancel)
|
||||
string id = 1;
|
||||
// Human-readable choice (e.g. Allow Once, Reject).
|
||||
string name = 2;
|
||||
// An optional longer description for a tooltip
|
||||
optional string description = 3;
|
||||
}
|
||||
|
||||
// Details for a request to execute a shell command
|
||||
message ExecuteDetails {
|
||||
// The shell command to be executed
|
||||
string command = 1;
|
||||
// An optional directory in which the command will be run
|
||||
optional string working_directory = 2;
|
||||
}
|
||||
|
||||
|
||||
message FileDiff {
|
||||
string file_name = 1;
|
||||
// The absolute path to the file to modify
|
||||
string file_path = 2;
|
||||
// The original content, if the file exists
|
||||
optional string old_content = 3;
|
||||
string new_content = 4;
|
||||
// Pre-formatted diff string for display
|
||||
optional string formatted_diff = 5;
|
||||
}
|
||||
|
||||
// Details for an MCP (Model Context Protocol) tool confirmation
|
||||
message McpDetails {
|
||||
// The name of the MCP server that provides the tool
|
||||
string server_name = 1;
|
||||
// THe name of the tool being called from the MCP Server
|
||||
string tool_name = 2;
|
||||
}
|
||||
|
||||
// Generic catch-all for ToolCall requests that don't fit other types
|
||||
message GenericDetails {
|
||||
// Description of the action requiring confirmation
|
||||
string description = 1;
|
||||
}
|
||||
```
|
||||
|
||||
**Agent Thoughts**
|
||||
|
||||
**Schema**
|
||||
|
||||
```proto
|
||||
syntax = "proto3";
|
||||
|
||||
// Represents a thought with a subject and a detailed description.
|
||||
message AgentThought {
|
||||
// A concise subject line or title for the thought.
|
||||
string subject = 1;
|
||||
|
||||
// The description or elaboration of the thought itself.
|
||||
string description = 2;
|
||||
}
|
||||
```
|
||||
|
||||
**Event Metadata**
|
||||
|
||||
The `metadata` object in `TaskStatusUpdateEvent` is used by the A2A client to deserialize the `TaskStatusUpdateEvents` into their appropriate objects.
|
||||
|
||||
**Schema**
|
||||
|
||||
```proto
|
||||
syntax = "proto3";
|
||||
|
||||
// A DevelopmentToolEvent event.
|
||||
message DevelopmentToolEvent {
|
||||
// Enum representing the specific type of development tool event.
|
||||
enum DevelopmentToolEventKind {
|
||||
// The default, unspecified value.
|
||||
DEVELOPMENT_TOOL_EVENT_KIND_UNSPECIFIED = 0;
|
||||
TOOL_CALL_CONFIRMATION = 1;
|
||||
TOOL_CALL_UPDATE = 2;
|
||||
TEXT_CONTENT = 3;
|
||||
STATE_CHANGE = 4;
|
||||
THOUGHT = 5;
|
||||
}
|
||||
|
||||
// The specific kind of event that occurred.
|
||||
DevelopmentToolEventKind kind = 1;
|
||||
|
||||
// The model used for this event.
|
||||
string model = 2;
|
||||
|
||||
// The tier of the user (optional).
|
||||
string user_tier = 3;
|
||||
|
||||
// An unexpected error occured in the agent execution (optional).
|
||||
string error = 4;
|
||||
}
|
||||
```
|
||||
|
||||
**Client-to-Agent Messages**
|
||||
|
||||
When the agent sends a `TaskStatusUpdateEvent` with `status.state` set to `input-required` and its message contains a `ConfirmationRequest`, the client must respond by sending a new `message/stream` request.
|
||||
|
||||
This new request must include the `contextId` and the `taskId` from the ongoing task and contain a `ToolCallConfirmation` object. This object conveys the user's decision regarding the tool call that was awaiting approval.
|
||||
|
||||
**Schema**
|
||||
|
||||
```proto
|
||||
syntax = "proto3";
|
||||
|
||||
// The client's response to a ConfirmationRequest.
|
||||
message ToolCallConfirmation {
|
||||
// A unique identifier, assigned by the agent
|
||||
string tool_call_id = 1;
|
||||
// The 'id' of the ConfirmationOption chosen by the user.
|
||||
string selected_option_id = 2;
|
||||
// Included if the user modifies the proposed change.
|
||||
// The type should correspond to the original ConfirmationRequest details.
|
||||
oneof modified_details {
|
||||
// Corresponds to a FileDiff confirmation
|
||||
ModifiedFileDetails file_details = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message ModifiedFileDetails {
|
||||
// The new content after user edits.
|
||||
string new_content = 1;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Method Definitions
|
||||
|
||||
This section defines the new methods introduced by the `development-tool` extension.
|
||||
|
||||
**Method: `commands/get`**
|
||||
|
||||
This method allows the client to discover slash commands supported by Gemini CLI. The client should call this method during startup to dynamically populate its command list.
|
||||
|
||||
```proto
|
||||
// Response message containing the list of all top-level slash commands.
|
||||
message GetAllSlashCommandsResponse {
|
||||
// A list of the top-level slash commands.
|
||||
repeated SlashCommand commands = 1;
|
||||
}
|
||||
|
||||
// Represents a single slash command, which can contain subcommands.
|
||||
message SlashCommand {
|
||||
// The primary name of the command.
|
||||
string name = 1;
|
||||
// A detailed description of what the command does.
|
||||
string description = 2;
|
||||
// A list of arguments that the command accepts.
|
||||
repeated SlashCommandArgument arguments = 3;
|
||||
// A list of nested subcommands.
|
||||
repeated SlashCommand sub_commands = 4;
|
||||
}
|
||||
|
||||
// Defines the structure for a single slash command argument.
|
||||
message SlashCommandArgument {
|
||||
// The name of the argument.
|
||||
string name = 1;
|
||||
// A brief description of what the argument is for.
|
||||
string description = 2;
|
||||
// Whether the argument is required or optional.
|
||||
bool is_required = 3;
|
||||
}
|
||||
```
|
||||
|
||||
**Method: `command/execute`**
|
||||
|
||||
This method allows the client to execute a slash command. Following the initial `ExecuteSlashCommandResponse`, the agent will use the standard streaming mechanism to communicate the command's progress and output. All subsequent updates, including textual output, agent thoughts, and any required user confirmations for tool calls (like executing a shell command), will be sent as `TaskStatusUpdateEvent` messages, re-using the schemas defined above.
|
||||
|
||||
```proto
|
||||
// Request to execute a specific slash command.
|
||||
message ExecuteSlashCommandRequest {
|
||||
// The path to the command, e.g., ["memory", "add"] for /memory add
|
||||
repeated string command_path = 1;
|
||||
// The arguments for the command as a single string.
|
||||
string args = 2;
|
||||
}
|
||||
|
||||
// Enum for the initial status of a command execution request.
|
||||
enum CommandExecutionStatus {
|
||||
// Default unspecified status.
|
||||
COMMAND_EXECUTION_STATUS_UNSPECIFIED = 0;
|
||||
// The command was successfully received and its execution has started.
|
||||
STARTED = 1;
|
||||
// The command failed to start (e.g., command not found, invalid format).
|
||||
FAILED_TO_START = 2;
|
||||
// The command has been paused and is waiting for the user to confirm
|
||||
// a set of shell commands.
|
||||
AWAITING_SHELL_CONFIRMATION = 3;
|
||||
// The command has been paused and is waiting for the user to confirm
|
||||
// a specific action.
|
||||
AWAITING_ACTION_CONFIRMATION = 4;
|
||||
}
|
||||
|
||||
// The immediate, async response after requesting a command execution.
|
||||
message ExecuteSlashCommandResponse {
|
||||
// A unique taskID for this specific command execution.
|
||||
string execution_id = 1;
|
||||
// The initial status of the command execution.
|
||||
CommandExecutionStatus status = 2;
|
||||
// An optional message, particularly useful for explaining why a command
|
||||
// failed to start.
|
||||
string message = 3;
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Separation of Concerns
|
||||
|
||||
We believe that all client-side context (ex., workspace state) and client-side tool execution (ex. read active buffers) should be routed through MCP.
|
||||
|
||||
This approach enforces a strict separation of concerns: the A2A `development-tool` extension standardizes communication to the agent, while MCP serves as the single, authoritative interface for client-side capabilities.
|
||||
|
||||
## Appendix
|
||||
|
||||
### A. Example Interaction Flow
|
||||
|
||||
1. **Client -> Server**: The client sends a `message/stream` request containing the initial prompt and configuration in an `AgentSettings` object.
|
||||
2. **Server -> Client**: SSE stream begins.
|
||||
- **Event 1**: The server sends a `Task` object with `status.state: 'submitted'` and the new `taskId`.
|
||||
- **Event 2**: The server sends a `TaskStatusUpdateEvent` with the metadata `kind` set to `'STATE_CHANGE'` and `status.state` set to `'working'`.
|
||||
3. **Agent Logic**: The agent processes the prompt and decides to call the `write_file` tool, which requires user confirmation.
|
||||
4. **Server -> Client**:
|
||||
- **Event 3**: The server sends a `TaskStatusUpdateEvent`. The metadata `kind` is `'TOOL_CALL_UPDATE'`, and the `DataPart` contains a `ToolCall` object with its `status` as `'PENDING'` and a populated `confirmation_request`.
|
||||
- **Event 4**: The server sends a final `TaskStatusUpdateEvent` for this exchange. The metadata `kind` is `'STATE_CHANGE'`, the `status.state` is `'input-required'`, and `final` is `true`. The stream for this request ends.
|
||||
5. **Client**: The client UI renders the confirmation prompt based on the `ToolCall` object from Event 3. The user clicks "Approve."
|
||||
6. **Client -> Server**: The client sends a new `message/stream` request. It includes the `taskId` from the ongoing task and a `DataPart` containing a `ToolCallConfirmation` object (e.g., `{"tool_call_id": "...", "selected_option_id": "proceed_once"}`).
|
||||
7. **Server -> Client**: A new SSE stream begins for the second request.
|
||||
- **Event 1**: The server sends a `TaskStatusUpdateEvent` with `kind: 'TOOL_CALL_UPDATE'`, containing the `ToolCall` object with its `status` now set to `'EXECUTING'`.
|
||||
- **Event 2**: After the tool runs, the server sends another `TaskStatusUpdateEvent` with `kind: 'TOOL_CALL_UPDATE'`, containing the `ToolCall` with its `status` as `'SUCCEEDED'`.
|
||||
8. **Agent Logic**: The agent receives the successful tool result and generates a final textual response.
|
||||
9. **Server -> Client**:
|
||||
- **Event 3**: The server sends a `TaskStatusUpdateEvent` with `kind: 'TEXT_CONTENT'` and a `TextPart` containing the agent's final answer.
|
||||
- **Event 4**: The server sends the final `TaskStatusUpdateEvent`. The `kind` is `'STATE_CHANGE'`, the `status.state` is `'completed'`, and `final` is `true`. The stream ends.
|
||||
10. **Client**: The client displays the final answer. The task is now complete but can be continued by sending another message with the same `taskId`.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-preview",
|
||||
"private": true,
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
|
||||
@@ -125,9 +125,7 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
eventBus,
|
||||
);
|
||||
runtimeTask.taskState = persistedState._taskState;
|
||||
await runtimeTask.geminiClient.initialize(
|
||||
runtimeTask.config.getContentGeneratorConfig(),
|
||||
);
|
||||
await runtimeTask.geminiClient.initialize();
|
||||
|
||||
const wrapper = new TaskWrapper(runtimeTask, agentSettings);
|
||||
this.tasks.set(sdkTask.id, wrapper);
|
||||
@@ -144,9 +142,7 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
const agentSettings = agentSettingsInput || ({} as AgentSettings);
|
||||
const config = await this.getConfig(agentSettings, taskId);
|
||||
const runtimeTask = await Task.create(taskId, contextId, config, eventBus);
|
||||
await runtimeTask.geminiClient.initialize(
|
||||
runtimeTask.config.getContentGeneratorConfig(),
|
||||
);
|
||||
await runtimeTask.geminiClient.initialize();
|
||||
|
||||
const wrapper = new TaskWrapper(runtimeTask, agentSettings);
|
||||
this.tasks.set(taskId, wrapper);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import {
|
||||
CoreToolScheduler,
|
||||
GeminiClient,
|
||||
type GeminiClient,
|
||||
GeminiEventType,
|
||||
ToolConfirmationOutcome,
|
||||
ApprovalMode,
|
||||
@@ -82,18 +82,17 @@ export class Task {
|
||||
this.contextId = contextId;
|
||||
this.config = config;
|
||||
this.scheduler = this.createScheduler();
|
||||
this.geminiClient = new GeminiClient(this.config);
|
||||
this.geminiClient = this.config.getGeminiClient();
|
||||
this.pendingToolConfirmationDetails = new Map();
|
||||
this.taskState = 'submitted';
|
||||
this.eventBus = eventBus;
|
||||
this.completedToolCalls = [];
|
||||
this._resetToolCompletionPromise();
|
||||
this.config.setFlashFallbackHandler(
|
||||
async (currentModel: string, fallbackModel: string): Promise<boolean> => {
|
||||
config.setModel(fallbackModel); // gemini-cli-core sets to DEFAULT_GEMINI_FLASH_MODEL
|
||||
// Switch model for future use but return false to stop current retry
|
||||
return false;
|
||||
},
|
||||
this.config.setFallbackModelHandler(
|
||||
// For a2a-server, we want to automatically switch to the fallback model
|
||||
// for future requests without retrying the current one. The 'stop'
|
||||
// intent achieves this.
|
||||
async () => 'stop',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,7 +226,7 @@ export class Task {
|
||||
} = {
|
||||
coderAgent: coderAgentMessage,
|
||||
model: this.config.getModel(),
|
||||
userTier: this.geminiClient.getUserTier(),
|
||||
userTier: this.config.getUserTier(),
|
||||
};
|
||||
|
||||
if (metadataError) {
|
||||
|
||||
@@ -9,8 +9,13 @@ import type {
|
||||
TaskStatusUpdateEvent,
|
||||
SendStreamingMessageSuccessResponse,
|
||||
} from '@a2a-js/sdk';
|
||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
ApprovalMode,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
GeminiClient,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Config, Storage } from '@google/gemini-cli-core';
|
||||
import { expect, vi } from 'vitest';
|
||||
|
||||
export function createMockConfig(
|
||||
@@ -28,19 +33,29 @@ export function createMockConfig(
|
||||
isPathWithinWorkspace: () => true,
|
||||
}),
|
||||
getTargetDir: () => '/test',
|
||||
getGeminiClient: vi.fn(),
|
||||
storage: {
|
||||
getProjectTempDir: () => '/tmp',
|
||||
} as Storage,
|
||||
getTruncateToolOutputThreshold: () =>
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({ model: 'gemini-pro' }),
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
|
||||
setFlashFallbackHandler: vi.fn(),
|
||||
setFallbackModelHandler: vi.fn(),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
getEmbeddingModel: vi.fn().mockReturnValue('text-embedding-004'),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getUserTier: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
} as unknown as Config;
|
||||
|
||||
mockConfig.getGeminiClient = vi
|
||||
.fn()
|
||||
.mockReturnValue(new GeminiClient(mockConfig));
|
||||
return mockConfig;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.3.1",
|
||||
"version": "0.5.0-preview",
|
||||
"description": "Gemini CLI",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -25,7 +25,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.3.1"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.5.0-preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { installCommand, handleInstall } from './install.js';
|
||||
import { installCommand } from './install.js';
|
||||
import yargs from 'yargs';
|
||||
import * as extension from '../../config/extension.js';
|
||||
|
||||
vi.mock('../../config/extension.js', () => ({
|
||||
installExtension: vi.fn(),
|
||||
@@ -17,33 +16,14 @@ describe('extensions install command', () => {
|
||||
it('should fail if no source is provided', () => {
|
||||
const validationParser = yargs([]).command(installCommand).fail(false);
|
||||
expect(() => validationParser.parse('install')).toThrow(
|
||||
'Either --source or --path must be provided.',
|
||||
'Either source or --path must be provided.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail if both git source and local path are provided', () => {
|
||||
const validationParser = yargs([]).command(installCommand).fail(false);
|
||||
expect(() =>
|
||||
validationParser.parse('install --source some-url --path /some/path'),
|
||||
validationParser.parse('install some-url --path /some/path'),
|
||||
).toThrow('Arguments source and path are mutually exclusive');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extensions install with org/repo', () => {
|
||||
it('should call installExtension with the correct git URL', async () => {
|
||||
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const installExtensionSpy = vi
|
||||
.spyOn(extension, 'installExtension')
|
||||
.mockResolvedValue('test-extension');
|
||||
|
||||
await handleInstall({ source: 'test-org/test-repo' });
|
||||
|
||||
expect(installExtensionSpy).toHaveBeenCalledWith({
|
||||
source: 'https://github.com/test-org/test-repo.git',
|
||||
type: 'git',
|
||||
});
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
'Extension "test-extension" installed successfully and enabled.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,8 +17,6 @@ interface InstallArgs {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
const ORG_REPO_REGEX = /^[a-zA-Z0-9-]+\/[\w.-]+$/;
|
||||
|
||||
export async function handleInstall(args: InstallArgs) {
|
||||
try {
|
||||
let installMetadata: ExtensionInstallMetadata;
|
||||
@@ -34,15 +32,8 @@ export async function handleInstall(args: InstallArgs) {
|
||||
source,
|
||||
type: 'git',
|
||||
};
|
||||
} else if (ORG_REPO_REGEX.test(source)) {
|
||||
installMetadata = {
|
||||
source: `https://github.com/${source}.git`,
|
||||
type: 'git',
|
||||
};
|
||||
} else {
|
||||
throw new Error(
|
||||
`The source "${source}" is not a valid URL or "org/repo" format.`,
|
||||
);
|
||||
throw new Error(`The source "${source}" is not a valid URL format.`);
|
||||
}
|
||||
} else if (args.path) {
|
||||
installMetadata = {
|
||||
@@ -54,10 +45,8 @@ export async function handleInstall(args: InstallArgs) {
|
||||
throw new Error('Either --source or --path must be provided.');
|
||||
}
|
||||
|
||||
const extensionName = await installExtension(installMetadata);
|
||||
console.log(
|
||||
`Extension "${extensionName}" installed successfully and enabled.`,
|
||||
);
|
||||
const name = await installExtension(installMetadata);
|
||||
console.log(`Extension "${name}" installed successfully and enabled.`);
|
||||
} catch (error) {
|
||||
console.error(getErrorMessage(error));
|
||||
process.exit(1);
|
||||
@@ -65,13 +54,12 @@ export async function handleInstall(args: InstallArgs) {
|
||||
}
|
||||
|
||||
export const installCommand: CommandModule = {
|
||||
command: 'install [--source | --path ]',
|
||||
describe:
|
||||
'Installs an extension from a git repository (URL or "org/repo") or a local path.',
|
||||
command: 'install [source]',
|
||||
describe: 'Installs an extension from a git repository URL or a local path.',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.option('source', {
|
||||
describe: 'The git URL or "org/repo" of the extension to install.',
|
||||
.positional('source', {
|
||||
describe: 'The github URL of the extension to install.',
|
||||
type: 'string',
|
||||
})
|
||||
.option('path', {
|
||||
@@ -81,7 +69,7 @@ export const installCommand: CommandModule = {
|
||||
.conflicts('source', 'path')
|
||||
.check((argv) => {
|
||||
if (!argv.source && !argv.path) {
|
||||
throw new Error('Either --source or --path must be provided.');
|
||||
throw new Error('Either source or --path must be provided.');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
|
||||
@@ -236,43 +236,43 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
})
|
||||
.deprecateOption(
|
||||
'telemetry',
|
||||
'Use settings.json instead. This flag will be removed in a future version.',
|
||||
'Use the "telemetry.enabled" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'telemetry-target',
|
||||
'Use settings.json instead. This flag will be removed in a future version.',
|
||||
'Use the "telemetry.target" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'telemetry-otlp-endpoint',
|
||||
'Use settings.json instead. This flag will be removed in a future version.',
|
||||
'Use the "telemetry.otlpEndpoint" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'telemetry-otlp-protocol',
|
||||
'Use settings.json instead. This flag will be removed in a future version.',
|
||||
'Use the "telemetry.otlpProtocol" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'telemetry-log-prompts',
|
||||
'Use settings.json instead. This flag will be removed in a future version.',
|
||||
'Use the "telemetry.logPrompts" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'telemetry-outfile',
|
||||
'Use settings.json instead. This flag will be removed in a future version.',
|
||||
'Use the "telemetry.outfile" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'show-memory-usage',
|
||||
'Use settings.json instead. This flag will be removed in a future version.',
|
||||
'Use the "ui.showMemoryUsage" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'sandbox-image',
|
||||
'Use settings.json instead. This flag will be removed in a future version.',
|
||||
'Use the "tools.sandbox" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'proxy',
|
||||
'Use settings.json instead. This flag will be removed in a future version.',
|
||||
'Use the "proxy" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'checkpointing',
|
||||
'Use settings.json instead. This flag will be removed in a future version.',
|
||||
'Use the "general.checkpointing.enabled" setting in settings.json instead. This flag will be removed in a future version.',
|
||||
)
|
||||
.deprecateOption(
|
||||
'all-files',
|
||||
@@ -305,7 +305,7 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
// Register MCP subcommands
|
||||
.command(mcpCommand);
|
||||
|
||||
if (settings?.experimental?.extensionManagement ?? false) {
|
||||
if (settings?.experimental?.extensionManagement ?? true) {
|
||||
yargsInstance.command(extensionsCommand);
|
||||
}
|
||||
|
||||
@@ -622,6 +622,9 @@ export async function loadCliConfig(
|
||||
shouldUseNodePtyShell: settings.tools?.usePty,
|
||||
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
|
||||
enablePromptCompletion: settings.general?.enablePromptCompletion ?? false,
|
||||
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
|
||||
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
|
||||
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
|
||||
eventEmitter: appEvents,
|
||||
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
|
||||
});
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
GEMINI_DIR,
|
||||
type GeminiCLIExtension,
|
||||
type MCPServerConfig,
|
||||
ClearcutLogger,
|
||||
type Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { SettingScope, loadSettings } from './settings.js';
|
||||
@@ -52,6 +54,22 @@ vi.mock('./trustedFolders.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const mockLogExtensionInstallEvent = vi.fn();
|
||||
return {
|
||||
...actual,
|
||||
ClearcutLogger: {
|
||||
getInstance: vi.fn(() => ({
|
||||
logExtensionInstallEvent: mockLogExtensionInstallEvent,
|
||||
})),
|
||||
},
|
||||
Config: vi.fn(),
|
||||
ExtensionInstallEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('child_process')>();
|
||||
return {
|
||||
@@ -63,59 +81,36 @@ vi.mock('child_process', async (importOriginal) => {
|
||||
const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
|
||||
|
||||
describe('loadExtensions', () => {
|
||||
let tempWorkspaceDir: string;
|
||||
let tempHomeDir: string;
|
||||
let workspaceExtensionsDir: string;
|
||||
let userExtensionsDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
|
||||
);
|
||||
tempHomeDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
|
||||
);
|
||||
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
|
||||
|
||||
workspaceExtensionsDir = path.join(
|
||||
tempWorkspaceDir,
|
||||
EXTENSIONS_DIRECTORY_NAME,
|
||||
);
|
||||
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
|
||||
userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
|
||||
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('ignores extensions in untrusted workspaces', () => {
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue(false);
|
||||
|
||||
const extensionDir = path.join(workspaceExtensionsDir, 'test-extension');
|
||||
fs.mkdirSync(extensionDir, { recursive: true });
|
||||
createExtension({
|
||||
extensionsDir: workspaceExtensionsDir,
|
||||
name: 'ext1',
|
||||
version: '1.0.0',
|
||||
addContextFile: true,
|
||||
});
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
expect(extensions.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should include extension path in loaded extension', () => {
|
||||
const extensionDir = path.join(workspaceExtensionsDir, 'test-extension');
|
||||
const extensionDir = path.join(userExtensionsDir, 'test-extension');
|
||||
fs.mkdirSync(extensionDir, { recursive: true });
|
||||
|
||||
createExtension({
|
||||
extensionsDir: workspaceExtensionsDir,
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
const extensions = loadExtensions();
|
||||
expect(extensions).toHaveLength(1);
|
||||
expect(extensions[0].path).toBe(extensionDir);
|
||||
expect(extensions[0].config.name).toBe('test-extension');
|
||||
@@ -123,70 +118,70 @@ describe('loadExtensions', () => {
|
||||
|
||||
it('should load context file path when GEMINI.md is present', () => {
|
||||
createExtension({
|
||||
extensionsDir: workspaceExtensionsDir,
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'ext1',
|
||||
version: '1.0.0',
|
||||
addContextFile: true,
|
||||
});
|
||||
createExtension({
|
||||
extensionsDir: workspaceExtensionsDir,
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'ext2',
|
||||
version: '2.0.0',
|
||||
});
|
||||
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
const extensions = loadExtensions();
|
||||
|
||||
expect(extensions).toHaveLength(2);
|
||||
const ext1 = extensions.find((e) => e.config.name === 'ext1');
|
||||
const ext2 = extensions.find((e) => e.config.name === 'ext2');
|
||||
expect(ext1?.contextFiles).toEqual([
|
||||
path.join(workspaceExtensionsDir, 'ext1', 'GEMINI.md'),
|
||||
path.join(userExtensionsDir, 'ext1', 'GEMINI.md'),
|
||||
]);
|
||||
expect(ext2?.contextFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it('should load context file path from the extension config', () => {
|
||||
createExtension({
|
||||
extensionsDir: workspaceExtensionsDir,
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'ext1',
|
||||
version: '1.0.0',
|
||||
addContextFile: false,
|
||||
contextFileName: 'my-context-file.md',
|
||||
});
|
||||
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
const extensions = loadExtensions();
|
||||
|
||||
expect(extensions).toHaveLength(1);
|
||||
const ext1 = extensions.find((e) => e.config.name === 'ext1');
|
||||
expect(ext1?.contextFiles).toEqual([
|
||||
path.join(workspaceExtensionsDir, 'ext1', 'my-context-file.md'),
|
||||
path.join(userExtensionsDir, 'ext1', 'my-context-file.md'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should filter out disabled extensions', () => {
|
||||
createExtension({
|
||||
extensionsDir: workspaceExtensionsDir,
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'ext1',
|
||||
version: '1.0.0',
|
||||
});
|
||||
createExtension({
|
||||
extensionsDir: workspaceExtensionsDir,
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'ext2',
|
||||
version: '2.0.0',
|
||||
});
|
||||
|
||||
const settingsDir = path.join(tempWorkspaceDir, GEMINI_DIR);
|
||||
const settingsDir = path.join(tempHomeDir, GEMINI_DIR);
|
||||
fs.mkdirSync(settingsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(settingsDir, 'settings.json'),
|
||||
JSON.stringify({ extensions: { disabled: ['ext1'] } }),
|
||||
);
|
||||
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
const extensions = loadExtensions();
|
||||
const activeExtensions = annotateActiveExtensions(
|
||||
extensions,
|
||||
[],
|
||||
tempWorkspaceDir,
|
||||
tempHomeDir,
|
||||
).filter((e) => e.isActive);
|
||||
expect(activeExtensions).toHaveLength(1);
|
||||
expect(activeExtensions[0].name).toBe('ext2');
|
||||
@@ -194,7 +189,7 @@ describe('loadExtensions', () => {
|
||||
|
||||
it('should hydrate variables', () => {
|
||||
createExtension({
|
||||
extensionsDir: workspaceExtensionsDir,
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
addContextFile: false,
|
||||
@@ -206,11 +201,11 @@ describe('loadExtensions', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
const extensions = loadExtensions();
|
||||
expect(extensions).toHaveLength(1);
|
||||
const loadedConfig = extensions[0].config;
|
||||
const expectedCwd = path.join(
|
||||
workspaceExtensionsDir,
|
||||
userExtensionsDir,
|
||||
'test-extension',
|
||||
'server',
|
||||
);
|
||||
@@ -218,6 +213,9 @@ describe('loadExtensions', () => {
|
||||
});
|
||||
|
||||
it('should load a linked extension correctly', async () => {
|
||||
const tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
|
||||
);
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempWorkspaceDir,
|
||||
name: 'my-linked-extension',
|
||||
@@ -231,7 +229,7 @@ describe('loadExtensions', () => {
|
||||
type: 'link',
|
||||
});
|
||||
expect(extensionName).toEqual('my-linked-extension');
|
||||
const extensions = loadExtensions(tempHomeDir);
|
||||
const extensions = loadExtensions();
|
||||
expect(extensions).toHaveLength(1);
|
||||
|
||||
const linkedExt = extensions[0];
|
||||
@@ -252,13 +250,13 @@ describe('loadExtensions', () => {
|
||||
process.env.TEST_DB_URL = 'postgresql://localhost:5432/testdb';
|
||||
|
||||
try {
|
||||
const workspaceExtensionsDir = path.join(
|
||||
tempWorkspaceDir,
|
||||
const userExtensionsDir = path.join(
|
||||
tempHomeDir,
|
||||
EXTENSIONS_DIRECTORY_NAME,
|
||||
);
|
||||
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
|
||||
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
||||
|
||||
const extDir = path.join(workspaceExtensionsDir, 'test-extension');
|
||||
const extDir = path.join(userExtensionsDir, 'test-extension');
|
||||
fs.mkdirSync(extDir);
|
||||
|
||||
// Write config to a separate file for clarity and good practices
|
||||
@@ -280,7 +278,7 @@ describe('loadExtensions', () => {
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(extensionConfig));
|
||||
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
const extensions = loadExtensions();
|
||||
|
||||
expect(extensions).toHaveLength(1);
|
||||
const extension = extensions[0];
|
||||
@@ -302,13 +300,10 @@ describe('loadExtensions', () => {
|
||||
});
|
||||
|
||||
it('should handle missing environment variables gracefully', () => {
|
||||
const workspaceExtensionsDir = path.join(
|
||||
tempWorkspaceDir,
|
||||
EXTENSIONS_DIRECTORY_NAME,
|
||||
);
|
||||
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
|
||||
const userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
|
||||
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
||||
|
||||
const extDir = path.join(workspaceExtensionsDir, 'test-extension');
|
||||
const extDir = path.join(userExtensionsDir, 'test-extension');
|
||||
fs.mkdirSync(extDir);
|
||||
|
||||
const extensionConfig = {
|
||||
@@ -331,7 +326,7 @@ describe('loadExtensions', () => {
|
||||
JSON.stringify(extensionConfig),
|
||||
);
|
||||
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
const extensions = loadExtensions();
|
||||
|
||||
expect(extensions).toHaveLength(1);
|
||||
const extension = extensions[0];
|
||||
@@ -542,6 +537,19 @@ describe('installExtension', () => {
|
||||
});
|
||||
fs.rmSync(targetExtDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should log to clearcut on successful install', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-local-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
await installExtension({ source: sourceExtDir, type: 'local' });
|
||||
|
||||
const logger = ClearcutLogger.getInstance({} as Config);
|
||||
expect(logger?.logExtensionInstallEvent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstallExtension', () => {
|
||||
@@ -592,7 +600,7 @@ describe('uninstallExtension', () => {
|
||||
await uninstallExtension('my-local-extension');
|
||||
|
||||
expect(fs.existsSync(sourceExtDir)).toBe(false);
|
||||
expect(loadExtensions(tempHomeDir)).toHaveLength(1);
|
||||
expect(loadExtensions()).toHaveLength(1);
|
||||
expect(fs.existsSync(otherExtDir)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -675,7 +683,7 @@ describe('performWorkspaceExtensionMigration', () => {
|
||||
});
|
||||
|
||||
await performWorkspaceExtensionMigration([loadExtension(ext1Path)!]);
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
const extensions = loadExtensions();
|
||||
|
||||
expect(extensions).toEqual([]);
|
||||
});
|
||||
@@ -703,7 +711,7 @@ describe('performWorkspaceExtensionMigration', () => {
|
||||
|
||||
const userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions');
|
||||
const userExt1Path = path.join(userExtensionsDir, 'ext1');
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
const extensions = loadExtensions();
|
||||
|
||||
expect(extensions).toHaveLength(2);
|
||||
const metadataPath = path.join(userExt1Path, INSTALL_METADATA_FILENAME);
|
||||
@@ -912,7 +920,7 @@ describe('enableExtension', () => {
|
||||
});
|
||||
|
||||
const getActiveExtensions = (): GeminiCLIExtension[] => {
|
||||
const extensions = loadExtensions(tempWorkspaceDir);
|
||||
const extensions = loadExtensions();
|
||||
const activeExtensions = annotateActiveExtensions(
|
||||
extensions,
|
||||
[],
|
||||
|
||||
@@ -8,7 +8,13 @@ import type {
|
||||
MCPServerConfig,
|
||||
GeminiCLIExtension,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { GEMINI_DIR, Storage } from '@google/gemini-cli-core';
|
||||
import {
|
||||
GEMINI_DIR,
|
||||
Storage,
|
||||
ClearcutLogger,
|
||||
Config,
|
||||
ExtensionInstallEvent,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
@@ -18,6 +24,7 @@ import { getErrorMessage } from '../utils/errors.js';
|
||||
import { recursivelyHydrateStrings } from './extensions/variables.js';
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
|
||||
|
||||
@@ -81,6 +88,10 @@ export class ExtensionStorage {
|
||||
}
|
||||
|
||||
export function getWorkspaceExtensions(workspaceDir: string): Extension[] {
|
||||
// If the workspace dir is the user extensions dir, there are no workspace extensions.
|
||||
if (path.resolve(workspaceDir) === path.resolve(os.homedir())) {
|
||||
return [];
|
||||
}
|
||||
return loadExtensionsFromDir(workspaceDir);
|
||||
}
|
||||
|
||||
@@ -119,7 +130,8 @@ export function loadExtensions(
|
||||
|
||||
if (
|
||||
(isWorkspaceTrusted(settings) ?? true) &&
|
||||
!settings.experimental?.extensionManagement
|
||||
// Default management setting to true
|
||||
!(settings.experimental?.extensionManagement ?? true)
|
||||
) {
|
||||
allExtensions.push(...getWorkspaceExtensions(workspaceDir));
|
||||
}
|
||||
@@ -345,83 +357,120 @@ export async function installExtension(
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
cwd: string = process.cwd(),
|
||||
): Promise<string> {
|
||||
const settings = loadSettings(cwd).merged;
|
||||
if (!isWorkspaceTrusted(settings)) {
|
||||
throw new Error(
|
||||
`Could not install extension from untrusted folder at ${installMetadata.source}`,
|
||||
);
|
||||
}
|
||||
|
||||
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
|
||||
await fs.promises.mkdir(extensionsDir, { recursive: true });
|
||||
|
||||
// Convert relative paths to absolute paths for the metadata file.
|
||||
if (
|
||||
!path.isAbsolute(installMetadata.source) &&
|
||||
(installMetadata.type === 'local' || installMetadata.type === 'link')
|
||||
) {
|
||||
installMetadata.source = path.resolve(cwd, installMetadata.source);
|
||||
}
|
||||
|
||||
let localSourcePath: string;
|
||||
let tempDir: string | undefined;
|
||||
let newExtensionName: string | undefined;
|
||||
|
||||
if (installMetadata.type === 'git') {
|
||||
tempDir = await ExtensionStorage.createTmpDir();
|
||||
await cloneFromGit(installMetadata.source, tempDir);
|
||||
localSourcePath = tempDir;
|
||||
} else if (
|
||||
installMetadata.type === 'local' ||
|
||||
installMetadata.type === 'link'
|
||||
) {
|
||||
localSourcePath = installMetadata.source;
|
||||
} else {
|
||||
throw new Error(`Unsupported install type: ${installMetadata.type}`);
|
||||
}
|
||||
const config = new Config({
|
||||
sessionId: randomUUID(),
|
||||
targetDir: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
model: '',
|
||||
debugMode: false,
|
||||
});
|
||||
const logger = ClearcutLogger.getInstance(config);
|
||||
let newExtensionConfig: ExtensionConfig | null = null;
|
||||
let localSourcePath: string | undefined;
|
||||
|
||||
try {
|
||||
const newExtensionConfig = await loadExtensionConfig(localSourcePath);
|
||||
if (!newExtensionConfig) {
|
||||
const settings = loadSettings(cwd).merged;
|
||||
if (!isWorkspaceTrusted(settings)) {
|
||||
throw new Error(
|
||||
`Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`,
|
||||
`Could not install extension from untrusted folder at ${installMetadata.source}`,
|
||||
);
|
||||
}
|
||||
|
||||
newExtensionName = newExtensionConfig.name;
|
||||
const extensionStorage = new ExtensionStorage(newExtensionName);
|
||||
const destinationPath = extensionStorage.getExtensionDir();
|
||||
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
|
||||
await fs.promises.mkdir(extensionsDir, { recursive: true });
|
||||
|
||||
const installedExtensions = loadUserExtensions();
|
||||
if (
|
||||
installedExtensions.some(
|
||||
(installed) => installed.config.name === newExtensionName,
|
||||
)
|
||||
!path.isAbsolute(installMetadata.source) &&
|
||||
(installMetadata.type === 'local' || installMetadata.type === 'link')
|
||||
) {
|
||||
throw new Error(
|
||||
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
|
||||
installMetadata.source = path.resolve(cwd, installMetadata.source);
|
||||
}
|
||||
|
||||
let tempDir: string | undefined;
|
||||
|
||||
if (installMetadata.type === 'git') {
|
||||
tempDir = await ExtensionStorage.createTmpDir();
|
||||
await cloneFromGit(installMetadata.source, tempDir);
|
||||
localSourcePath = tempDir;
|
||||
} else if (
|
||||
installMetadata.type === 'local' ||
|
||||
installMetadata.type === 'link'
|
||||
) {
|
||||
localSourcePath = installMetadata.source;
|
||||
} else {
|
||||
throw new Error(`Unsupported install type: ${installMetadata.type}`);
|
||||
}
|
||||
|
||||
try {
|
||||
newExtensionConfig = await loadExtensionConfig(localSourcePath);
|
||||
if (!newExtensionConfig) {
|
||||
throw new Error(
|
||||
`Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`,
|
||||
);
|
||||
}
|
||||
|
||||
const newExtensionName = newExtensionConfig.name;
|
||||
const extensionStorage = new ExtensionStorage(newExtensionName);
|
||||
const destinationPath = extensionStorage.getExtensionDir();
|
||||
|
||||
const installedExtensions = loadUserExtensions();
|
||||
if (
|
||||
installedExtensions.some(
|
||||
(installed) => installed.config.name === newExtensionName,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
|
||||
);
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(destinationPath, { recursive: true });
|
||||
|
||||
if (installMetadata.type === 'local' || installMetadata.type === 'git') {
|
||||
await copyExtension(localSourcePath, destinationPath);
|
||||
}
|
||||
|
||||
const metadataString = JSON.stringify(installMetadata, null, 2);
|
||||
const metadataPath = path.join(
|
||||
destinationPath,
|
||||
INSTALL_METADATA_FILENAME,
|
||||
);
|
||||
await fs.promises.writeFile(metadataPath, metadataString);
|
||||
} finally {
|
||||
if (tempDir) {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(destinationPath, { recursive: true });
|
||||
logger?.logExtensionInstallEvent(
|
||||
new ExtensionInstallEvent(
|
||||
newExtensionConfig!.name,
|
||||
newExtensionConfig!.version,
|
||||
installMetadata.source,
|
||||
'success',
|
||||
),
|
||||
);
|
||||
|
||||
if (installMetadata.type === 'local' || installMetadata.type === 'git') {
|
||||
await copyExtension(localSourcePath, destinationPath);
|
||||
}
|
||||
|
||||
const metadataString = JSON.stringify(installMetadata, null, 2);
|
||||
const metadataPath = path.join(destinationPath, INSTALL_METADATA_FILENAME);
|
||||
await fs.promises.writeFile(metadataPath, metadataString);
|
||||
} finally {
|
||||
if (tempDir) {
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
return newExtensionConfig!.name;
|
||||
} catch (error) {
|
||||
// Attempt to load config from the source path even if installation fails
|
||||
// to get the name and version for logging.
|
||||
if (!newExtensionConfig && localSourcePath) {
|
||||
newExtensionConfig = await loadExtensionConfig(localSourcePath);
|
||||
}
|
||||
logger?.logExtensionInstallEvent(
|
||||
new ExtensionInstallEvent(
|
||||
newExtensionConfig?.name ?? '',
|
||||
newExtensionConfig?.version ?? '',
|
||||
installMetadata.source,
|
||||
'error',
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return newExtensionName;
|
||||
}
|
||||
|
||||
async function loadExtensionConfig(
|
||||
export async function loadExtensionConfig(
|
||||
extensionDir: string,
|
||||
): Promise<ExtensionConfig | null> {
|
||||
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
|
||||
@@ -521,7 +521,7 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore folderTrust from workspace settings', () => {
|
||||
it('should use folderTrust from workspace settings when trusted', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const userSettingsContent = {
|
||||
security: {
|
||||
@@ -533,7 +533,7 @@ describe('Settings Loading and Merging', () => {
|
||||
const workspaceSettingsContent = {
|
||||
security: {
|
||||
folderTrust: {
|
||||
enabled: false, // This should be ignored
|
||||
enabled: false, // This should be used
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -554,7 +554,7 @@ describe('Settings Loading and Merging', () => {
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.security?.folderTrust?.enabled).toBe(true); // User setting should be used
|
||||
expect(settings.merged.security?.folderTrust?.enabled).toBe(false); // Workspace setting should be used
|
||||
});
|
||||
|
||||
it('should use system folderTrust over user setting', () => {
|
||||
|
||||
@@ -22,17 +22,17 @@ import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import {
|
||||
type Settings,
|
||||
type MemoryImportFormat,
|
||||
SETTINGS_SCHEMA,
|
||||
type MergeStrategy,
|
||||
type SettingsSchema,
|
||||
type SettingDefinition,
|
||||
getSettingsSchema,
|
||||
} from './settingsSchema.js';
|
||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
import { customDeepMerge } from '../utils/deepMerge.js';
|
||||
|
||||
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
|
||||
let current: SettingDefinition | undefined = undefined;
|
||||
let currentSchema: SettingsSchema | undefined = SETTINGS_SCHEMA;
|
||||
let currentSchema: SettingsSchema | undefined = getSettingsSchema();
|
||||
|
||||
for (const key of path) {
|
||||
if (!currentSchema || !currentSchema[key]) {
|
||||
@@ -76,7 +76,7 @@ const MIGRATION_MAP: Record<string, string> = {
|
||||
excludeTools: 'tools.exclude',
|
||||
excludeMCPServers: 'mcp.excluded',
|
||||
excludedProjectEnvVars: 'advanced.excludedEnvVars',
|
||||
extensionManagement: 'advanced.extensionManagement',
|
||||
extensionManagement: 'experimental.extensionManagement',
|
||||
extensions: 'extensions',
|
||||
fileFiltering: 'context.fileFiltering',
|
||||
folderTrustFeature: 'security.folderTrust.featureEnabled',
|
||||
@@ -329,18 +329,6 @@ function mergeSettings(
|
||||
): Settings {
|
||||
const safeWorkspace = isTrusted ? workspace : ({} as Settings);
|
||||
|
||||
// folderTrust is not supported at workspace level.
|
||||
const { security, ...restOfWorkspace } = safeWorkspace;
|
||||
const safeWorkspaceWithoutFolderTrust = security
|
||||
? {
|
||||
...restOfWorkspace,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
security: (({ folderTrust, ...rest }) => rest)(security),
|
||||
}
|
||||
: {
|
||||
...restOfWorkspace,
|
||||
};
|
||||
|
||||
// Settings are merged with the following precedence (last one wins for
|
||||
// single values):
|
||||
// 1. System Defaults
|
||||
@@ -352,7 +340,7 @@ function mergeSettings(
|
||||
{}, // Start with an empty object
|
||||
systemDefaults,
|
||||
user,
|
||||
safeWorkspaceWithoutFolderTrust,
|
||||
safeWorkspace,
|
||||
system,
|
||||
) as Settings;
|
||||
}
|
||||
|
||||
@@ -5,13 +5,17 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Settings } from './settingsSchema.js';
|
||||
import { SETTINGS_SCHEMA } from './settingsSchema.js';
|
||||
import {
|
||||
getSettingsSchema,
|
||||
type SettingDefinition,
|
||||
type Settings,
|
||||
type SettingsSchema,
|
||||
} from './settingsSchema.js';
|
||||
|
||||
describe('SettingsSchema', () => {
|
||||
describe('SETTINGS_SCHEMA', () => {
|
||||
describe('getSettingsSchema', () => {
|
||||
it('should contain all expected top-level settings', () => {
|
||||
const expectedSettings = [
|
||||
const expectedSettings: Array<keyof Settings> = [
|
||||
'mcpServers',
|
||||
'general',
|
||||
'ui',
|
||||
@@ -27,14 +31,12 @@ describe('SettingsSchema', () => {
|
||||
];
|
||||
|
||||
expectedSettings.forEach((setting) => {
|
||||
expect(
|
||||
SETTINGS_SCHEMA[setting as keyof typeof SETTINGS_SCHEMA],
|
||||
).toBeDefined();
|
||||
expect(getSettingsSchema()[setting as keyof Settings]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have correct structure for each setting', () => {
|
||||
Object.entries(SETTINGS_SCHEMA).forEach(([_key, definition]) => {
|
||||
Object.entries(getSettingsSchema()).forEach(([_key, definition]) => {
|
||||
expect(definition).toHaveProperty('type');
|
||||
expect(definition).toHaveProperty('label');
|
||||
expect(definition).toHaveProperty('category');
|
||||
@@ -48,7 +50,7 @@ describe('SettingsSchema', () => {
|
||||
});
|
||||
|
||||
it('should have correct nested setting structure', () => {
|
||||
const nestedSettings = [
|
||||
const nestedSettings: Array<keyof Settings> = [
|
||||
'general',
|
||||
'ui',
|
||||
'ide',
|
||||
@@ -62,11 +64,9 @@ describe('SettingsSchema', () => {
|
||||
];
|
||||
|
||||
nestedSettings.forEach((setting) => {
|
||||
const definition = SETTINGS_SCHEMA[
|
||||
setting as keyof typeof SETTINGS_SCHEMA
|
||||
] as (typeof SETTINGS_SCHEMA)[keyof typeof SETTINGS_SCHEMA] & {
|
||||
properties: unknown;
|
||||
};
|
||||
const definition = getSettingsSchema()[
|
||||
setting as keyof Settings
|
||||
] as SettingDefinition;
|
||||
expect(definition.type).toBe('object');
|
||||
expect(definition.properties).toBeDefined();
|
||||
expect(typeof definition.properties).toBe('object');
|
||||
@@ -75,35 +75,36 @@ describe('SettingsSchema', () => {
|
||||
|
||||
it('should have accessibility nested properties', () => {
|
||||
expect(
|
||||
SETTINGS_SCHEMA.ui?.properties?.accessibility?.properties,
|
||||
getSettingsSchema().ui?.properties?.accessibility?.properties,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
SETTINGS_SCHEMA.ui?.properties?.accessibility.properties
|
||||
getSettingsSchema().ui?.properties?.accessibility.properties
|
||||
?.disableLoadingPhrases.type,
|
||||
).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should have checkpointing nested properties', () => {
|
||||
expect(
|
||||
SETTINGS_SCHEMA.general?.properties?.checkpointing.properties?.enabled,
|
||||
getSettingsSchema().general?.properties?.checkpointing.properties
|
||||
?.enabled,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
SETTINGS_SCHEMA.general?.properties?.checkpointing.properties?.enabled
|
||||
.type,
|
||||
getSettingsSchema().general?.properties?.checkpointing.properties
|
||||
?.enabled.type,
|
||||
).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should have fileFiltering nested properties', () => {
|
||||
expect(
|
||||
SETTINGS_SCHEMA.context.properties.fileFiltering.properties
|
||||
getSettingsSchema().context.properties.fileFiltering.properties
|
||||
?.respectGitIgnore,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
SETTINGS_SCHEMA.context.properties.fileFiltering.properties
|
||||
getSettingsSchema().context.properties.fileFiltering.properties
|
||||
?.respectGeminiIgnore,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
SETTINGS_SCHEMA.context.properties.fileFiltering.properties
|
||||
getSettingsSchema().context.properties.fileFiltering.properties
|
||||
?.enableRecursiveFileSearch,
|
||||
).toBeDefined();
|
||||
});
|
||||
@@ -112,7 +113,7 @@ describe('SettingsSchema', () => {
|
||||
const categories = new Set();
|
||||
|
||||
// Collect categories from top-level settings
|
||||
Object.values(SETTINGS_SCHEMA).forEach((definition) => {
|
||||
Object.values(getSettingsSchema()).forEach((definition) => {
|
||||
categories.add(definition.category);
|
||||
// Also collect from nested properties
|
||||
const defWithProps = definition as typeof definition & {
|
||||
@@ -137,74 +138,80 @@ describe('SettingsSchema', () => {
|
||||
});
|
||||
|
||||
it('should have consistent default values for boolean settings', () => {
|
||||
const checkBooleanDefaults = (schema: Record<string, unknown>) => {
|
||||
Object.entries(schema).forEach(
|
||||
([_key, definition]: [string, unknown]) => {
|
||||
const def = definition as {
|
||||
type?: string;
|
||||
default?: unknown;
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
if (def.type === 'boolean') {
|
||||
// Boolean settings can have boolean or undefined defaults (for optional settings)
|
||||
expect(['boolean', 'undefined']).toContain(typeof def.default);
|
||||
}
|
||||
if (def.properties) {
|
||||
checkBooleanDefaults(def.properties);
|
||||
}
|
||||
},
|
||||
);
|
||||
const checkBooleanDefaults = (schema: SettingsSchema) => {
|
||||
Object.entries(schema).forEach(([, definition]) => {
|
||||
const def = definition as SettingDefinition;
|
||||
if (def.type === 'boolean') {
|
||||
// Boolean settings can have boolean or undefined defaults (for optional settings)
|
||||
expect(['boolean', 'undefined']).toContain(typeof def.default);
|
||||
}
|
||||
if (def.properties) {
|
||||
checkBooleanDefaults(def.properties);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
checkBooleanDefaults(SETTINGS_SCHEMA as Record<string, unknown>);
|
||||
checkBooleanDefaults(getSettingsSchema() as SettingsSchema);
|
||||
});
|
||||
|
||||
it('should have showInDialog property configured', () => {
|
||||
// Check that user-facing settings are marked for dialog display
|
||||
expect(SETTINGS_SCHEMA.ui.properties.showMemoryUsage.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
expect(SETTINGS_SCHEMA.general.properties.vimMode.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
expect(SETTINGS_SCHEMA.ide.properties.enabled.showInDialog).toBe(true);
|
||||
expect(
|
||||
SETTINGS_SCHEMA.general.properties.disableAutoUpdate.showInDialog,
|
||||
getSettingsSchema().ui.properties.showMemoryUsage.showInDialog,
|
||||
).toBe(true);
|
||||
expect(SETTINGS_SCHEMA.ui.properties.hideWindowTitle.showInDialog).toBe(
|
||||
expect(getSettingsSchema().general.properties.vimMode.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
expect(getSettingsSchema().ide.properties.enabled.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
expect(SETTINGS_SCHEMA.ui.properties.hideTips.showInDialog).toBe(true);
|
||||
expect(SETTINGS_SCHEMA.ui.properties.hideBanner.showInDialog).toBe(true);
|
||||
expect(
|
||||
SETTINGS_SCHEMA.privacy.properties.usageStatisticsEnabled.showInDialog,
|
||||
getSettingsSchema().general.properties.disableAutoUpdate.showInDialog,
|
||||
).toBe(true);
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.hideWindowTitle.showInDialog,
|
||||
).toBe(true);
|
||||
expect(getSettingsSchema().ui.properties.hideTips.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
expect(getSettingsSchema().ui.properties.hideBanner.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
getSettingsSchema().privacy.properties.usageStatisticsEnabled
|
||||
.showInDialog,
|
||||
).toBe(false);
|
||||
|
||||
// Check that advanced settings are hidden from dialog
|
||||
expect(SETTINGS_SCHEMA.security.properties.auth.showInDialog).toBe(false);
|
||||
expect(SETTINGS_SCHEMA.tools.properties.core.showInDialog).toBe(false);
|
||||
expect(SETTINGS_SCHEMA.mcpServers.showInDialog).toBe(false);
|
||||
expect(SETTINGS_SCHEMA.telemetry.showInDialog).toBe(false);
|
||||
expect(getSettingsSchema().security.properties.auth.showInDialog).toBe(
|
||||
false,
|
||||
);
|
||||
expect(getSettingsSchema().tools.properties.core.showInDialog).toBe(
|
||||
false,
|
||||
);
|
||||
expect(getSettingsSchema().mcpServers.showInDialog).toBe(false);
|
||||
expect(getSettingsSchema().telemetry.showInDialog).toBe(false);
|
||||
|
||||
// Check that some settings are appropriately hidden
|
||||
expect(SETTINGS_SCHEMA.ui.properties.theme.showInDialog).toBe(false); // Changed to false
|
||||
expect(SETTINGS_SCHEMA.ui.properties.customThemes.showInDialog).toBe(
|
||||
expect(getSettingsSchema().ui.properties.theme.showInDialog).toBe(false); // Changed to false
|
||||
expect(getSettingsSchema().ui.properties.customThemes.showInDialog).toBe(
|
||||
false,
|
||||
); // Managed via theme editor
|
||||
expect(
|
||||
SETTINGS_SCHEMA.general.properties.checkpointing.showInDialog,
|
||||
getSettingsSchema().general.properties.checkpointing.showInDialog,
|
||||
).toBe(false); // Experimental feature
|
||||
expect(SETTINGS_SCHEMA.ui.properties.accessibility.showInDialog).toBe(
|
||||
expect(getSettingsSchema().ui.properties.accessibility.showInDialog).toBe(
|
||||
false,
|
||||
); // Changed to false
|
||||
expect(
|
||||
SETTINGS_SCHEMA.context.properties.fileFiltering.showInDialog,
|
||||
getSettingsSchema().context.properties.fileFiltering.showInDialog,
|
||||
).toBe(false); // Changed to false
|
||||
expect(
|
||||
SETTINGS_SCHEMA.general.properties.preferredEditor.showInDialog,
|
||||
getSettingsSchema().general.properties.preferredEditor.showInDialog,
|
||||
).toBe(false); // Changed to false
|
||||
expect(
|
||||
SETTINGS_SCHEMA.advanced.properties.autoConfigureMemory.showInDialog,
|
||||
getSettingsSchema().advanced.properties.autoConfigureMemory
|
||||
.showInDialog,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -228,80 +235,84 @@ describe('SettingsSchema', () => {
|
||||
|
||||
it('should have includeDirectories setting in schema', () => {
|
||||
expect(
|
||||
SETTINGS_SCHEMA.context?.properties.includeDirectories,
|
||||
getSettingsSchema().context?.properties.includeDirectories,
|
||||
).toBeDefined();
|
||||
expect(SETTINGS_SCHEMA.context?.properties.includeDirectories.type).toBe(
|
||||
'array',
|
||||
);
|
||||
expect(
|
||||
SETTINGS_SCHEMA.context?.properties.includeDirectories.category,
|
||||
getSettingsSchema().context?.properties.includeDirectories.type,
|
||||
).toBe('array');
|
||||
expect(
|
||||
getSettingsSchema().context?.properties.includeDirectories.category,
|
||||
).toBe('Context');
|
||||
expect(
|
||||
SETTINGS_SCHEMA.context?.properties.includeDirectories.default,
|
||||
getSettingsSchema().context?.properties.includeDirectories.default,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('should have loadMemoryFromIncludeDirectories setting in schema', () => {
|
||||
expect(
|
||||
SETTINGS_SCHEMA.context?.properties.loadMemoryFromIncludeDirectories,
|
||||
getSettingsSchema().context?.properties
|
||||
.loadMemoryFromIncludeDirectories,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
SETTINGS_SCHEMA.context?.properties.loadMemoryFromIncludeDirectories
|
||||
getSettingsSchema().context?.properties.loadMemoryFromIncludeDirectories
|
||||
.type,
|
||||
).toBe('boolean');
|
||||
expect(
|
||||
SETTINGS_SCHEMA.context?.properties.loadMemoryFromIncludeDirectories
|
||||
getSettingsSchema().context?.properties.loadMemoryFromIncludeDirectories
|
||||
.category,
|
||||
).toBe('Context');
|
||||
expect(
|
||||
SETTINGS_SCHEMA.context?.properties.loadMemoryFromIncludeDirectories
|
||||
getSettingsSchema().context?.properties.loadMemoryFromIncludeDirectories
|
||||
.default,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should have folderTrustFeature setting in schema', () => {
|
||||
expect(
|
||||
SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled,
|
||||
getSettingsSchema().security.properties.folderTrust.properties.enabled,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled.type,
|
||||
getSettingsSchema().security.properties.folderTrust.properties.enabled
|
||||
.type,
|
||||
).toBe('boolean');
|
||||
expect(
|
||||
SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled
|
||||
getSettingsSchema().security.properties.folderTrust.properties.enabled
|
||||
.category,
|
||||
).toBe('Security');
|
||||
expect(
|
||||
SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled
|
||||
getSettingsSchema().security.properties.folderTrust.properties.enabled
|
||||
.default,
|
||||
).toBe(false);
|
||||
expect(
|
||||
SETTINGS_SCHEMA.security.properties.folderTrust.properties.enabled
|
||||
getSettingsSchema().security.properties.folderTrust.properties.enabled
|
||||
.showInDialog,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should have debugKeystrokeLogging setting in schema', () => {
|
||||
expect(
|
||||
SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging,
|
||||
getSettingsSchema().general.properties.debugKeystrokeLogging,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.type,
|
||||
getSettingsSchema().general.properties.debugKeystrokeLogging.type,
|
||||
).toBe('boolean');
|
||||
expect(
|
||||
SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.category,
|
||||
getSettingsSchema().general.properties.debugKeystrokeLogging.category,
|
||||
).toBe('General');
|
||||
expect(
|
||||
SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.default,
|
||||
getSettingsSchema().general.properties.debugKeystrokeLogging.default,
|
||||
).toBe(false);
|
||||
expect(
|
||||
SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging
|
||||
getSettingsSchema().general.properties.debugKeystrokeLogging
|
||||
.requiresRestart,
|
||||
).toBe(false);
|
||||
expect(
|
||||
SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.showInDialog,
|
||||
getSettingsSchema().general.properties.debugKeystrokeLogging
|
||||
.showInDialog,
|
||||
).toBe(true);
|
||||
expect(
|
||||
SETTINGS_SCHEMA.general.properties.debugKeystrokeLogging.description,
|
||||
getSettingsSchema().general.properties.debugKeystrokeLogging
|
||||
.description,
|
||||
).toBe('Enable debug logging of keystrokes to the console.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,8 +11,43 @@ import type {
|
||||
AuthType,
|
||||
ChatCompressionSettings,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { CustomTheme } from '../ui/themes/theme.js';
|
||||
|
||||
export type SettingsType =
|
||||
| 'boolean'
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'array'
|
||||
| 'object'
|
||||
| 'enum';
|
||||
|
||||
export type SettingsValue =
|
||||
| boolean
|
||||
| string
|
||||
| number
|
||||
| string[]
|
||||
| object
|
||||
| undefined;
|
||||
|
||||
/**
|
||||
* Setting datatypes that "toggle" through a fixed list of options
|
||||
* (e.g. an enum or true/false) rather than allowing for free form input
|
||||
* (like a number or string).
|
||||
*/
|
||||
export const TOGGLE_TYPES: ReadonlySet<SettingsType | undefined> = new Set([
|
||||
'boolean',
|
||||
'enum',
|
||||
]);
|
||||
|
||||
export interface SettingEnumOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export enum MergeStrategy {
|
||||
// Replace the old value with the new value. This is the default.
|
||||
REPLACE = 'replace',
|
||||
@@ -25,11 +60,11 @@ export enum MergeStrategy {
|
||||
}
|
||||
|
||||
export interface SettingDefinition {
|
||||
type: 'boolean' | 'string' | 'number' | 'array' | 'object';
|
||||
type: SettingsType;
|
||||
label: string;
|
||||
category: string;
|
||||
requiresRestart: boolean;
|
||||
default: boolean | string | number | string[] | object | undefined;
|
||||
default: SettingsValue;
|
||||
description?: string;
|
||||
parentKey?: string;
|
||||
childKey?: string;
|
||||
@@ -37,6 +72,8 @@ export interface SettingDefinition {
|
||||
properties?: SettingsSchema;
|
||||
showInDialog?: boolean;
|
||||
mergeStrategy?: MergeStrategy;
|
||||
/** Enum type options */
|
||||
options?: readonly SettingEnumOption[];
|
||||
}
|
||||
|
||||
export interface SettingsSchema {
|
||||
@@ -51,7 +88,7 @@ export type DnsResolutionOrder = 'ipv4first' | 'verbatim';
|
||||
* The structure of this object defines the structure of the `Settings` type.
|
||||
* `as const` is crucial for TypeScript to infer the most specific types possible.
|
||||
*/
|
||||
export const SETTINGS_SCHEMA = {
|
||||
const SETTINGS_SCHEMA = {
|
||||
// Maintained for compatibility/criticality
|
||||
mcpServers: {
|
||||
type: 'object',
|
||||
@@ -654,6 +691,34 @@ export const SETTINGS_SCHEMA = {
|
||||
'Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.',
|
||||
showInDialog: true,
|
||||
},
|
||||
enableToolOutputTruncation: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Tool Output Truncation',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable truncation of large tool outputs.',
|
||||
showInDialog: true,
|
||||
},
|
||||
truncateToolOutputThreshold: {
|
||||
type: 'number',
|
||||
label: 'Tool Output Truncation Threshold',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
description:
|
||||
'Truncate tool output if it is larger than this many characters. Set to -1 to disable.',
|
||||
showInDialog: true,
|
||||
},
|
||||
truncateToolOutputLines: {
|
||||
type: 'number',
|
||||
label: 'Tool Output Truncation Lines',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
description: 'The number of lines to keep when truncating tool output.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -838,7 +903,7 @@ export const SETTINGS_SCHEMA = {
|
||||
label: 'Extension Management',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
default: true,
|
||||
description: 'Enable extension management features.',
|
||||
showInDialog: false,
|
||||
},
|
||||
@@ -877,7 +942,13 @@ export const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
} as const satisfies SettingsSchema;
|
||||
|
||||
export type SettingsSchemaType = typeof SETTINGS_SCHEMA;
|
||||
|
||||
export function getSettingsSchema(): SettingsSchemaType {
|
||||
return SETTINGS_SCHEMA;
|
||||
}
|
||||
|
||||
type InferSettings<T extends SettingsSchema> = {
|
||||
-readonly [K in keyof T]?: T[K] extends { properties: SettingsSchema }
|
||||
@@ -887,7 +958,7 @@ type InferSettings<T extends SettingsSchema> = {
|
||||
: T[K]['default'];
|
||||
};
|
||||
|
||||
export type Settings = InferSettings<typeof SETTINGS_SCHEMA>;
|
||||
export type Settings = InferSettings<SettingsSchemaType>;
|
||||
|
||||
export interface FooterSettings {
|
||||
hideCWD?: boolean;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type AuthType,
|
||||
type Config,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
* Handles the initial authentication flow.
|
||||
* @param config The application config.
|
||||
* @param authType The selected auth type.
|
||||
* @returns An error message if authentication fails, otherwise null.
|
||||
*/
|
||||
export async function performInitialAuth(
|
||||
config: Config,
|
||||
authType: AuthType | undefined,
|
||||
): Promise<string | null> {
|
||||
if (!authType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await config.refreshAuth(authType);
|
||||
// The console.log is intentionally left out here.
|
||||
// We can add a dedicated startup message later if needed.
|
||||
} catch (e) {
|
||||
return `Failed to login. Message: ${getErrorMessage(e)}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import { type LoadedSettings } from '../config/settings.js';
|
||||
import { performInitialAuth } from './auth.js';
|
||||
import { validateTheme } from './theme.js';
|
||||
|
||||
export interface InitializationResult {
|
||||
authError: string | null;
|
||||
themeError: string | null;
|
||||
shouldOpenAuthDialog: boolean;
|
||||
geminiMdFileCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates the application's startup initialization.
|
||||
* This runs BEFORE the React UI is rendered.
|
||||
* @param config The application config.
|
||||
* @param settings The loaded application settings.
|
||||
* @returns The results of the initialization.
|
||||
*/
|
||||
export async function initializeApp(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
): Promise<InitializationResult> {
|
||||
const authError = await performInitialAuth(
|
||||
config,
|
||||
settings.merged.security?.auth?.selectedType,
|
||||
);
|
||||
const themeError = validateTheme(settings);
|
||||
|
||||
const shouldOpenAuthDialog =
|
||||
settings.merged.security?.auth?.selectedType === undefined || !!authError;
|
||||
|
||||
return {
|
||||
authError,
|
||||
themeError,
|
||||
shouldOpenAuthDialog,
|
||||
geminiMdFileCount: config.getGeminiMdFileCount(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { themeManager } from '../ui/themes/theme-manager.js';
|
||||
import { type LoadedSettings } from '../config/settings.js';
|
||||
|
||||
/**
|
||||
* Validates the configured theme.
|
||||
* @param settings The loaded application settings.
|
||||
* @returns An error message if the theme is not found, otherwise null.
|
||||
*/
|
||||
export function validateTheme(settings: LoadedSettings): string | null {
|
||||
const effectiveTheme = settings.merged.ui?.theme;
|
||||
if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) {
|
||||
return `Theme "${effectiveTheme}" not found.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -4,8 +4,17 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import {
|
||||
main,
|
||||
setupUnhandledRejectionHandler,
|
||||
validateDnsResolutionOrder,
|
||||
startInteractiveUI,
|
||||
@@ -33,14 +42,10 @@ vi.mock('./config/settings.js', async (importOriginal) => {
|
||||
|
||||
vi.mock('./config/config.js', () => ({
|
||||
loadCliConfig: vi.fn().mockResolvedValue({
|
||||
config: {
|
||||
getSandbox: vi.fn(() => false),
|
||||
getQuestion: vi.fn(() => ''),
|
||||
},
|
||||
modelWasSwitched: false,
|
||||
originalModelBeforeSwitch: null,
|
||||
finalModel: 'test-model',
|
||||
}),
|
||||
getSandbox: vi.fn(() => false),
|
||||
getQuestion: vi.fn(() => ''),
|
||||
} as unknown as Config),
|
||||
parseArguments: vi.fn().mockResolvedValue({ sessionSummary: null }),
|
||||
}));
|
||||
|
||||
vi.mock('read-package-up', () => ({
|
||||
@@ -157,6 +162,88 @@ describe('gemini.tsx main function', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('gemini.tsx main function kitty protocol', () => {
|
||||
let setRawModeSpy: MockInstance<
|
||||
(mode: boolean) => NodeJS.ReadStream & { fd: 0 }
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (!(process.stdin as any).setRawMode) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(process.stdin as any).setRawMode = vi.fn();
|
||||
}
|
||||
setRawModeSpy = vi.spyOn(process.stdin, 'setRawMode');
|
||||
});
|
||||
|
||||
it('should call setRawMode and detectAndEnableKittyProtocol when isInteractive is true', async () => {
|
||||
const { detectAndEnableKittyProtocol } = await import(
|
||||
'./ui/utils/kittyProtocolDetector.js'
|
||||
);
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
vi.mocked(loadCliConfig).mockResolvedValue({
|
||||
isInteractive: () => true,
|
||||
getQuestion: () => '',
|
||||
getSandbox: () => false,
|
||||
getDebugMode: () => false,
|
||||
getListExtensions: () => false,
|
||||
getMcpServers: () => ({}),
|
||||
initialize: vi.fn(),
|
||||
getIdeMode: () => false,
|
||||
getExperimentalZedIntegration: () => false,
|
||||
getScreenReader: () => false,
|
||||
getGeminiMdFileCount: () => 0,
|
||||
} as unknown as Config);
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
errors: [],
|
||||
merged: {
|
||||
advanced: {},
|
||||
security: { auth: {} },
|
||||
ui: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
} as never);
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
model: undefined,
|
||||
sandbox: undefined,
|
||||
sandboxImage: undefined,
|
||||
debug: undefined,
|
||||
prompt: undefined,
|
||||
promptInteractive: undefined,
|
||||
allFiles: undefined,
|
||||
showMemoryUsage: undefined,
|
||||
yolo: undefined,
|
||||
approvalMode: undefined,
|
||||
telemetry: undefined,
|
||||
checkpointing: undefined,
|
||||
telemetryTarget: undefined,
|
||||
telemetryOtlpEndpoint: undefined,
|
||||
telemetryOtlpProtocol: undefined,
|
||||
telemetryLogPrompts: undefined,
|
||||
telemetryOutfile: undefined,
|
||||
allowedMcpServerNames: undefined,
|
||||
allowedTools: undefined,
|
||||
experimentalAcp: undefined,
|
||||
extensions: undefined,
|
||||
listExtensions: undefined,
|
||||
proxy: undefined,
|
||||
includeDirectories: undefined,
|
||||
screenReader: undefined,
|
||||
useSmartEdit: undefined,
|
||||
sessionSummary: undefined,
|
||||
promptWords: undefined,
|
||||
});
|
||||
|
||||
await main();
|
||||
|
||||
expect(setRawModeSpy).toHaveBeenCalledWith(true);
|
||||
expect(detectAndEnableKittyProtocol).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateDnsResolutionOrder', () => {
|
||||
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
@@ -213,7 +300,7 @@ describe('startInteractiveUI', () => {
|
||||
}));
|
||||
|
||||
vi.mock('./ui/utils/kittyProtocolDetector.js', () => ({
|
||||
detectAndEnableKittyProtocol: vi.fn(() => Promise.resolve()),
|
||||
detectAndEnableKittyProtocol: vi.fn(() => Promise.resolve(true)),
|
||||
}));
|
||||
|
||||
vi.mock('./ui/utils/updateCheck.js', () => ({
|
||||
@@ -237,11 +324,19 @@ describe('startInteractiveUI', () => {
|
||||
const { render } = await import('ink');
|
||||
const renderSpy = vi.mocked(render);
|
||||
|
||||
const mockInitializationResult = {
|
||||
authError: null,
|
||||
themeError: null,
|
||||
shouldOpenAuthDialog: false,
|
||||
geminiMdFileCount: 0,
|
||||
};
|
||||
|
||||
await startInteractiveUI(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
mockStartupWarnings,
|
||||
mockWorkspaceRoot,
|
||||
mockInitializationResult,
|
||||
);
|
||||
|
||||
// Verify render was called with correct options
|
||||
@@ -260,22 +355,26 @@ describe('startInteractiveUI', () => {
|
||||
|
||||
it('should perform all startup tasks in correct order', async () => {
|
||||
const { getCliVersion } = await import('./utils/version.js');
|
||||
const { detectAndEnableKittyProtocol } = await import(
|
||||
'./ui/utils/kittyProtocolDetector.js'
|
||||
);
|
||||
const { checkForUpdates } = await import('./ui/utils/updateCheck.js');
|
||||
const { registerCleanup } = await import('./utils/cleanup.js');
|
||||
|
||||
const mockInitializationResult = {
|
||||
authError: null,
|
||||
themeError: null,
|
||||
shouldOpenAuthDialog: false,
|
||||
geminiMdFileCount: 0,
|
||||
};
|
||||
|
||||
await startInteractiveUI(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
mockStartupWarnings,
|
||||
mockWorkspaceRoot,
|
||||
mockInitializationResult,
|
||||
);
|
||||
|
||||
// Verify all startup tasks were called
|
||||
expect(getCliVersion).toHaveBeenCalledTimes(1);
|
||||
expect(detectAndEnableKittyProtocol).toHaveBeenCalledTimes(1);
|
||||
expect(registerCleanup).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify cleanup handler is registered with unmount function
|
||||
|
||||
+77
-68
@@ -4,10 +4,9 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { render, Box, Text } from 'ink';
|
||||
import Spinner from 'ink-spinner';
|
||||
import { AppWrapper } from './ui/App.js';
|
||||
import React from 'react';
|
||||
import { render } from 'ink';
|
||||
import { AppContainer } from './ui/AppContainer.js';
|
||||
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||
import { readStdin } from './utils/readStdin.js';
|
||||
import { basename } from 'node:path';
|
||||
@@ -38,6 +37,10 @@ import {
|
||||
getOauthClient,
|
||||
uiTelemetryService,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
initializeApp,
|
||||
type InitializationResult,
|
||||
} from './core/initializer.js';
|
||||
import { validateAuthMethod } from './config/auth.js';
|
||||
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
|
||||
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||
@@ -47,6 +50,10 @@ import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
||||
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
||||
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
|
||||
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
||||
|
||||
export function validateDnsResolutionOrder(
|
||||
order: string | undefined,
|
||||
@@ -109,38 +116,6 @@ async function relaunchWithAdditionalArgs(additionalArgs: string[]) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const InitializingComponent = ({ initialTotal }: { initialTotal: number }) => {
|
||||
const [total, setTotal] = useState(initialTotal);
|
||||
const [connected, setConnected] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const onStart = ({ count }: { count: number }) => setTotal(count);
|
||||
const onChange = () => {
|
||||
setConnected((val) => val + 1);
|
||||
};
|
||||
|
||||
appEvents.on('mcp-servers-discovery-start', onStart);
|
||||
appEvents.on('mcp-server-connected', onChange);
|
||||
appEvents.on('mcp-server-error', onChange);
|
||||
|
||||
return () => {
|
||||
appEvents.off('mcp-servers-discovery-start', onStart);
|
||||
appEvents.off('mcp-server-connected', onChange);
|
||||
appEvents.off('mcp-server-error', onChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const message = `Connecting to MCP servers... (${connected}/${total})`;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text>
|
||||
<Spinner /> {message}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
import { runZedIntegration } from './zed-integration/zedIntegration.js';
|
||||
|
||||
export function setupUnhandledRejectionHandler() {
|
||||
@@ -170,23 +145,45 @@ export async function startInteractiveUI(
|
||||
settings: LoadedSettings,
|
||||
startupWarnings: string[],
|
||||
workspaceRoot: string = process.cwd(),
|
||||
initializationResult: InitializationResult,
|
||||
) {
|
||||
const version = await getCliVersion();
|
||||
// Detect and enable Kitty keyboard protocol once at startup
|
||||
await detectAndEnableKittyProtocol();
|
||||
setWindowTitle(basename(workspaceRoot), settings);
|
||||
|
||||
// Create wrapper component to use hooks inside render
|
||||
const AppWrapper = () => {
|
||||
const kittyProtocolStatus = useKittyKeyboardProtocol();
|
||||
return (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider
|
||||
kittyProtocolEnabled={kittyProtocolStatus.enabled}
|
||||
config={config}
|
||||
debugKeystrokeLogging={settings.merged.general?.debugKeystrokeLogging}
|
||||
>
|
||||
<SessionStatsProvider>
|
||||
<VimModeProvider settings={settings}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
settings={settings}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
initializationResult={initializationResult}
|
||||
/>
|
||||
</VimModeProvider>
|
||||
</SessionStatsProvider>
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const instance = render(
|
||||
<React.StrictMode>
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<AppWrapper
|
||||
config={config}
|
||||
settings={settings}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
/>
|
||||
</SettingsContext.Provider>
|
||||
<AppWrapper />
|
||||
</React.StrictMode>,
|
||||
{ exitOnCtrlC: false, isScreenReaderEnabled: config.getScreenReader() },
|
||||
{
|
||||
exitOnCtrlC: false,
|
||||
isScreenReaderEnabled: config.getScreenReader(),
|
||||
},
|
||||
);
|
||||
|
||||
checkForUpdates()
|
||||
@@ -218,6 +215,24 @@ export async function main() {
|
||||
argv,
|
||||
);
|
||||
|
||||
const wasRaw = process.stdin.isRaw;
|
||||
let kittyProtocolDetectionComplete: Promise<boolean> | undefined;
|
||||
if (config.isInteractive() && !wasRaw) {
|
||||
// Set this as early as possible to avoid spurious characters from
|
||||
// input showing up in the output.
|
||||
process.stdin.setRawMode(true);
|
||||
|
||||
// This cleanup isn't strictly needed but may help in certain situations.
|
||||
process.on('SIGTERM', () => {
|
||||
process.stdin.setRawMode(wasRaw);
|
||||
});
|
||||
process.on('SIGINT', () => {
|
||||
process.stdin.setRawMode(wasRaw);
|
||||
});
|
||||
|
||||
// Detect and enable Kitty keyboard protocol once at startup.
|
||||
kittyProtocolDetectionComplete = detectAndEnableKittyProtocol();
|
||||
}
|
||||
if (argv.sessionSummary) {
|
||||
registerCleanup(() => {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
@@ -267,36 +282,19 @@ export async function main() {
|
||||
|
||||
setMaxSizedBoxDebugging(config.getDebugMode());
|
||||
|
||||
const mcpServers = config.getMcpServers();
|
||||
const mcpServersCount = mcpServers ? Object.keys(mcpServers).length : 0;
|
||||
|
||||
let spinnerInstance;
|
||||
if (config.isInteractive() && mcpServersCount > 0) {
|
||||
spinnerInstance = render(
|
||||
<InitializingComponent initialTotal={mcpServersCount} />,
|
||||
);
|
||||
}
|
||||
|
||||
await config.initialize();
|
||||
|
||||
if (spinnerInstance) {
|
||||
// Small UX detail to show the completion message for a bit before unmounting.
|
||||
await new Promise((f) => setTimeout(f, 100));
|
||||
spinnerInstance.clear();
|
||||
spinnerInstance.unmount();
|
||||
}
|
||||
|
||||
// Load custom themes from settings
|
||||
themeManager.loadCustomThemes(settings.merged.ui?.customThemes);
|
||||
|
||||
if (settings.merged.ui?.theme) {
|
||||
if (!themeManager.setActiveTheme(settings.merged.ui?.theme)) {
|
||||
// If the theme is not found during initial load, log a warning and continue.
|
||||
// The useThemeCommand hook in App.tsx will handle opening the dialog.
|
||||
// The useThemeCommand hook in AppContainer.tsx will handle opening the dialog.
|
||||
console.warn(`Warning: Theme "${settings.merged.ui?.theme}" not found.`);
|
||||
}
|
||||
}
|
||||
|
||||
const initializationResult = await initializeApp(config, settings);
|
||||
|
||||
// hop into sandbox if we are outside and sandboxing is enabled
|
||||
if (!process.env['SANDBOX']) {
|
||||
const memoryArgs = settings.merged.advanced?.autoConfigureMemory
|
||||
@@ -385,9 +383,20 @@ export async function main() {
|
||||
|
||||
// Render UI, passing necessary config values. Check that there is no command line question.
|
||||
if (config.isInteractive()) {
|
||||
await startInteractiveUI(config, settings, startupWarnings);
|
||||
// Need kitty detection to be complete before we can start the interactive UI.
|
||||
await kittyProtocolDetectionComplete;
|
||||
await startInteractiveUI(
|
||||
config,
|
||||
settings,
|
||||
startupWarnings,
|
||||
process.cwd(),
|
||||
initializationResult,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await config.initialize();
|
||||
|
||||
// If not a TTY, read from stdin
|
||||
// This is for cases where the user pipes input directly into the command
|
||||
if (!process.stdin.isTTY) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
parseAndFormatApiError,
|
||||
FatalInputError,
|
||||
FatalTurnLimitedError,
|
||||
promptIdContext,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Content, Part } from '@google/genai';
|
||||
|
||||
@@ -24,115 +25,117 @@ export async function runNonInteractive(
|
||||
input: string,
|
||||
prompt_id: string,
|
||||
): Promise<void> {
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
stderr: true,
|
||||
debugMode: config.getDebugMode(),
|
||||
});
|
||||
|
||||
try {
|
||||
consolePatcher.patch();
|
||||
// Handle EPIPE errors when the output is piped to a command that closes early.
|
||||
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EPIPE') {
|
||||
// Exit gracefully if the pipe is closed.
|
||||
process.exit(0);
|
||||
}
|
||||
return promptIdContext.run(prompt_id, async () => {
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
stderr: true,
|
||||
debugMode: config.getDebugMode(),
|
||||
});
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
try {
|
||||
consolePatcher.patch();
|
||||
// Handle EPIPE errors when the output is piped to a command that closes early.
|
||||
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EPIPE') {
|
||||
// Exit gracefully if the pipe is closed.
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
const geminiClient = config.getGeminiClient();
|
||||
|
||||
const { processedQuery, shouldProceed } = await handleAtCommand({
|
||||
query: input,
|
||||
config,
|
||||
addItem: (_item, _timestamp) => 0,
|
||||
onDebugMessage: () => {},
|
||||
messageId: Date.now(),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
|
||||
if (!shouldProceed || !processedQuery) {
|
||||
// An error occurred during @include processing (e.g., file not found).
|
||||
// The error message is already logged by handleAtCommand.
|
||||
throw new FatalInputError(
|
||||
'Exiting due to an error processing the @ command.',
|
||||
);
|
||||
}
|
||||
const { processedQuery, shouldProceed } = await handleAtCommand({
|
||||
query: input,
|
||||
config,
|
||||
addItem: (_item, _timestamp) => 0,
|
||||
onDebugMessage: () => {},
|
||||
messageId: Date.now(),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
let currentMessages: Content[] = [
|
||||
{ role: 'user', parts: processedQuery as Part[] },
|
||||
];
|
||||
|
||||
let turnCount = 0;
|
||||
while (true) {
|
||||
turnCount++;
|
||||
if (
|
||||
config.getMaxSessionTurns() >= 0 &&
|
||||
turnCount > config.getMaxSessionTurns()
|
||||
) {
|
||||
throw new FatalTurnLimitedError(
|
||||
'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
|
||||
if (!shouldProceed || !processedQuery) {
|
||||
// An error occurred during @include processing (e.g., file not found).
|
||||
// The error message is already logged by handleAtCommand.
|
||||
throw new FatalInputError(
|
||||
'Exiting due to an error processing the @ command.',
|
||||
);
|
||||
}
|
||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||
|
||||
const responseStream = geminiClient.sendMessageStream(
|
||||
currentMessages[0]?.parts || [],
|
||||
abortController.signal,
|
||||
prompt_id,
|
||||
);
|
||||
let currentMessages: Content[] = [
|
||||
{ role: 'user', parts: processedQuery as Part[] },
|
||||
];
|
||||
|
||||
for await (const event of responseStream) {
|
||||
if (abortController.signal.aborted) {
|
||||
console.error('Operation cancelled.');
|
||||
let turnCount = 0;
|
||||
while (true) {
|
||||
turnCount++;
|
||||
if (
|
||||
config.getMaxSessionTurns() >= 0 &&
|
||||
turnCount > config.getMaxSessionTurns()
|
||||
) {
|
||||
throw new FatalTurnLimitedError(
|
||||
'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
|
||||
);
|
||||
}
|
||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||
|
||||
const responseStream = geminiClient.sendMessageStream(
|
||||
currentMessages[0]?.parts || [],
|
||||
abortController.signal,
|
||||
prompt_id,
|
||||
);
|
||||
|
||||
for await (const event of responseStream) {
|
||||
if (abortController.signal.aborted) {
|
||||
console.error('Operation cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === GeminiEventType.Content) {
|
||||
process.stdout.write(event.value);
|
||||
} else if (event.type === GeminiEventType.ToolCallRequest) {
|
||||
toolCallRequests.push(event.value);
|
||||
}
|
||||
}
|
||||
|
||||
if (toolCallRequests.length > 0) {
|
||||
const toolResponseParts: Part[] = [];
|
||||
for (const requestInfo of toolCallRequests) {
|
||||
const toolResponse = await executeToolCall(
|
||||
config,
|
||||
requestInfo,
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
if (toolResponse.error) {
|
||||
console.error(
|
||||
`Error executing tool ${requestInfo.name}: ${toolResponse.resultDisplay || toolResponse.error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (toolResponse.responseParts) {
|
||||
toolResponseParts.push(...toolResponse.responseParts);
|
||||
}
|
||||
}
|
||||
currentMessages = [{ role: 'user', parts: toolResponseParts }];
|
||||
} else {
|
||||
process.stdout.write('\n'); // Ensure a final newline
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === GeminiEventType.Content) {
|
||||
process.stdout.write(event.value);
|
||||
} else if (event.type === GeminiEventType.ToolCallRequest) {
|
||||
toolCallRequests.push(event.value);
|
||||
}
|
||||
}
|
||||
|
||||
if (toolCallRequests.length > 0) {
|
||||
const toolResponseParts: Part[] = [];
|
||||
for (const requestInfo of toolCallRequests) {
|
||||
const toolResponse = await executeToolCall(
|
||||
config,
|
||||
requestInfo,
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
if (toolResponse.error) {
|
||||
console.error(
|
||||
`Error executing tool ${requestInfo.name}: ${toolResponse.resultDisplay || toolResponse.error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (toolResponse.responseParts) {
|
||||
toolResponseParts.push(...toolResponse.responseParts);
|
||||
}
|
||||
}
|
||||
currentMessages = [{ role: 'user', parts: toolResponseParts }];
|
||||
} else {
|
||||
process.stdout.write('\n'); // Ensure a final newline
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
parseAndFormatApiError(
|
||||
error,
|
||||
config.getContentGeneratorConfig()?.authType,
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
consolePatcher.cleanup();
|
||||
if (isTelemetrySdkInitialized()) {
|
||||
await shutdownTelemetry(config);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
parseAndFormatApiError(
|
||||
error,
|
||||
config.getContentGeneratorConfig()?.authType,
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
consolePatcher.cleanup();
|
||||
if (isTelemetrySdkInitialized()) {
|
||||
await shutdownTelemetry(config);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,7 +15,16 @@ vi.mock('../ui/commands/aboutCommand.js', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../ui/commands/ideCommand.js', () => ({ ideCommand: vi.fn() }));
|
||||
vi.mock('../ui/commands/ideCommand.js', async () => {
|
||||
const { CommandKind } = await import('../ui/commands/types.js');
|
||||
return {
|
||||
ideCommand: vi.fn().mockResolvedValue({
|
||||
name: 'ide',
|
||||
description: 'IDE command',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('../ui/commands/restoreCommand.js', () => ({
|
||||
restoreCommand: vi.fn(),
|
||||
}));
|
||||
@@ -25,7 +34,6 @@ import { BuiltinCommandLoader } from './BuiltinCommandLoader.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { CommandKind } from '../ui/commands/types.js';
|
||||
|
||||
import { ideCommand } from '../ui/commands/ideCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
|
||||
vi.mock('../ui/commands/authCommand.js', () => ({ authCommand: {} }));
|
||||
@@ -57,18 +65,12 @@ vi.mock('../ui/commands/mcpCommand.js', () => ({
|
||||
describe('BuiltinCommandLoader', () => {
|
||||
let mockConfig: Config;
|
||||
|
||||
const ideCommandMock = ideCommand as Mock;
|
||||
const restoreCommandMock = restoreCommand as Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConfig = { some: 'config' } as unknown as Config;
|
||||
|
||||
ideCommandMock.mockResolvedValue({
|
||||
name: 'ide',
|
||||
description: 'IDE command',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
});
|
||||
restoreCommandMock.mockReturnValue({
|
||||
name: 'restore',
|
||||
description: 'Restore command',
|
||||
@@ -76,25 +78,23 @@ describe('BuiltinCommandLoader', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly pass the config object to command factory functions', async () => {
|
||||
it('should correctly pass the config object to restore command factory', async () => {
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
await loader.loadCommands(new AbortController().signal);
|
||||
|
||||
expect(ideCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect(ideCommandMock).toHaveBeenCalledWith();
|
||||
// ideCommand is now a constant, no longer needs config
|
||||
expect(restoreCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect(restoreCommandMock).toHaveBeenCalledWith(mockConfig);
|
||||
});
|
||||
|
||||
it('should filter out null command definitions returned by factories', async () => {
|
||||
// Override the mock's behavior for this specific test.
|
||||
ideCommandMock.mockReturnValue(null);
|
||||
// ideCommand is now a constant SlashCommand
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
|
||||
// The 'ide' command should be filtered out.
|
||||
// The 'ide' command should be present.
|
||||
const ideCmd = commands.find((c) => c.name === 'ide');
|
||||
expect(ideCmd).toBeUndefined();
|
||||
expect(ideCmd).toBeDefined();
|
||||
|
||||
// Other commands should still be present.
|
||||
const aboutCmd = commands.find((c) => c.name === 'about');
|
||||
@@ -104,8 +104,7 @@ describe('BuiltinCommandLoader', () => {
|
||||
it('should handle a null config gracefully when calling factories', async () => {
|
||||
const loader = new BuiltinCommandLoader(null);
|
||||
await loader.loadCommands(new AbortController().signal);
|
||||
expect(ideCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect(ideCommandMock).toHaveBeenCalledWith();
|
||||
// ideCommand is now a constant, no longer needs config
|
||||
expect(restoreCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect(restoreCommandMock).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
+59
-1719
File diff suppressed because it is too large
Load Diff
+18
-1435
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,559 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { render, cleanup } from 'ink-testing-library';
|
||||
import { AppContainer } from './AppContainer.js';
|
||||
import { type Config, makeFakeConfig } from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import type { InitializationResult } from '../core/initializer.js';
|
||||
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
||||
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
|
||||
import {
|
||||
UIActionsContext,
|
||||
type UIActions,
|
||||
} from './contexts/UIActionsContext.js';
|
||||
import { useContext } from 'react';
|
||||
|
||||
// Helper component will read the context values provided by AppContainer
|
||||
// so we can assert against them in our tests.
|
||||
let capturedUIState: UIState;
|
||||
let capturedUIActions: UIActions;
|
||||
function TestContextConsumer() {
|
||||
capturedUIState = useContext(UIStateContext)!;
|
||||
capturedUIActions = useContext(UIActionsContext)!;
|
||||
return null;
|
||||
}
|
||||
|
||||
vi.mock('./App.js', () => ({
|
||||
App: TestContextConsumer,
|
||||
}));
|
||||
|
||||
vi.mock('./hooks/useQuotaAndFallback.js');
|
||||
vi.mock('./hooks/useHistoryManager.js');
|
||||
vi.mock('./hooks/useThemeCommand.js');
|
||||
vi.mock('./auth/useAuth.js');
|
||||
vi.mock('./hooks/useEditorSettings.js');
|
||||
vi.mock('./hooks/useSettingsCommand.js');
|
||||
vi.mock('./hooks/slashCommandProcessor.js');
|
||||
vi.mock('./hooks/useConsoleMessages.js');
|
||||
vi.mock('./hooks/useTerminalSize.js', () => ({
|
||||
useTerminalSize: vi.fn(() => ({ columns: 80, rows: 24 })),
|
||||
}));
|
||||
vi.mock('./hooks/useGeminiStream.js');
|
||||
vi.mock('./hooks/vim.js');
|
||||
vi.mock('./hooks/useFocus.js');
|
||||
vi.mock('./hooks/useBracketedPaste.js');
|
||||
vi.mock('./hooks/useKeypress.js');
|
||||
vi.mock('./hooks/useLoadingIndicator.js');
|
||||
vi.mock('./hooks/useFolderTrust.js');
|
||||
vi.mock('./hooks/useMessageQueue.js');
|
||||
vi.mock('./hooks/useAutoAcceptIndicator.js');
|
||||
vi.mock('./hooks/useWorkspaceMigration.js');
|
||||
vi.mock('./hooks/useGitBranchName.js');
|
||||
vi.mock('./contexts/VimModeContext.js');
|
||||
vi.mock('./contexts/SessionContext.js');
|
||||
vi.mock('./components/shared/text-buffer.js');
|
||||
vi.mock('./hooks/useLogger.js');
|
||||
|
||||
// Mock external utilities
|
||||
vi.mock('../utils/events.js');
|
||||
vi.mock('../utils/handleAutoUpdate.js');
|
||||
vi.mock('./utils/ConsolePatcher.js');
|
||||
vi.mock('../utils/cleanup.js');
|
||||
|
||||
import { useHistory } from './hooks/useHistoryManager.js';
|
||||
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
||||
import { useAuthCommand } from './auth/useAuth.js';
|
||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||
import { useVim } from './hooks/vim.js';
|
||||
import { useFolderTrust } from './hooks/useFolderTrust.js';
|
||||
import { useMessageQueue } from './hooks/useMessageQueue.js';
|
||||
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
|
||||
import { useWorkspaceMigration } from './hooks/useWorkspaceMigration.js';
|
||||
import { useGitBranchName } from './hooks/useGitBranchName.js';
|
||||
import { useVimMode } from './contexts/VimModeContext.js';
|
||||
import { useSessionStats } from './contexts/SessionContext.js';
|
||||
import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||
import { useLogger } from './hooks/useLogger.js';
|
||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||
|
||||
describe('AppContainer State Management', () => {
|
||||
let mockConfig: Config;
|
||||
let mockSettings: LoadedSettings;
|
||||
let mockInitResult: InitializationResult;
|
||||
|
||||
// Create typed mocks for all hooks
|
||||
const mockedUseQuotaAndFallback = useQuotaAndFallback as Mock;
|
||||
const mockedUseHistory = useHistory as Mock;
|
||||
const mockedUseThemeCommand = useThemeCommand as Mock;
|
||||
const mockedUseAuthCommand = useAuthCommand as Mock;
|
||||
const mockedUseEditorSettings = useEditorSettings as Mock;
|
||||
const mockedUseSettingsCommand = useSettingsCommand as Mock;
|
||||
const mockedUseSlashCommandProcessor = useSlashCommandProcessor as Mock;
|
||||
const mockedUseConsoleMessages = useConsoleMessages as Mock;
|
||||
const mockedUseGeminiStream = useGeminiStream as Mock;
|
||||
const mockedUseVim = useVim as Mock;
|
||||
const mockedUseFolderTrust = useFolderTrust as Mock;
|
||||
const mockedUseMessageQueue = useMessageQueue as Mock;
|
||||
const mockedUseAutoAcceptIndicator = useAutoAcceptIndicator as Mock;
|
||||
const mockedUseWorkspaceMigration = useWorkspaceMigration as Mock;
|
||||
const mockedUseGitBranchName = useGitBranchName as Mock;
|
||||
const mockedUseVimMode = useVimMode as Mock;
|
||||
const mockedUseSessionStats = useSessionStats as Mock;
|
||||
const mockedUseTextBuffer = useTextBuffer as Mock;
|
||||
const mockedUseLogger = useLogger as Mock;
|
||||
const mockedUseLoadingIndicator = useLoadingIndicator as Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
capturedUIState = null!;
|
||||
capturedUIActions = null!;
|
||||
|
||||
// **Provide a default return value for EVERY mocked hook.**
|
||||
mockedUseQuotaAndFallback.mockReturnValue({
|
||||
proQuotaRequest: null,
|
||||
handleProQuotaChoice: vi.fn(),
|
||||
});
|
||||
mockedUseHistory.mockReturnValue({
|
||||
history: [],
|
||||
addItem: vi.fn(),
|
||||
updateItem: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
});
|
||||
mockedUseThemeCommand.mockReturnValue({
|
||||
isThemeDialogOpen: false,
|
||||
openThemeDialog: vi.fn(),
|
||||
handleThemeSelect: vi.fn(),
|
||||
handleThemeHighlight: vi.fn(),
|
||||
});
|
||||
mockedUseAuthCommand.mockReturnValue({
|
||||
authState: 'authenticated',
|
||||
setAuthState: vi.fn(),
|
||||
authError: null,
|
||||
onAuthError: vi.fn(),
|
||||
});
|
||||
mockedUseEditorSettings.mockReturnValue({
|
||||
isEditorDialogOpen: false,
|
||||
openEditorDialog: vi.fn(),
|
||||
handleEditorSelect: vi.fn(),
|
||||
exitEditorDialog: vi.fn(),
|
||||
});
|
||||
mockedUseSettingsCommand.mockReturnValue({
|
||||
isSettingsDialogOpen: false,
|
||||
openSettingsDialog: vi.fn(),
|
||||
closeSettingsDialog: vi.fn(),
|
||||
});
|
||||
mockedUseSlashCommandProcessor.mockReturnValue({
|
||||
handleSlashCommand: vi.fn(),
|
||||
slashCommands: [],
|
||||
pendingHistoryItems: [],
|
||||
commandContext: {},
|
||||
shellConfirmationRequest: null,
|
||||
confirmationRequest: null,
|
||||
});
|
||||
mockedUseConsoleMessages.mockReturnValue({
|
||||
consoleMessages: [],
|
||||
handleNewMessage: vi.fn(),
|
||||
clearConsoleMessages: vi.fn(),
|
||||
});
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'idle',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
});
|
||||
mockedUseVim.mockReturnValue({ handleInput: vi.fn() });
|
||||
mockedUseFolderTrust.mockReturnValue({
|
||||
isFolderTrustDialogOpen: false,
|
||||
handleFolderTrustSelect: vi.fn(),
|
||||
isRestarting: false,
|
||||
});
|
||||
mockedUseMessageQueue.mockReturnValue({
|
||||
messageQueue: [],
|
||||
addMessage: vi.fn(),
|
||||
clearQueue: vi.fn(),
|
||||
getQueuedMessagesText: vi.fn().mockReturnValue(''),
|
||||
});
|
||||
mockedUseAutoAcceptIndicator.mockReturnValue(false);
|
||||
mockedUseWorkspaceMigration.mockReturnValue({
|
||||
showWorkspaceMigrationDialog: false,
|
||||
workspaceExtensions: [],
|
||||
onWorkspaceMigrationDialogOpen: vi.fn(),
|
||||
onWorkspaceMigrationDialogClose: vi.fn(),
|
||||
});
|
||||
mockedUseGitBranchName.mockReturnValue('main');
|
||||
mockedUseVimMode.mockReturnValue({
|
||||
isVimEnabled: false,
|
||||
toggleVimEnabled: vi.fn(),
|
||||
});
|
||||
mockedUseSessionStats.mockReturnValue({ stats: {} });
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: '',
|
||||
setText: vi.fn(),
|
||||
// Add other properties if AppContainer uses them
|
||||
});
|
||||
mockedUseLogger.mockReturnValue({
|
||||
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
mockedUseLoadingIndicator.mockReturnValue({
|
||||
elapsedTime: '0.0s',
|
||||
currentLoadingPhrase: '',
|
||||
});
|
||||
|
||||
// Mock Config
|
||||
mockConfig = makeFakeConfig();
|
||||
|
||||
// Mock LoadedSettings
|
||||
mockSettings = {
|
||||
merged: {
|
||||
hideBanner: false,
|
||||
hideFooter: false,
|
||||
hideTips: false,
|
||||
showMemoryUsage: false,
|
||||
theme: 'default',
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
// Mock InitializationResult
|
||||
mockInitResult = {
|
||||
themeError: null,
|
||||
authError: null,
|
||||
shouldOpenAuthDialog: false,
|
||||
geminiMdFileCount: 0,
|
||||
} as InitializationResult;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('Basic Rendering', () => {
|
||||
it('renders without crashing with minimal props', () => {
|
||||
expect(() => {
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('renders with startup warnings', () => {
|
||||
const startupWarnings = ['Warning 1', 'Warning 2'];
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
startupWarnings={startupWarnings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('State Initialization', () => {
|
||||
it('initializes with theme error from initialization result', () => {
|
||||
const initResultWithError = {
|
||||
...mockInitResult,
|
||||
themeError: 'Failed to load theme',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={initResultWithError}
|
||||
/>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('handles debug mode state', () => {
|
||||
const debugConfig = makeFakeConfig();
|
||||
vi.spyOn(debugConfig, 'getDebugMode').mockReturnValue(true);
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<AppContainer
|
||||
config={debugConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Context Providers', () => {
|
||||
it('provides AppContext with correct values', () => {
|
||||
const { unmount } = render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="2.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should render and unmount cleanly
|
||||
expect(() => unmount()).not.toThrow();
|
||||
});
|
||||
|
||||
it('provides UIStateContext with state management', () => {
|
||||
expect(() => {
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('provides UIActionsContext with action handlers', () => {
|
||||
expect(() => {
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('provides ConfigContext with config object', () => {
|
||||
expect(() => {
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings Integration', () => {
|
||||
it('handles settings with all display options disabled', () => {
|
||||
const settingsAllHidden = {
|
||||
merged: {
|
||||
hideBanner: true,
|
||||
hideFooter: true,
|
||||
hideTips: true,
|
||||
showMemoryUsage: false,
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={settingsAllHidden}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('handles settings with memory usage enabled', () => {
|
||||
const settingsWithMemory = {
|
||||
merged: {
|
||||
hideBanner: false,
|
||||
hideFooter: false,
|
||||
hideTips: false,
|
||||
showMemoryUsage: true,
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={settingsWithMemory}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Version Handling', () => {
|
||||
it.each(['1.0.0', '2.1.3-beta', '3.0.0-nightly'])(
|
||||
'handles version format: %s',
|
||||
(version) => {
|
||||
expect(() => {
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version={version}
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('handles config methods that might throw', () => {
|
||||
const errorConfig = makeFakeConfig();
|
||||
vi.spyOn(errorConfig, 'getModel').mockImplementation(() => {
|
||||
throw new Error('Config error');
|
||||
});
|
||||
|
||||
// Should still render without crashing - errors should be handled internally
|
||||
expect(() => {
|
||||
render(
|
||||
<AppContainer
|
||||
config={errorConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('handles undefined settings gracefully', () => {
|
||||
const undefinedSettings = {
|
||||
merged: {},
|
||||
} as LoadedSettings;
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={undefinedSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Provider Hierarchy', () => {
|
||||
it('establishes correct provider nesting order', () => {
|
||||
// This tests that all the context providers are properly nested
|
||||
// and that the component tree can be built without circular dependencies
|
||||
const { unmount } = render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(() => unmount()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Quota and Fallback Integration', () => {
|
||||
it('passes a null proQuotaRequest to UIStateContext by default', () => {
|
||||
// The default mock from beforeEach already sets proQuotaRequest to null
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert that the context value is as expected
|
||||
expect(capturedUIState.proQuotaRequest).toBeNull();
|
||||
});
|
||||
|
||||
it('passes a valid proQuotaRequest to UIStateContext when provided by the hook', () => {
|
||||
// Arrange: Create a mock request object that a UI dialog would receive
|
||||
const mockRequest = {
|
||||
failedModel: 'gemini-pro',
|
||||
fallbackModel: 'gemini-flash',
|
||||
resolve: vi.fn(),
|
||||
};
|
||||
mockedUseQuotaAndFallback.mockReturnValue({
|
||||
proQuotaRequest: mockRequest,
|
||||
handleProQuotaChoice: vi.fn(),
|
||||
});
|
||||
|
||||
// Act: Render the container
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert: The mock request is correctly passed through the context
|
||||
expect(capturedUIState.proQuotaRequest).toEqual(mockRequest);
|
||||
});
|
||||
|
||||
it('passes the handleProQuotaChoice function to UIActionsContext', () => {
|
||||
// Arrange: Create a mock handler function
|
||||
const mockHandler = vi.fn();
|
||||
mockedUseQuotaAndFallback.mockReturnValue({
|
||||
proQuotaRequest: null,
|
||||
handleProQuotaChoice: mockHandler,
|
||||
});
|
||||
|
||||
// Act: Render the container
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert: The action in the context is the mock handler we provided
|
||||
expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler);
|
||||
|
||||
// You can even verify that the plumbed function is callable
|
||||
capturedUIActions.handleProQuotaChoice('auth');
|
||||
expect(mockHandler).toHaveBeenCalledWith('auth');
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { AuthDialog } from './AuthDialog.js';
|
||||
import { AuthType, type Config } from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { AuthState } from '../types.js';
|
||||
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { validateAuthMethodWithSettings } from './useAuth.js';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
import { clearCachedCredentialFile } from '@google/gemini-cli-core';
|
||||
import { Text } from 'ink';
|
||||
|
||||
// Mocks
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
clearCachedCredentialFile: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../utils/cleanup.js', () => ({
|
||||
runExitCleanup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./useAuth.js', () => ({
|
||||
validateAuthMethodWithSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../components/shared/RadioButtonSelect.js', () => ({
|
||||
RadioButtonSelect: vi.fn(({ items, initialIndex }) => (
|
||||
<>
|
||||
{items.map((item: { value: string; label: string }, index: number) => (
|
||||
<Text key={item.value}>
|
||||
{index === initialIndex ? '(selected)' : '(not selected)'}{' '}
|
||||
{item.label}
|
||||
</Text>
|
||||
))}
|
||||
</>
|
||||
)),
|
||||
}));
|
||||
|
||||
const mockedUseKeypress = useKeypress as Mock;
|
||||
const mockedRadioButtonSelect = RadioButtonSelect as Mock;
|
||||
const mockedValidateAuthMethod = validateAuthMethodWithSettings as Mock;
|
||||
const mockedRunExitCleanup = runExitCleanup as Mock;
|
||||
const mockedClearCachedCredentialFile = clearCachedCredentialFile as Mock;
|
||||
|
||||
describe('AuthDialog', () => {
|
||||
let props: {
|
||||
config: Config;
|
||||
settings: LoadedSettings;
|
||||
setAuthState: (state: AuthState) => void;
|
||||
authError: string | null;
|
||||
onAuthError: (error: string) => void;
|
||||
};
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
process.env = {};
|
||||
|
||||
props = {
|
||||
config: {
|
||||
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config,
|
||||
settings: {
|
||||
merged: {
|
||||
security: {
|
||||
auth: {},
|
||||
},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
} as unknown as LoadedSettings,
|
||||
setAuthState: vi.fn(),
|
||||
authError: null,
|
||||
onAuthError: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('shows Cloud Shell option when in Cloud Shell environment', () => {
|
||||
process.env['CLOUD_SHELL'] = 'true';
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||
expect(items).toContainEqual({
|
||||
label: 'Use Cloud Shell user credentials',
|
||||
value: AuthType.CLOUD_SHELL,
|
||||
});
|
||||
});
|
||||
|
||||
it('filters auth types when enforcedType is set', () => {
|
||||
props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI;
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].value).toBe(AuthType.USE_GEMINI);
|
||||
});
|
||||
|
||||
it('sets initial index to 0 when enforcedType is set', () => {
|
||||
props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI;
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
expect(initialIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('selects initial auth type from settings', () => {
|
||||
props.settings.merged.security!.auth!.selectedType = AuthType.USE_VERTEX_AI;
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
expect(items[initialIndex].value).toBe(AuthType.USE_VERTEX_AI);
|
||||
});
|
||||
|
||||
it('selects initial auth type from GEMINI_DEFAULT_AUTH_TYPE env var', () => {
|
||||
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI;
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
expect(items[initialIndex].value).toBe(AuthType.USE_GEMINI);
|
||||
});
|
||||
|
||||
it('selects initial auth type from GEMINI_API_KEY env var', () => {
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
expect(items[initialIndex].value).toBe(AuthType.USE_GEMINI);
|
||||
});
|
||||
|
||||
it('defaults to Login with Google', () => {
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||
expect(items[initialIndex].value).toBe(AuthType.LOGIN_WITH_GOOGLE);
|
||||
});
|
||||
|
||||
describe('handleAuthSelect', () => {
|
||||
it('calls onAuthError if validation fails', () => {
|
||||
mockedValidateAuthMethod.mockReturnValue('Invalid method');
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const { onSelect: handleAuthSelect } =
|
||||
mockedRadioButtonSelect.mock.calls[0][0];
|
||||
handleAuthSelect(AuthType.USE_GEMINI);
|
||||
|
||||
expect(mockedValidateAuthMethod).toHaveBeenCalledWith(
|
||||
AuthType.USE_GEMINI,
|
||||
props.settings,
|
||||
);
|
||||
expect(props.onAuthError).toHaveBeenCalledWith('Invalid method');
|
||||
expect(props.settings.setValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onSelect if validation passes', async () => {
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const { onSelect: handleAuthSelect } =
|
||||
mockedRadioButtonSelect.mock.calls[0][0];
|
||||
await handleAuthSelect(AuthType.USE_GEMINI);
|
||||
|
||||
expect(mockedValidateAuthMethod).toHaveBeenCalledWith(
|
||||
AuthType.USE_GEMINI,
|
||||
props.settings,
|
||||
);
|
||||
expect(props.onAuthError).not.toHaveBeenCalled();
|
||||
expect(mockedClearCachedCredentialFile).toHaveBeenCalled();
|
||||
expect(props.settings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
AuthType.USE_GEMINI,
|
||||
);
|
||||
expect(props.setAuthState).toHaveBeenCalledWith(
|
||||
AuthState.Unauthenticated,
|
||||
);
|
||||
});
|
||||
|
||||
it('exits process for Login with Google when browser is suppressed', async () => {
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation(() => undefined as never);
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true);
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const { onSelect: handleAuthSelect } =
|
||||
mockedRadioButtonSelect.mock.calls[0][0];
|
||||
await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE);
|
||||
|
||||
expect(mockedRunExitCleanup).toHaveBeenCalled();
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Please restart Gemini CLI'),
|
||||
);
|
||||
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||
|
||||
exitSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays authError when provided', () => {
|
||||
props.authError = 'Something went wrong';
|
||||
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
|
||||
expect(lastFrame()).toContain('Something went wrong');
|
||||
});
|
||||
|
||||
describe('useKeypress', () => {
|
||||
it('does nothing on escape if authError is present', () => {
|
||||
props.authError = 'Some error';
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
keypressHandler({ name: 'escape' });
|
||||
expect(props.onAuthError).not.toHaveBeenCalled();
|
||||
expect(props.setAuthState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onAuthError on escape if no auth method is set', () => {
|
||||
props.settings.merged.security!.auth!.selectedType = undefined;
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
keypressHandler({ name: 'escape' });
|
||||
expect(props.onAuthError).toHaveBeenCalledWith(
|
||||
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onSelect(undefined) on escape if auth method is set', () => {
|
||||
props.settings.merged.security!.auth!.selectedType = AuthType.USE_GEMINI;
|
||||
renderWithProviders(<AuthDialog {...props} />);
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
keypressHandler({ name: 'escape' });
|
||||
expect(props.setAuthState).toHaveBeenCalledWith(
|
||||
AuthState.Unauthenticated,
|
||||
);
|
||||
expect(props.settings.setValue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
+59
-54
@@ -5,63 +5,37 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { AuthType } from '@google/gemini-cli-core';
|
||||
import { validateAuthMethod } from '../../config/auth.js';
|
||||
import {
|
||||
AuthType,
|
||||
clearCachedCredentialFile,
|
||||
type Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { AuthState } from '../types.js';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
import { validateAuthMethodWithSettings } from './useAuth.js';
|
||||
|
||||
interface AuthDialogProps {
|
||||
onSelect: (authMethod: AuthType | undefined, scope: SettingScope) => void;
|
||||
config: Config;
|
||||
settings: LoadedSettings;
|
||||
initialErrorMessage?: string | null;
|
||||
}
|
||||
|
||||
function parseDefaultAuthType(
|
||||
defaultAuthType: string | undefined,
|
||||
): AuthType | null {
|
||||
if (
|
||||
defaultAuthType &&
|
||||
Object.values(AuthType).includes(defaultAuthType as AuthType)
|
||||
) {
|
||||
return defaultAuthType as AuthType;
|
||||
}
|
||||
return null;
|
||||
setAuthState: (state: AuthState) => void;
|
||||
authError: string | null;
|
||||
onAuthError: (error: string) => void;
|
||||
}
|
||||
|
||||
export function AuthDialog({
|
||||
onSelect,
|
||||
config,
|
||||
settings,
|
||||
initialErrorMessage,
|
||||
setAuthState,
|
||||
authError,
|
||||
onAuthError,
|
||||
}: AuthDialogProps): React.JSX.Element {
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(() => {
|
||||
if (initialErrorMessage) {
|
||||
return initialErrorMessage;
|
||||
}
|
||||
|
||||
const defaultAuthType = parseDefaultAuthType(
|
||||
process.env['GEMINI_DEFAULT_AUTH_TYPE'],
|
||||
);
|
||||
|
||||
if (process.env['GEMINI_DEFAULT_AUTH_TYPE'] && defaultAuthType === null) {
|
||||
return (
|
||||
`Invalid value for GEMINI_DEFAULT_AUTH_TYPE: "${process.env['GEMINI_DEFAULT_AUTH_TYPE']}". ` +
|
||||
`Valid values are: ${Object.values(AuthType).join(', ')}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
process.env['GEMINI_API_KEY'] &&
|
||||
(!defaultAuthType || defaultAuthType === AuthType.USE_GEMINI)
|
||||
) {
|
||||
return 'Existing API key detected (GEMINI_API_KEY). Select "Gemini API Key" option to use it.';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
let items = [
|
||||
{
|
||||
label: 'Login with Google',
|
||||
@@ -88,14 +62,20 @@ export function AuthDialog({
|
||||
);
|
||||
}
|
||||
|
||||
let defaultAuthType = null;
|
||||
const defaultAuthTypeEnv = process.env['GEMINI_DEFAULT_AUTH_TYPE'];
|
||||
if (
|
||||
defaultAuthTypeEnv &&
|
||||
Object.values(AuthType).includes(defaultAuthTypeEnv as AuthType)
|
||||
) {
|
||||
defaultAuthType = defaultAuthTypeEnv as AuthType;
|
||||
}
|
||||
|
||||
let initialAuthIndex = items.findIndex((item) => {
|
||||
if (settings.merged.security?.auth?.selectedType) {
|
||||
return item.value === settings.merged.security.auth.selectedType;
|
||||
}
|
||||
|
||||
const defaultAuthType = parseDefaultAuthType(
|
||||
process.env['GEMINI_DEFAULT_AUTH_TYPE'],
|
||||
);
|
||||
if (defaultAuthType) {
|
||||
return item.value === defaultAuthType;
|
||||
}
|
||||
@@ -110,12 +90,37 @@ export function AuthDialog({
|
||||
initialAuthIndex = 0;
|
||||
}
|
||||
|
||||
const onSelect = useCallback(
|
||||
async (authType: AuthType | undefined, scope: SettingScope) => {
|
||||
if (authType) {
|
||||
await clearCachedCredentialFile();
|
||||
|
||||
settings.setValue(scope, 'security.auth.selectedType', authType);
|
||||
if (
|
||||
authType === AuthType.LOGIN_WITH_GOOGLE &&
|
||||
config.isBrowserLaunchSuppressed()
|
||||
) {
|
||||
runExitCleanup();
|
||||
console.log(
|
||||
`
|
||||
----------------------------------------------------------------
|
||||
Logging in with Google... Please restart Gemini CLI to continue.
|
||||
----------------------------------------------------------------
|
||||
`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
setAuthState(AuthState.Unauthenticated);
|
||||
},
|
||||
[settings, config, setAuthState],
|
||||
);
|
||||
|
||||
const handleAuthSelect = (authMethod: AuthType) => {
|
||||
const error = validateAuthMethod(authMethod);
|
||||
const error = validateAuthMethodWithSettings(authMethod, settings);
|
||||
if (error) {
|
||||
setErrorMessage(error);
|
||||
onAuthError(error);
|
||||
} else {
|
||||
setErrorMessage(null);
|
||||
onSelect(authMethod, SettingScope.User);
|
||||
}
|
||||
};
|
||||
@@ -125,12 +130,12 @@ export function AuthDialog({
|
||||
if (key.name === 'escape') {
|
||||
// Prevent exit if there is an error message.
|
||||
// This means they user is not authenticated yet.
|
||||
if (errorMessage) {
|
||||
if (authError) {
|
||||
return;
|
||||
}
|
||||
if (settings.merged.security?.auth?.selectedType === undefined) {
|
||||
// Prevent exiting if no auth method is set
|
||||
setErrorMessage(
|
||||
onAuthError(
|
||||
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
|
||||
);
|
||||
return;
|
||||
@@ -160,9 +165,9 @@ export function AuthDialog({
|
||||
onSelect={handleAuthSelect}
|
||||
/>
|
||||
</Box>
|
||||
{errorMessage && (
|
||||
{authError && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentRed}>{errorMessage}</Text>
|
||||
<Text color={Colors.AccentRed}>{authError}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { AuthType, type Config } from '@google/gemini-cli-core';
|
||||
import { getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { AuthState } from '../types.js';
|
||||
import { validateAuthMethod } from '../../config/auth.js';
|
||||
|
||||
export function validateAuthMethodWithSettings(
|
||||
authType: AuthType,
|
||||
settings: LoadedSettings,
|
||||
): string | null {
|
||||
const enforcedType = settings.merged.security?.auth?.enforcedType;
|
||||
if (enforcedType && enforcedType !== authType) {
|
||||
return `Authentication is enforced to be ${enforcedType}, but you are currently using ${authType}.`;
|
||||
}
|
||||
if (settings.merged.security?.auth?.useExternal) {
|
||||
return null;
|
||||
}
|
||||
return validateAuthMethod(authType);
|
||||
}
|
||||
|
||||
export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
|
||||
const [authState, setAuthState] = useState<AuthState>(
|
||||
AuthState.Unauthenticated,
|
||||
);
|
||||
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
|
||||
const onAuthError = useCallback(
|
||||
(error: string) => {
|
||||
setAuthError(error);
|
||||
setAuthState(AuthState.Updating);
|
||||
},
|
||||
[setAuthError, setAuthState],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (authState !== AuthState.Unauthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const authType = settings.merged.security?.auth?.selectedType;
|
||||
if (!authType) {
|
||||
if (process.env['GEMINI_API_KEY']) {
|
||||
onAuthError(
|
||||
'Existing API key detected (GEMINI_API_KEY). Select "Gemini API Key" option to use it.',
|
||||
);
|
||||
} else {
|
||||
onAuthError('No authentication method selected.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const error = validateAuthMethodWithSettings(authType, settings);
|
||||
if (error) {
|
||||
onAuthError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultAuthType = process.env['GEMINI_DEFAULT_AUTH_TYPE'];
|
||||
if (
|
||||
defaultAuthType &&
|
||||
!Object.values(AuthType).includes(defaultAuthType as AuthType)
|
||||
) {
|
||||
onAuthError(
|
||||
`Invalid value for GEMINI_DEFAULT_AUTH_TYPE: "${defaultAuthType}". ` +
|
||||
`Valid values are: ${Object.values(AuthType).join(', ')}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await config.refreshAuth(authType);
|
||||
|
||||
console.log(`Authenticated via "${authType}".`);
|
||||
setAuthError(null);
|
||||
setAuthState(AuthState.Authenticated);
|
||||
} catch (e) {
|
||||
onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`);
|
||||
}
|
||||
})();
|
||||
}, [settings, config, authState, setAuthState, setAuthError, onAuthError]);
|
||||
|
||||
return {
|
||||
authState,
|
||||
setAuthState,
|
||||
authError,
|
||||
onAuthError,
|
||||
};
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { CommandKind, type SlashCommand } from './types.js';
|
||||
export const corgiCommand: SlashCommand = {
|
||||
name: 'corgi',
|
||||
description: 'Toggles corgi mode.',
|
||||
hidden: true,
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (context, _args) => {
|
||||
context.ui.toggleCorgiMode();
|
||||
|
||||
@@ -20,9 +20,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
IdeClient: {
|
||||
getInstance: vi.fn(),
|
||||
},
|
||||
ideContext: {
|
||||
getIdeContext: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ const getMcpStatus = async (
|
||||
'@google/gemini-cli-core'
|
||||
);
|
||||
const tokenStorage = new MCPOAuthTokenStorage();
|
||||
const hasToken = await tokenStorage.getToken(serverName);
|
||||
const hasToken = await tokenStorage.getCredentials(serverName);
|
||||
if (hasToken) {
|
||||
const isExpired = tokenStorage.isTokenExpired(hasToken.token);
|
||||
if (isExpired) {
|
||||
|
||||
@@ -170,6 +170,7 @@ export interface SlashCommand {
|
||||
name: string;
|
||||
altNames?: string[];
|
||||
description: string;
|
||||
hidden?: boolean;
|
||||
|
||||
kind: CommandKind;
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box } from 'ink';
|
||||
import { Header } from './Header.js';
|
||||
import { Tips } from './Tips.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
|
||||
interface AppHeaderProps {
|
||||
version: string;
|
||||
}
|
||||
|
||||
export const AppHeader = ({ version }: AppHeaderProps) => {
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
const { nightly, isFolderTrustDialogOpen } = useUIState();
|
||||
const showTips =
|
||||
!isFolderTrustDialogOpen &&
|
||||
!settings.merged.ui?.hideTips &&
|
||||
!config.getScreenReader();
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{!(settings.merged.ui?.hideBanner || config.getScreenReader()) && (
|
||||
<Header version={version} nightly={nightly} />
|
||||
)}
|
||||
{showTips && <Tips config={config} />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,473 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { AuthDialog } from './AuthDialog.js';
|
||||
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import { AuthType } from '@google/gemini-cli-core';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
|
||||
describe('AuthDialog', () => {
|
||||
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
process.env['GEMINI_API_KEY'] = '';
|
||||
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = '';
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should show an error if the initial auth type is invalid', () => {
|
||||
process.env['GEMINI_API_KEY'] = '';
|
||||
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: AuthType.USE_GEMINI,
|
||||
},
|
||||
},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
true,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AuthDialog
|
||||
onSelect={() => {}}
|
||||
settings={settings}
|
||||
initialErrorMessage="GEMINI_API_KEY environment variable not found"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
'GEMINI_API_KEY environment variable not found',
|
||||
);
|
||||
});
|
||||
|
||||
describe('GEMINI_API_KEY environment variable', () => {
|
||||
it('should detect GEMINI_API_KEY environment variable', () => {
|
||||
process.env['GEMINI_API_KEY'] = 'foobar';
|
||||
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {
|
||||
security: { auth: { selectedType: undefined } },
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
true,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
'Existing API key detected (GEMINI_API_KEY)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not show the GEMINI_API_KEY message if GEMINI_DEFAULT_AUTH_TYPE is set to something else', () => {
|
||||
process.env['GEMINI_API_KEY'] = 'foobar';
|
||||
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.LOGIN_WITH_GOOGLE;
|
||||
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {
|
||||
security: { auth: { selectedType: undefined } },
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
true,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain(
|
||||
'Existing API key detected (GEMINI_API_KEY)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show the GEMINI_API_KEY message if GEMINI_DEFAULT_AUTH_TYPE is set to use api key', () => {
|
||||
process.env['GEMINI_API_KEY'] = 'foobar';
|
||||
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI;
|
||||
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {
|
||||
security: { auth: { selectedType: undefined } },
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
true,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
'Existing API key detected (GEMINI_API_KEY)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GEMINI_DEFAULT_AUTH_TYPE environment variable', () => {
|
||||
it('should select the auth type specified by GEMINI_DEFAULT_AUTH_TYPE', () => {
|
||||
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.LOGIN_WITH_GOOGLE;
|
||||
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {
|
||||
security: { auth: { selectedType: undefined } },
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
true,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
|
||||
// This is a bit brittle, but it's the best way to check which item is selected.
|
||||
expect(lastFrame()).toContain('● 1. Login with Google');
|
||||
});
|
||||
|
||||
it('should fall back to default if GEMINI_DEFAULT_AUTH_TYPE is not set', () => {
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {
|
||||
security: { auth: { selectedType: undefined } },
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
true,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
|
||||
// Default is LOGIN_WITH_GOOGLE
|
||||
expect(lastFrame()).toContain('● 1. Login with Google');
|
||||
});
|
||||
|
||||
it('should show an error and fall back to default if GEMINI_DEFAULT_AUTH_TYPE is invalid', () => {
|
||||
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = 'invalid-auth-type';
|
||||
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {
|
||||
security: { auth: { selectedType: undefined } },
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
true,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
'Invalid value for GEMINI_DEFAULT_AUTH_TYPE: "invalid-auth-type"',
|
||||
);
|
||||
|
||||
// Default is LOGIN_WITH_GOOGLE
|
||||
expect(lastFrame()).toContain('● 1. Login with Google');
|
||||
});
|
||||
});
|
||||
|
||||
it('should prevent exiting when no auth method is selected and show error message', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
security: { auth: { selectedType: undefined } },
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
true,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame, stdin, unmount } = renderWithProviders(
|
||||
<AuthDialog onSelect={onSelect} settings={settings} />,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Simulate pressing escape key
|
||||
stdin.write('\u001b'); // ESC key
|
||||
await wait();
|
||||
|
||||
// Should show error message instead of calling onSelect
|
||||
expect(lastFrame()).toContain(
|
||||
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
|
||||
);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not exit if there is already an error message', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
security: { auth: { selectedType: undefined } },
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
true,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame, stdin, unmount } = renderWithProviders(
|
||||
<AuthDialog
|
||||
onSelect={onSelect}
|
||||
settings={settings}
|
||||
initialErrorMessage="Initial error"
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
expect(lastFrame()).toContain('Initial error');
|
||||
|
||||
// Simulate pressing escape key
|
||||
stdin.write('\u001b'); // ESC key
|
||||
await wait();
|
||||
|
||||
// Should not call onSelect
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should allow exiting when auth method is already selected', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
true,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AuthDialog onSelect={onSelect} settings={settings} />,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Simulate pressing escape key
|
||||
stdin.write('\u001b'); // ESC key
|
||||
await wait();
|
||||
|
||||
// Should call onSelect with undefined to exit
|
||||
expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('enforcedAuthType', () => {
|
||||
it('should display the enforced auth type message if enforcedAuthType is set', () => {
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {
|
||||
security: {
|
||||
auth: {
|
||||
enforcedType: AuthType.USE_VERTEX_AI,
|
||||
},
|
||||
},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: AuthType.USE_VERTEX_AI,
|
||||
},
|
||||
},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
true,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('1. Vertex AI');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { Text } from 'ink';
|
||||
import { Composer } from './Composer.js';
|
||||
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
|
||||
import {
|
||||
UIActionsContext,
|
||||
type UIActions,
|
||||
} from '../contexts/UIActionsContext.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
// Mock VimModeContext hook
|
||||
vi.mock('../contexts/VimModeContext.js', () => ({
|
||||
useVimMode: vi.fn(() => ({
|
||||
vimEnabled: false,
|
||||
vimMode: 'NORMAL',
|
||||
})),
|
||||
}));
|
||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
import { StreamingState } from '../types.js';
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./LoadingIndicator.js', () => ({
|
||||
LoadingIndicator: ({ thought }: { thought?: string }) => (
|
||||
<Text>LoadingIndicator{thought ? `: ${thought}` : ''}</Text>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./ContextSummaryDisplay.js', () => ({
|
||||
ContextSummaryDisplay: () => <Text>ContextSummaryDisplay</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./AutoAcceptIndicator.js', () => ({
|
||||
AutoAcceptIndicator: () => <Text>AutoAcceptIndicator</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./ShellModeIndicator.js', () => ({
|
||||
ShellModeIndicator: () => <Text>ShellModeIndicator</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./DetailedMessagesDisplay.js', () => ({
|
||||
DetailedMessagesDisplay: () => <Text>DetailedMessagesDisplay</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./InputPrompt.js', () => ({
|
||||
InputPrompt: () => <Text>InputPrompt</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./Footer.js', () => ({
|
||||
Footer: () => <Text>Footer</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./ShowMoreLines.js', () => ({
|
||||
ShowMoreLines: () => <Text>ShowMoreLines</Text>,
|
||||
}));
|
||||
|
||||
// Mock contexts
|
||||
vi.mock('../contexts/OverflowContext.js', () => ({
|
||||
OverflowProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
// Create mock context providers
|
||||
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
({
|
||||
streamingState: null,
|
||||
contextFileNames: [],
|
||||
showAutoAcceptIndicator: ApprovalMode.DEFAULT,
|
||||
messageQueue: [],
|
||||
showErrorDetails: false,
|
||||
constrainHeight: false,
|
||||
isInputActive: true,
|
||||
buffer: '',
|
||||
inputWidth: 80,
|
||||
suggestionsWidth: 40,
|
||||
userMessages: [],
|
||||
slashCommands: [],
|
||||
commandContext: null,
|
||||
shellModeActive: false,
|
||||
isFocused: true,
|
||||
thought: '',
|
||||
currentLoadingPhrase: '',
|
||||
elapsedTime: 0,
|
||||
ctrlCPressedOnce: false,
|
||||
ctrlDPressedOnce: false,
|
||||
showEscapePrompt: false,
|
||||
ideContextState: null,
|
||||
geminiMdFileCount: 0,
|
||||
showToolDescriptions: false,
|
||||
filteredConsoleMessages: [],
|
||||
sessionStats: {
|
||||
lastPromptTokenCount: 0,
|
||||
sessionTokenCount: 0,
|
||||
totalPrompts: 0,
|
||||
},
|
||||
branchName: 'main',
|
||||
debugMessage: '',
|
||||
corgiMode: false,
|
||||
errorCount: 0,
|
||||
nightly: false,
|
||||
isTrustedFolder: true,
|
||||
...overrides,
|
||||
}) as UIState;
|
||||
|
||||
const createMockUIActions = (): UIActions =>
|
||||
({
|
||||
handleFinalSubmit: vi.fn(),
|
||||
handleClearScreen: vi.fn(),
|
||||
setShellModeActive: vi.fn(),
|
||||
onEscapePromptChange: vi.fn(),
|
||||
vimHandleInput: vi.fn(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any;
|
||||
|
||||
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(() => ({})),
|
||||
getBlockedMcpServers: vi.fn(() => []),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockSettings = (merged = {}) => ({
|
||||
merged: {
|
||||
hideFooter: false,
|
||||
showMemoryUsage: false,
|
||||
...merged,
|
||||
},
|
||||
});
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const renderComposer = (
|
||||
uiState: UIState,
|
||||
settings = createMockSettings(),
|
||||
config = createMockConfig(),
|
||||
uiActions = createMockUIActions(),
|
||||
) =>
|
||||
render(
|
||||
<ConfigContext.Provider value={config as any}>
|
||||
<SettingsContext.Provider value={settings as any}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<UIActionsContext.Provider value={uiActions}>
|
||||
<Composer />
|
||||
</UIActionsContext.Provider>
|
||||
</UIStateContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
describe('Composer', () => {
|
||||
describe('Footer Display Settings', () => {
|
||||
it('renders Footer by default when hideFooter is false', () => {
|
||||
const uiState = createMockUIState();
|
||||
const settings = createMockSettings({ hideFooter: false });
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings);
|
||||
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
});
|
||||
|
||||
it('does NOT render Footer when hideFooter is true', () => {
|
||||
const uiState = createMockUIState();
|
||||
const settings = createMockSettings({ hideFooter: true });
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings);
|
||||
|
||||
// Check for content that only appears IN the Footer component itself
|
||||
expect(lastFrame()).not.toContain('[NORMAL]'); // Vim mode indicator
|
||||
expect(lastFrame()).not.toContain('(main'); // Branch name with parentheses
|
||||
});
|
||||
|
||||
it('passes correct props to Footer including vim mode when enabled', async () => {
|
||||
const uiState = createMockUIState({
|
||||
branchName: 'feature-branch',
|
||||
corgiMode: true,
|
||||
errorCount: 2,
|
||||
sessionStats: {
|
||||
sessionId: 'test-session',
|
||||
sessionStartTime: new Date(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
metrics: {} as any,
|
||||
lastPromptTokenCount: 150,
|
||||
promptCount: 5,
|
||||
},
|
||||
});
|
||||
const config = createMockConfig({
|
||||
getModel: vi.fn(() => 'gemini-1.5-flash'),
|
||||
getTargetDir: vi.fn(() => '/project/path'),
|
||||
getDebugMode: vi.fn(() => true),
|
||||
});
|
||||
const settings = createMockSettings({
|
||||
hideFooter: false,
|
||||
showMemoryUsage: true,
|
||||
});
|
||||
// Mock vim mode for this test
|
||||
const { useVimMode } = await import('../contexts/VimModeContext.js');
|
||||
vi.mocked(useVimMode).mockReturnValueOnce({
|
||||
vimEnabled: true,
|
||||
vimMode: 'INSERT',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings, config);
|
||||
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
// Footer should be rendered with all the state passed through
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading Indicator', () => {
|
||||
it('renders LoadingIndicator with thought when streaming', () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
thought: {
|
||||
subject: 'Processing',
|
||||
description: 'Processing your request...',
|
||||
},
|
||||
currentLoadingPhrase: 'Analyzing',
|
||||
elapsedTime: 1500,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
});
|
||||
|
||||
it('renders LoadingIndicator without thought when accessibility disables loading phrases', () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
thought: { subject: 'Hidden', description: 'Should not show' },
|
||||
});
|
||||
const config = createMockConfig({
|
||||
getAccessibility: vi.fn(() => ({ disableLoadingPhrases: true })),
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, undefined, config);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
expect(output).not.toContain('Should not show');
|
||||
});
|
||||
|
||||
it('suppresses thought when waiting for confirmation', () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.WaitingForConfirmation,
|
||||
thought: {
|
||||
subject: 'Confirmation',
|
||||
description: 'Should not show during confirmation',
|
||||
},
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
expect(output).not.toContain('Should not show during confirmation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Queue Display', () => {
|
||||
it('displays queued messages when present', () => {
|
||||
const uiState = createMockUIState({
|
||||
messageQueue: [
|
||||
'First queued message',
|
||||
'Second queued message',
|
||||
'Third queued message',
|
||||
],
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('First queued message');
|
||||
expect(output).toContain('Second queued message');
|
||||
expect(output).toContain('Third queued message');
|
||||
});
|
||||
|
||||
it('shows overflow indicator when more than 3 messages are queued', () => {
|
||||
const uiState = createMockUIState({
|
||||
messageQueue: [
|
||||
'Message 1',
|
||||
'Message 2',
|
||||
'Message 3',
|
||||
'Message 4',
|
||||
'Message 5',
|
||||
],
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Message 1');
|
||||
expect(output).toContain('Message 2');
|
||||
expect(output).toContain('Message 3');
|
||||
expect(output).toContain('... (+2 more)');
|
||||
});
|
||||
|
||||
it('does not display message queue section when empty', () => {
|
||||
const uiState = createMockUIState({
|
||||
messageQueue: [],
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
// Should not contain queued message indicators
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('more)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Context and Status Display', () => {
|
||||
it('shows ContextSummaryDisplay in normal state', () => {
|
||||
const uiState = createMockUIState({
|
||||
ctrlCPressedOnce: false,
|
||||
ctrlDPressedOnce: false,
|
||||
showEscapePrompt: false,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('ContextSummaryDisplay');
|
||||
});
|
||||
|
||||
it('shows Ctrl+C exit prompt when ctrlCPressedOnce is true', () => {
|
||||
const uiState = createMockUIState({
|
||||
ctrlCPressedOnce: true,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('Press Ctrl+C again to exit');
|
||||
});
|
||||
|
||||
it('shows Ctrl+D exit prompt when ctrlDPressedOnce is true', () => {
|
||||
const uiState = createMockUIState({
|
||||
ctrlDPressedOnce: true,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('Press Esc again to clear');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input and Indicators', () => {
|
||||
it('renders InputPrompt when input is active', () => {
|
||||
const uiState = createMockUIState({
|
||||
isInputActive: true,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('InputPrompt');
|
||||
});
|
||||
|
||||
it('does not render InputPrompt when input is inactive', () => {
|
||||
const uiState = createMockUIState({
|
||||
isInputActive: false,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('InputPrompt');
|
||||
});
|
||||
|
||||
it('shows AutoAcceptIndicator when approval mode is not default and shell mode is inactive', () => {
|
||||
const uiState = createMockUIState({
|
||||
showAutoAcceptIndicator: ApprovalMode.YOLO,
|
||||
shellModeActive: false,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('AutoAcceptIndicator');
|
||||
});
|
||||
|
||||
it('shows ShellModeIndicator when shell mode is active', () => {
|
||||
const uiState = createMockUIState({
|
||||
shellModeActive: true,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('ShellModeIndicator');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Details Display', () => {
|
||||
it('shows DetailedMessagesDisplay when showErrorDetails is true', () => {
|
||||
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,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('DetailedMessagesDisplay');
|
||||
expect(lastFrame()).toContain('ShowMoreLines');
|
||||
});
|
||||
|
||||
it('does not show error details when showErrorDetails is false', () => {
|
||||
const uiState = createMockUIState({
|
||||
showErrorDetails: false,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('DetailedMessagesDisplay');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
|
||||
import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
|
||||
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
||||
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
|
||||
import { InputPrompt } from './InputPrompt.js';
|
||||
import { Footer, type FooterProps } from './Footer.js';
|
||||
import { ShowMoreLines } from './ShowMoreLines.js';
|
||||
import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||
import { Colors } from '../colors.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
|
||||
|
||||
const MAX_DISPLAYED_QUEUED_MESSAGES = 3;
|
||||
|
||||
export const Composer = () => {
|
||||
const config = useConfig();
|
||||
const settings = useSettings();
|
||||
const uiState = useUIState();
|
||||
const uiActions = useUIActions();
|
||||
const { vimEnabled, vimMode } = useVimMode();
|
||||
const terminalWidth = process.stdout.columns;
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
|
||||
|
||||
const { contextFileNames, showAutoAcceptIndicator } = uiState;
|
||||
|
||||
// Build footer props from context values
|
||||
const footerProps: Omit<FooterProps, 'vimMode'> = {
|
||||
model: config.getModel(),
|
||||
targetDir: config.getTargetDir(),
|
||||
debugMode: config.getDebugMode(),
|
||||
branchName: uiState.branchName,
|
||||
debugMessage: uiState.debugMessage,
|
||||
corgiMode: uiState.corgiMode,
|
||||
errorCount: uiState.errorCount,
|
||||
showErrorDetails: uiState.showErrorDetails,
|
||||
showMemoryUsage:
|
||||
config.getDebugMode() || settings.merged.ui?.showMemoryUsage || false,
|
||||
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
|
||||
nightly: uiState.nightly,
|
||||
isTrustedFolder: uiState.isTrustedFolder,
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<LoadingIndicator
|
||||
thought={
|
||||
uiState.streamingState === StreamingState.WaitingForConfirmation ||
|
||||
config.getAccessibility()?.disableLoadingPhrases
|
||||
? undefined
|
||||
: uiState.thought
|
||||
}
|
||||
currentLoadingPhrase={
|
||||
config.getAccessibility()?.disableLoadingPhrases
|
||||
? undefined
|
||||
: uiState.currentLoadingPhrase
|
||||
}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
/>
|
||||
|
||||
{!uiState.isConfigInitialized && <ConfigInitDisplay />}
|
||||
|
||||
{uiState.messageQueue.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{uiState.messageQueue
|
||||
.slice(0, MAX_DISPLAYED_QUEUED_MESSAGES)
|
||||
.map((message, index) => {
|
||||
const preview = message.replace(/\s+/g, ' ');
|
||||
|
||||
return (
|
||||
<Box key={index} paddingLeft={2} width="100%">
|
||||
<Text dimColor wrap="truncate">
|
||||
{preview}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{uiState.messageQueue.length > MAX_DISPLAYED_QUEUED_MESSAGES && (
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor>
|
||||
... (+
|
||||
{uiState.messageQueue.length -
|
||||
MAX_DISPLAYED_QUEUED_MESSAGES}{' '}
|
||||
more)
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
marginTop={1}
|
||||
justifyContent="space-between"
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
<Box>
|
||||
{process.env['GEMINI_SYSTEM_MD'] && (
|
||||
<Text color={Colors.AccentRed}>|⌐■_■| </Text>
|
||||
)}
|
||||
{uiState.ctrlCPressedOnce ? (
|
||||
<Text color={Colors.AccentYellow}>Press Ctrl+C again to exit.</Text>
|
||||
) : uiState.ctrlDPressedOnce ? (
|
||||
<Text color={Colors.AccentYellow}>Press Ctrl+D again to exit.</Text>
|
||||
) : uiState.showEscapePrompt ? (
|
||||
<Text color={Colors.Gray}>Press Esc again to clear.</Text>
|
||||
) : (
|
||||
!settings.merged.ui?.hideContextSummary && (
|
||||
<ContextSummaryDisplay
|
||||
ideContext={uiState.ideContextState}
|
||||
geminiMdFileCount={uiState.geminiMdFileCount}
|
||||
contextFileNames={contextFileNames}
|
||||
mcpServers={config.getMcpServers()}
|
||||
blockedMcpServers={config.getBlockedMcpServers()}
|
||||
showToolDescriptions={uiState.showToolDescriptions}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
<Box paddingTop={isNarrow ? 1 : 0}>
|
||||
{showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
|
||||
!uiState.shellModeActive && (
|
||||
<AutoAcceptIndicator approvalMode={showAutoAcceptIndicator} />
|
||||
)}
|
||||
{uiState.shellModeActive && <ShellModeIndicator />}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{uiState.showErrorDetails && (
|
||||
<OverflowProvider>
|
||||
<Box flexDirection="column">
|
||||
<DetailedMessagesDisplay
|
||||
messages={uiState.filteredConsoleMessages}
|
||||
maxHeight={
|
||||
uiState.constrainHeight ? debugConsoleMaxHeight : undefined
|
||||
}
|
||||
width={uiState.inputWidth}
|
||||
/>
|
||||
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
|
||||
</Box>
|
||||
</OverflowProvider>
|
||||
)}
|
||||
|
||||
{uiState.isInputActive && (
|
||||
<InputPrompt
|
||||
buffer={uiState.buffer}
|
||||
inputWidth={uiState.inputWidth}
|
||||
suggestionsWidth={uiState.suggestionsWidth}
|
||||
onSubmit={uiActions.handleFinalSubmit}
|
||||
userMessages={uiState.userMessages}
|
||||
onClearScreen={uiActions.handleClearScreen}
|
||||
config={config}
|
||||
slashCommands={uiState.slashCommands}
|
||||
commandContext={uiState.commandContext}
|
||||
shellModeActive={uiState.shellModeActive}
|
||||
setShellModeActive={uiActions.setShellModeActive}
|
||||
onEscapePromptChange={uiActions.onEscapePromptChange}
|
||||
focus={uiState.isFocused}
|
||||
vimHandleInput={uiActions.vimHandleInput}
|
||||
placeholder={
|
||||
vimEnabled
|
||||
? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode."
|
||||
: ' Type your message or @path/to/file'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!settings.merged.ui?.hideFooter && (
|
||||
<Footer {...footerProps} vimMode={vimEnabled ? vimMode : undefined} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { appEvents } from './../../utils/events.js';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { type McpClient, MCPServerStatus } from '@google/gemini-cli-core';
|
||||
import { GeminiSpinner } from './GeminiRespondingSpinner.js';
|
||||
|
||||
export const ConfigInitDisplay = () => {
|
||||
const config = useConfig();
|
||||
const [message, setMessage] = useState('Initializing...');
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = (clients?: Map<string, McpClient>) => {
|
||||
if (!clients || clients.size === 0) {
|
||||
setMessage(`Initializing...`);
|
||||
return;
|
||||
}
|
||||
let connected = 0;
|
||||
for (const client of clients.values()) {
|
||||
if (client.getStatus() === MCPServerStatus.CONNECTED) {
|
||||
connected++;
|
||||
}
|
||||
}
|
||||
setMessage(`Connecting to MCP servers... (${connected}/${clients.size})`);
|
||||
};
|
||||
|
||||
appEvents.on('mcp-client-update', onChange);
|
||||
return () => {
|
||||
appEvents.off('mcp-client-update', onChange);
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
<GeminiSpinner /> {message}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { IdeIntegrationNudge } from '../IdeIntegrationNudge.js';
|
||||
import { FolderTrustDialog } from './FolderTrustDialog.js';
|
||||
import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { ThemeDialog } from './ThemeDialog.js';
|
||||
import { SettingsDialog } from './SettingsDialog.js';
|
||||
import { AuthInProgress } from '../auth/AuthInProgress.js';
|
||||
import { AuthDialog } from '../auth/AuthDialog.js';
|
||||
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
|
||||
import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
|
||||
import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js';
|
||||
import { ProQuotaDialog } from './ProQuotaDialog.js';
|
||||
import { Colors } from '../colors.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import process from 'node:process';
|
||||
|
||||
// Props for DialogManager
|
||||
export const DialogManager = () => {
|
||||
const config = useConfig();
|
||||
const settings = useSettings();
|
||||
|
||||
const uiState = useUIState();
|
||||
const uiActions = useUIActions();
|
||||
const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } =
|
||||
uiState;
|
||||
|
||||
if (uiState.showIdeRestartPrompt) {
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={Colors.AccentYellow} paddingX={1}>
|
||||
<Text color={Colors.AccentYellow}>
|
||||
Workspace trust has changed. Press 'r' to restart Gemini to
|
||||
apply the changes.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (uiState.showWorkspaceMigrationDialog) {
|
||||
return (
|
||||
<WorkspaceMigrationDialog
|
||||
workspaceExtensions={uiState.workspaceExtensions}
|
||||
onOpen={uiActions.onWorkspaceMigrationDialogOpen}
|
||||
onClose={uiActions.onWorkspaceMigrationDialogClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.proQuotaRequest) {
|
||||
return (
|
||||
<ProQuotaDialog
|
||||
failedModel={uiState.proQuotaRequest.failedModel}
|
||||
fallbackModel={uiState.proQuotaRequest.fallbackModel}
|
||||
onChoice={uiActions.handleProQuotaChoice}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.shouldShowIdePrompt) {
|
||||
return (
|
||||
<IdeIntegrationNudge
|
||||
ide={uiState.currentIDE!}
|
||||
onComplete={uiActions.handleIdePromptComplete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.isFolderTrustDialogOpen) {
|
||||
return (
|
||||
<FolderTrustDialog
|
||||
onSelect={uiActions.handleFolderTrustSelect}
|
||||
isRestarting={uiState.isRestarting}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.shellConfirmationRequest) {
|
||||
return (
|
||||
<ShellConfirmationDialog request={uiState.shellConfirmationRequest} />
|
||||
);
|
||||
}
|
||||
if (uiState.confirmationRequest) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{uiState.confirmationRequest.prompt}
|
||||
<Box paddingY={1}>
|
||||
<RadioButtonSelect
|
||||
items={[
|
||||
{ label: 'Yes', value: true },
|
||||
{ label: 'No', value: false },
|
||||
]}
|
||||
onSelect={(value: boolean) => {
|
||||
uiState.confirmationRequest!.onConfirm(value);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (uiState.isThemeDialogOpen) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{uiState.themeError && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={Colors.AccentRed}>{uiState.themeError}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<ThemeDialog
|
||||
onSelect={uiActions.handleThemeSelect}
|
||||
onHighlight={uiActions.handleThemeHighlight}
|
||||
settings={settings}
|
||||
availableTerminalHeight={
|
||||
constrainHeight ? terminalHeight - staticExtraHeight : undefined
|
||||
}
|
||||
terminalWidth={mainAreaWidth}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (uiState.isSettingsDialogOpen) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<SettingsDialog
|
||||
settings={settings}
|
||||
onSelect={() => uiActions.closeSettingsDialog()}
|
||||
onRestartRequest={() => process.exit(0)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (uiState.isAuthenticating) {
|
||||
return (
|
||||
<AuthInProgress
|
||||
onTimeout={() => {
|
||||
uiActions.onAuthError('Authentication cancelled.');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.isAuthDialogOpen) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<AuthDialog
|
||||
config={config}
|
||||
settings={settings}
|
||||
setAuthState={uiActions.setAuthState}
|
||||
authError={uiState.authError}
|
||||
onAuthError={uiActions.onAuthError}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (uiState.isEditorDialogOpen) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{uiState.editorError && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={Colors.AccentRed}>{uiState.editorError}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<EditorSettingsDialog
|
||||
onSelect={uiActions.handleEditorSelect}
|
||||
settings={settings}
|
||||
onExit={uiActions.exitEditorDialog}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (uiState.showPrivacyNotice) {
|
||||
return (
|
||||
<PrivacyNotice
|
||||
onExit={() => uiActions.exitPrivacyNotice()}
|
||||
config={config}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -156,31 +156,4 @@ describe('<Footer />', () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
|
||||
describe('visibility toggles', () => {
|
||||
it('should hide CWD when hideCWD is true', () => {
|
||||
const { lastFrame } = renderWithWidth(120, {
|
||||
...defaultProps,
|
||||
hideCWD: true,
|
||||
});
|
||||
expect(lastFrame()).not.toContain(defaultProps.targetDir);
|
||||
});
|
||||
|
||||
it('should hide sandbox status when hideSandboxStatus is true', () => {
|
||||
const { lastFrame } = renderWithWidth(120, {
|
||||
...defaultProps,
|
||||
isTrustedFolder: true,
|
||||
hideSandboxStatus: true,
|
||||
});
|
||||
expect(lastFrame()).not.toContain('no sandbox');
|
||||
});
|
||||
|
||||
it('should hide model info when hideModelInfo is true', () => {
|
||||
const { lastFrame } = renderWithWidth(120, {
|
||||
...defaultProps,
|
||||
hideModelInfo: true,
|
||||
});
|
||||
expect(lastFrame()).not.toContain(defaultProps.model);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ import { DebugProfiler } from './DebugProfiler.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
|
||||
interface FooterProps {
|
||||
export interface FooterProps {
|
||||
model: string;
|
||||
targetDir: string;
|
||||
branchName?: string;
|
||||
@@ -33,9 +33,6 @@ interface FooterProps {
|
||||
nightly: boolean;
|
||||
vimMode?: string;
|
||||
isTrustedFolder?: boolean;
|
||||
hideCWD?: boolean;
|
||||
hideSandboxStatus?: boolean;
|
||||
hideModelInfo?: boolean;
|
||||
}
|
||||
|
||||
export const Footer: React.FC<FooterProps> = ({
|
||||
@@ -52,9 +49,6 @@ export const Footer: React.FC<FooterProps> = ({
|
||||
nightly,
|
||||
vimMode,
|
||||
isTrustedFolder,
|
||||
hideCWD = false,
|
||||
hideSandboxStatus = false,
|
||||
hideModelInfo = false,
|
||||
}) => {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
|
||||
@@ -66,93 +60,85 @@ export const Footer: React.FC<FooterProps> = ({
|
||||
? path.basename(tildeifyPath(targetDir))
|
||||
: shortenPath(tildeifyPath(targetDir), pathLength);
|
||||
|
||||
const justifyContent = hideCWD && hideModelInfo ? 'center' : 'space-between';
|
||||
|
||||
return (
|
||||
<Box
|
||||
justifyContent={justifyContent}
|
||||
justifyContent="space-between"
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
{!hideCWD && (
|
||||
<Box>
|
||||
{debugMode && <DebugProfiler />}
|
||||
{vimMode && <Text color={theme.text.secondary}>[{vimMode}] </Text>}
|
||||
{nightly ? (
|
||||
<Gradient colors={theme.ui.gradient}>
|
||||
<Text>
|
||||
{displayPath}
|
||||
{branchName && <Text> ({branchName}*)</Text>}
|
||||
</Text>
|
||||
</Gradient>
|
||||
) : (
|
||||
<Text color={theme.text.link}>
|
||||
<Box>
|
||||
{debugMode && <DebugProfiler />}
|
||||
{vimMode && <Text color={theme.text.secondary}>[{vimMode}] </Text>}
|
||||
{nightly ? (
|
||||
<Gradient colors={theme.ui.gradient}>
|
||||
<Text>
|
||||
{displayPath}
|
||||
{branchName && (
|
||||
<Text color={theme.text.secondary}> ({branchName}*)</Text>
|
||||
)}
|
||||
{branchName && <Text> ({branchName}*)</Text>}
|
||||
</Text>
|
||||
)}
|
||||
{debugMode && (
|
||||
<Text color={theme.status.error}>
|
||||
{' ' + (debugMessage || '--debug')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Gradient>
|
||||
) : (
|
||||
<Text color={theme.text.link}>
|
||||
{displayPath}
|
||||
{branchName && (
|
||||
<Text color={theme.text.secondary}> ({branchName}*)</Text>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{debugMode && (
|
||||
<Text color={theme.status.error}>
|
||||
{' ' + (debugMessage || '--debug')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Middle Section: Centered Trust/Sandbox Info */}
|
||||
{!hideSandboxStatus && (
|
||||
<Box
|
||||
flexGrow={isNarrow || hideCWD || hideModelInfo ? 0 : 1}
|
||||
alignItems="center"
|
||||
justifyContent={isNarrow || hideCWD ? 'flex-start' : 'center'}
|
||||
display="flex"
|
||||
paddingX={isNarrow ? 0 : 1}
|
||||
paddingTop={isNarrow ? 1 : 0}
|
||||
>
|
||||
{isTrustedFolder === false ? (
|
||||
<Text color={theme.status.warning}>untrusted</Text>
|
||||
) : process.env['SANDBOX'] &&
|
||||
process.env['SANDBOX'] !== 'sandbox-exec' ? (
|
||||
<Text color="green">
|
||||
{process.env['SANDBOX'].replace(/^gemini-(?:cli-)?/, '')}
|
||||
<Box
|
||||
flexGrow={isNarrow ? 0 : 1}
|
||||
alignItems="center"
|
||||
justifyContent={isNarrow ? 'flex-start' : 'center'}
|
||||
display="flex"
|
||||
paddingX={isNarrow ? 0 : 1}
|
||||
paddingTop={isNarrow ? 1 : 0}
|
||||
>
|
||||
{isTrustedFolder === false ? (
|
||||
<Text color={theme.status.warning}>untrusted</Text>
|
||||
) : process.env['SANDBOX'] &&
|
||||
process.env['SANDBOX'] !== 'sandbox-exec' ? (
|
||||
<Text color="green">
|
||||
{process.env['SANDBOX'].replace(/^gemini-(?:cli-)?/, '')}
|
||||
</Text>
|
||||
) : process.env['SANDBOX'] === 'sandbox-exec' ? (
|
||||
<Text color={theme.status.warning}>
|
||||
macOS Seatbelt{' '}
|
||||
<Text color={theme.text.secondary}>
|
||||
({process.env['SEATBELT_PROFILE']})
|
||||
</Text>
|
||||
) : process.env['SANDBOX'] === 'sandbox-exec' ? (
|
||||
<Text color={theme.status.warning}>
|
||||
macOS Seatbelt{' '}
|
||||
<Text color={theme.text.secondary}>
|
||||
({process.env['SEATBELT_PROFILE']})
|
||||
</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={theme.status.error}>
|
||||
no sandbox <Text color={theme.text.secondary}>(see /docs)</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={theme.status.error}>
|
||||
no sandbox <Text color={theme.text.secondary}>(see /docs)</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Right Section: Gemini Label and Console Summary */}
|
||||
<Box alignItems="center" paddingTop={isNarrow ? 1 : 0}>
|
||||
{!hideModelInfo && (
|
||||
<Box alignItems="center">
|
||||
<Text color={theme.text.accent}>
|
||||
{isNarrow ? '' : ' '}
|
||||
{model}{' '}
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={promptTokenCount}
|
||||
model={model}
|
||||
/>
|
||||
</Text>
|
||||
{showMemoryUsage && <MemoryUsageDisplay />}
|
||||
</Box>
|
||||
)}
|
||||
<Box alignItems="center">
|
||||
<Text color={theme.text.accent}>
|
||||
{isNarrow ? '' : ' '}
|
||||
{model}{' '}
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={promptTokenCount}
|
||||
model={model}
|
||||
/>
|
||||
</Text>
|
||||
{showMemoryUsage && <MemoryUsageDisplay />}
|
||||
</Box>
|
||||
<Box alignItems="center" paddingLeft={2}>
|
||||
{corgiMode && (
|
||||
<Text>
|
||||
{!hideModelInfo && <Text color={theme.ui.symbol}>| </Text>}
|
||||
<Text color={theme.ui.symbol}>| </Text>
|
||||
<Text color={theme.status.error}>▼</Text>
|
||||
<Text color={theme.text.primary}>(´</Text>
|
||||
<Text color={theme.status.error}>ᴥ</Text>
|
||||
@@ -162,7 +148,7 @@ export const Footer: React.FC<FooterProps> = ({
|
||||
)}
|
||||
{!showErrorDetails && errorCount > 0 && (
|
||||
<Box>
|
||||
{!hideModelInfo && <Text color={theme.ui.symbol}>| </Text>}
|
||||
<Text color={theme.ui.symbol}>| </Text>
|
||||
<ConsoleSummaryDisplay errorCount={errorCount} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -30,10 +30,11 @@ export const GeminiRespondingSpinner: React.FC<
|
||||
const streamingState = useStreamingContext();
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
if (streamingState === StreamingState.Responding) {
|
||||
return isScreenReaderEnabled ? (
|
||||
<Text>{SCREEN_READER_RESPONDING}</Text>
|
||||
) : (
|
||||
<Spinner type={spinnerType} />
|
||||
return (
|
||||
<GeminiSpinner
|
||||
spinnerType={spinnerType}
|
||||
altText={SCREEN_READER_RESPONDING}
|
||||
/>
|
||||
);
|
||||
} else if (nonRespondingDisplay) {
|
||||
return isScreenReaderEnabled ? (
|
||||
@@ -44,3 +45,20 @@ export const GeminiRespondingSpinner: React.FC<
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
interface GeminiSpinnerProps {
|
||||
spinnerType?: SpinnerName;
|
||||
altText?: string;
|
||||
}
|
||||
|
||||
export const GeminiSpinner: React.FC<GeminiSpinnerProps> = ({
|
||||
spinnerType = 'dots',
|
||||
altText,
|
||||
}) => {
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
return isScreenReaderEnabled ? (
|
||||
<Text>{altText}</Text>
|
||||
) : (
|
||||
<Spinner type={spinnerType} />
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Help } from './Help.js';
|
||||
import type { SlashCommand } from '../commands/types.js';
|
||||
import { CommandKind } from '../commands/types.js';
|
||||
|
||||
const mockCommands: readonly SlashCommand[] = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'A test command',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
},
|
||||
{
|
||||
name: 'hidden',
|
||||
description: 'A hidden command',
|
||||
hidden: true,
|
||||
kind: CommandKind.BUILT_IN,
|
||||
},
|
||||
{
|
||||
name: 'parent',
|
||||
description: 'A parent command',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'visible-child',
|
||||
description: 'A visible child command',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
},
|
||||
{
|
||||
name: 'hidden-child',
|
||||
description: 'A hidden child command',
|
||||
hidden: true,
|
||||
kind: CommandKind.BUILT_IN,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe('Help Component', () => {
|
||||
it('should not render hidden commands', () => {
|
||||
const { lastFrame } = render(<Help commands={mockCommands} />);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('/test');
|
||||
expect(output).not.toContain('/hidden');
|
||||
});
|
||||
|
||||
it('should not render hidden subcommands', () => {
|
||||
const { lastFrame } = render(<Help commands={mockCommands} />);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('visible-child');
|
||||
expect(output).not.toContain('hidden-child');
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import type { SlashCommand } from '../commands/types.js';
|
||||
import { type SlashCommand, CommandKind } from '../commands/types.js';
|
||||
|
||||
interface Help {
|
||||
commands: readonly SlashCommand[];
|
||||
@@ -65,7 +65,7 @@ export const Help: React.FC<Help> = ({ commands }) => (
|
||||
Commands:
|
||||
</Text>
|
||||
{commands
|
||||
.filter((command) => command.description)
|
||||
.filter((command) => command.description && !command.hidden)
|
||||
.map((command: SlashCommand) => (
|
||||
<Box key={command.name} flexDirection="column">
|
||||
<Text color={Colors.Foreground}>
|
||||
@@ -73,18 +73,23 @@ export const Help: React.FC<Help> = ({ commands }) => (
|
||||
{' '}
|
||||
/{command.name}
|
||||
</Text>
|
||||
{command.kind === CommandKind.MCP_PROMPT && (
|
||||
<Text color={Colors.Gray}> [MCP]</Text>
|
||||
)}
|
||||
{command.description && ' - ' + command.description}
|
||||
</Text>
|
||||
{command.subCommands &&
|
||||
command.subCommands.map((subCommand) => (
|
||||
<Text key={subCommand.name} color={Colors.Foreground}>
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
{' '}
|
||||
{subCommand.name}
|
||||
command.subCommands
|
||||
.filter((subCommand) => !subCommand.hidden)
|
||||
.map((subCommand) => (
|
||||
<Text key={subCommand.name} color={Colors.Foreground}>
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
{' '}
|
||||
{subCommand.name}
|
||||
</Text>
|
||||
{subCommand.description && ' - ' + subCommand.description}
|
||||
</Text>
|
||||
{subCommand.description && ' - ' + subCommand.description}
|
||||
</Text>
|
||||
))}
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
<Text color={Colors.Foreground}>
|
||||
@@ -94,6 +99,10 @@ export const Help: React.FC<Help> = ({ commands }) => (
|
||||
</Text>
|
||||
- shell command
|
||||
</Text>
|
||||
<Text color={Colors.Foreground}>
|
||||
<Text color={Colors.Gray}>[MCP]</Text> - Model Context Protocol command
|
||||
(from external servers)
|
||||
</Text>
|
||||
|
||||
<Box height={1} />
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import { StatsDisplay } from './StatsDisplay.js';
|
||||
import { ModelStatsDisplay } from './ModelStatsDisplay.js';
|
||||
import { ToolStatsDisplay } from './ToolStatsDisplay.js';
|
||||
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { Help } from './Help.js';
|
||||
import type { SlashCommand } from '../commands/types.js';
|
||||
|
||||
@@ -29,7 +28,6 @@ interface HistoryItemDisplayProps {
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
isPending: boolean;
|
||||
config: Config;
|
||||
isFocused?: boolean;
|
||||
commands?: readonly SlashCommand[];
|
||||
}
|
||||
@@ -39,7 +37,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
isPending,
|
||||
config,
|
||||
commands,
|
||||
isFocused = true,
|
||||
}) => (
|
||||
@@ -87,7 +84,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
groupId={item.id}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
config={config}
|
||||
isFocused={isFocused}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -20,6 +20,8 @@ import type { UseCommandCompletionReturn } from '../hooks/useCommandCompletion.j
|
||||
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
|
||||
import type { UseInputHistoryReturn } from '../hooks/useInputHistory.js';
|
||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||
import type { UseReverseSearchCompletionReturn } from '../hooks/useReverseSearchCompletion.js';
|
||||
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
|
||||
import * as clipboardUtils from '../utils/clipboardUtils.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import chalk from 'chalk';
|
||||
@@ -27,6 +29,7 @@ import chalk from 'chalk';
|
||||
vi.mock('../hooks/useShellHistory.js');
|
||||
vi.mock('../hooks/useCommandCompletion.js');
|
||||
vi.mock('../hooks/useInputHistory.js');
|
||||
vi.mock('../hooks/useReverseSearchCompletion.js');
|
||||
vi.mock('../utils/clipboardUtils.js');
|
||||
|
||||
const mockSlashCommands: SlashCommand[] = [
|
||||
@@ -82,12 +85,16 @@ describe('InputPrompt', () => {
|
||||
let mockShellHistory: UseShellHistoryReturn;
|
||||
let mockCommandCompletion: UseCommandCompletionReturn;
|
||||
let mockInputHistory: UseInputHistoryReturn;
|
||||
let mockReverseSearchCompletion: UseReverseSearchCompletionReturn;
|
||||
let mockBuffer: TextBuffer;
|
||||
let mockCommandContext: CommandContext;
|
||||
|
||||
const mockedUseShellHistory = vi.mocked(useShellHistory);
|
||||
const mockedUseCommandCompletion = vi.mocked(useCommandCompletion);
|
||||
const mockedUseInputHistory = vi.mocked(useInputHistory);
|
||||
const mockedUseReverseSearchCompletion = vi.mocked(
|
||||
useReverseSearchCompletion,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
@@ -168,6 +175,21 @@ describe('InputPrompt', () => {
|
||||
};
|
||||
mockedUseInputHistory.mockReturnValue(mockInputHistory);
|
||||
|
||||
mockReverseSearchCompletion = {
|
||||
suggestions: [],
|
||||
activeSuggestionIndex: -1,
|
||||
visibleStartIndex: 0,
|
||||
showSuggestions: false,
|
||||
isLoadingSuggestions: false,
|
||||
navigateUp: vi.fn(),
|
||||
navigateDown: vi.fn(),
|
||||
handleAutocomplete: vi.fn(),
|
||||
resetCompletionState: vi.fn(),
|
||||
};
|
||||
mockedUseReverseSearchCompletion.mockReturnValue(
|
||||
mockReverseSearchCompletion,
|
||||
);
|
||||
|
||||
props = {
|
||||
buffer: mockBuffer,
|
||||
onSubmit: vi.fn(),
|
||||
@@ -1311,6 +1333,33 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiline rendering', () => {
|
||||
it('should correctly render multiline input including blank lines', async () => {
|
||||
const text = 'hello\n\nworld';
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = text.split('\n');
|
||||
mockBuffer.viewportVisualLines = text.split('\n');
|
||||
mockBuffer.allVisualLines = text.split('\n');
|
||||
mockBuffer.visualCursor = [2, 5]; // cursor at the end of "world"
|
||||
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
await wait();
|
||||
|
||||
const frame = stdout.lastFrame();
|
||||
// Check that all lines, including the empty one, are rendered.
|
||||
// This implicitly tests that the Box wrapper provides height for the empty line.
|
||||
expect(frame).toContain('hello');
|
||||
expect(frame).toContain(`world${chalk.inverse(' ')}`);
|
||||
|
||||
const outputLines = frame!.split('\n');
|
||||
// The number of lines should be 2 for the border plus 3 for the content.
|
||||
expect(outputLines.length).toBe(5);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiline paste', () => {
|
||||
it.each([
|
||||
{
|
||||
@@ -1348,6 +1397,77 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('paste auto-submission protection', () => {
|
||||
it('should prevent auto-submission immediately after paste with newlines', async () => {
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// First type some text manually
|
||||
stdin.write('test command');
|
||||
await wait();
|
||||
|
||||
// Simulate a paste operation (this should set the paste protection)
|
||||
stdin.write(`\x1b[200~\npasted content\x1b[201~`);
|
||||
await wait();
|
||||
|
||||
// Simulate an Enter key press immediately after paste
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
// Verify that onSubmit was NOT called due to recent paste protection
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should allow submission after paste protection timeout', async () => {
|
||||
// Set up buffer with text for submission
|
||||
props.buffer.text = 'test command';
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Simulate a paste operation (this sets the protection)
|
||||
stdin.write(`\x1b[200~\npasted\x1b[201~`);
|
||||
await wait();
|
||||
|
||||
// Wait for the protection timeout to naturally expire
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
|
||||
// Now Enter should work normally
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
// Verify that onSubmit was called after the timeout
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('test command');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not interfere with normal Enter key submission when no recent paste', async () => {
|
||||
// Set up buffer with text before rendering to ensure submission works
|
||||
props.buffer.text = 'normal command';
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Press Enter without any recent paste
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
// Verify that onSubmit was called normally
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('normal command');
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('enhanced input UX - double ESC clear functionality', () => {
|
||||
it('should clear buffer on second ESC press', async () => {
|
||||
const onEscapePromptChange = vi.fn();
|
||||
@@ -1475,12 +1595,27 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
it('invokes reverse search on Ctrl+R', async () => {
|
||||
// Mock the reverse search completion to return suggestions
|
||||
mockedUseReverseSearchCompletion.mockReturnValue({
|
||||
...mockReverseSearchCompletion,
|
||||
suggestions: [
|
||||
{ label: 'echo hello', value: 'echo hello' },
|
||||
{ label: 'echo world', value: 'echo world' },
|
||||
{ label: 'ls', value: 'ls' },
|
||||
],
|
||||
showSuggestions: true,
|
||||
activeSuggestionIndex: 0,
|
||||
});
|
||||
|
||||
const { stdin, stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x12');
|
||||
// Trigger reverse search with Ctrl+R
|
||||
act(() => {
|
||||
stdin.write('\x12');
|
||||
});
|
||||
await wait();
|
||||
|
||||
const frame = stdout.lastFrame();
|
||||
@@ -1512,6 +1647,27 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
it('completes the highlighted entry on Tab and exits reverse-search', async () => {
|
||||
// Mock the reverse search completion
|
||||
const mockHandleAutocomplete = vi.fn(() => {
|
||||
props.buffer.setText('echo hello');
|
||||
});
|
||||
|
||||
mockedUseReverseSearchCompletion.mockImplementation(
|
||||
(buffer, shellHistory, reverseSearchActive) => ({
|
||||
...mockReverseSearchCompletion,
|
||||
suggestions: reverseSearchActive
|
||||
? [
|
||||
{ label: 'echo hello', value: 'echo hello' },
|
||||
{ label: 'echo world', value: 'echo world' },
|
||||
{ label: 'ls', value: 'ls' },
|
||||
]
|
||||
: [],
|
||||
showSuggestions: reverseSearchActive,
|
||||
activeSuggestionIndex: reverseSearchActive ? 0 : -1,
|
||||
handleAutocomplete: mockHandleAutocomplete,
|
||||
}),
|
||||
);
|
||||
|
||||
const { stdin, stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
@@ -1529,19 +1685,26 @@ describe('InputPrompt', () => {
|
||||
act(() => {
|
||||
stdin.write('\t');
|
||||
});
|
||||
await wait();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(stdout.lastFrame()).not.toContain('(r:)');
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
); // Increase timeout
|
||||
|
||||
expect(mockHandleAutocomplete).toHaveBeenCalledWith(0);
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('echo hello');
|
||||
unmount();
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
it('submits the highlighted entry on Enter and exits reverse-search', async () => {
|
||||
// Mock the reverse search completion to return suggestions
|
||||
mockedUseReverseSearchCompletion.mockReturnValue({
|
||||
...mockReverseSearchCompletion,
|
||||
suggestions: [
|
||||
{ label: 'echo hello', value: 'echo hello' },
|
||||
{ label: 'echo world', value: 'echo world' },
|
||||
{ label: 'ls', value: 'ls' },
|
||||
],
|
||||
showSuggestions: true,
|
||||
activeSuggestionIndex: 0,
|
||||
});
|
||||
|
||||
const { stdin, stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
|
||||
@@ -71,6 +71,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const [escPressCount, setEscPressCount] = useState(0);
|
||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [recentPasteTime, setRecentPasteTime] = useState<number | null>(null);
|
||||
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const [dirs, setDirs] = useState<readonly string[]>(
|
||||
config.getWorkspaceContext().getDirectories(),
|
||||
@@ -130,6 +132,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (escapeTimerRef.current) {
|
||||
clearTimeout(escapeTimerRef.current);
|
||||
}
|
||||
if (pasteTimeoutRef.current) {
|
||||
clearTimeout(pasteTimeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -245,6 +250,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
|
||||
if (key.paste) {
|
||||
// Record paste time to prevent accidental auto-submission
|
||||
setRecentPasteTime(Date.now());
|
||||
|
||||
// Clear any existing paste timeout
|
||||
if (pasteTimeoutRef.current) {
|
||||
clearTimeout(pasteTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Clear the paste protection after a safe delay
|
||||
pasteTimeoutRef.current = setTimeout(() => {
|
||||
setRecentPasteTime(null);
|
||||
pasteTimeoutRef.current = null;
|
||||
}, 500);
|
||||
|
||||
// Ensure we never accidentally interpret paste as regular input.
|
||||
buffer.handleInput(key);
|
||||
return;
|
||||
@@ -460,6 +479,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
if (keyMatchers[Command.SUBMIT](key)) {
|
||||
if (buffer.text.trim()) {
|
||||
// Check if a paste operation occurred recently to prevent accidental auto-submission
|
||||
if (recentPasteTime !== null) {
|
||||
// Paste occurred recently, ignore this submit to prevent auto-execution
|
||||
return;
|
||||
}
|
||||
|
||||
const [row, col] = buffer.cursor;
|
||||
const line = buffer.lines[row];
|
||||
const charBefore = col > 0 ? cpSlice(line, col - 1, col) : '';
|
||||
@@ -558,6 +583,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
reverseSearchActive,
|
||||
textBeforeReverseSearch,
|
||||
cursorPosition,
|
||||
recentPasteTime,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -723,7 +749,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
) : (
|
||||
linesToRender
|
||||
.map((lineText, visualIdxInRenderedSet) => {
|
||||
const tokens = parseInputForHighlighting(lineText);
|
||||
const tokens = parseInputForHighlighting(
|
||||
lineText,
|
||||
visualIdxInRenderedSet,
|
||||
);
|
||||
const cursorVisualRow =
|
||||
cursorVisualRowAbsolute - scrollVisualRow;
|
||||
const isOnCursorLine =
|
||||
@@ -784,7 +813,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
) {
|
||||
if (!currentLineGhost) {
|
||||
renderedLine.push(
|
||||
<Text key="cursor-end">{chalk.inverse(' ')}</Text>,
|
||||
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
||||
{chalk.inverse(' ')}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -796,15 +827,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
currentLineGhost;
|
||||
|
||||
return (
|
||||
<Text key={`line-${visualIdxInRenderedSet}`}>
|
||||
{renderedLine}
|
||||
{showCursorBeforeGhost && chalk.inverse(' ')}
|
||||
{currentLineGhost && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{currentLineGhost}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
|
||||
<Text>
|
||||
{renderedLine}
|
||||
{showCursorBeforeGhost && chalk.inverse(' ')}
|
||||
{currentLineGhost && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{currentLineGhost}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
.concat(
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Static } from 'ink';
|
||||
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
||||
import { ShowMoreLines } from './ShowMoreLines.js';
|
||||
import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useAppContext } from '../contexts/AppContext.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
|
||||
export const MainContent = () => {
|
||||
const { version } = useAppContext();
|
||||
const uiState = useUIState();
|
||||
const {
|
||||
pendingHistoryItems,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
availableTerminalHeight,
|
||||
} = uiState;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Static
|
||||
key={uiState.historyRemountKey}
|
||||
items={[
|
||||
<AppHeader key="app-header" version={version} />,
|
||||
...uiState.history.map((h) => (
|
||||
<HistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={staticAreaMaxItemHeight}
|
||||
key={h.id}
|
||||
item={h}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
/>
|
||||
)),
|
||||
]}
|
||||
>
|
||||
{(item) => item}
|
||||
</Static>
|
||||
<OverflowProvider>
|
||||
<Box flexDirection="column">
|
||||
{pendingHistoryItems.map((item, i) => (
|
||||
<HistoryItemDisplay
|
||||
key={i}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight ? availableTerminalHeight : undefined
|
||||
}
|
||||
terminalWidth={mainAreaWidth}
|
||||
item={{ ...item, id: 0 }}
|
||||
isPending={true}
|
||||
isFocused={!uiState.isEditorDialogOpen}
|
||||
/>
|
||||
))}
|
||||
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
|
||||
</Box>
|
||||
</OverflowProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { useAppContext } from '../contexts/AppContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { Colors } from '../colors.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { UpdateNotification } from './UpdateNotification.js';
|
||||
|
||||
export const Notifications = () => {
|
||||
const { startupWarnings } = useAppContext();
|
||||
const { initError, streamingState, updateInfo } = useUIState();
|
||||
|
||||
const showStartupWarnings = startupWarnings.length > 0;
|
||||
const showInitError =
|
||||
initError && streamingState !== StreamingState.Responding;
|
||||
|
||||
if (!showStartupWarnings && !showInitError && !updateInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{updateInfo && <UpdateNotification message={updateInfo.message} />}
|
||||
{showStartupWarnings && (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentYellow}
|
||||
paddingX={1}
|
||||
marginY={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
{startupWarnings.map((warning, index) => (
|
||||
<Text key={index} color={Colors.AccentYellow}>
|
||||
{warning}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{showInitError && (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentRed}
|
||||
paddingX={1}
|
||||
marginBottom={1}
|
||||
>
|
||||
<Text color={Colors.AccentRed}>
|
||||
Initialization Error: {initError}
|
||||
</Text>
|
||||
<Text color={Colors.AccentRed}>
|
||||
{' '}
|
||||
Please check API key and configuration.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -22,7 +22,7 @@ describe('ProQuotaDialog', () => {
|
||||
it('should render with correct title and options', () => {
|
||||
const { lastFrame } = render(
|
||||
<ProQuotaDialog
|
||||
currentModel="gemini-2.5-pro"
|
||||
failedModel="gemini-2.5-pro"
|
||||
fallbackModel="gemini-2.5-flash"
|
||||
onChoice={() => {}}
|
||||
/>,
|
||||
@@ -53,7 +53,7 @@ describe('ProQuotaDialog', () => {
|
||||
const mockOnChoice = vi.fn();
|
||||
render(
|
||||
<ProQuotaDialog
|
||||
currentModel="gemini-2.5-pro"
|
||||
failedModel="gemini-2.5-pro"
|
||||
fallbackModel="gemini-2.5-flash"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
@@ -72,7 +72,7 @@ describe('ProQuotaDialog', () => {
|
||||
const mockOnChoice = vi.fn();
|
||||
render(
|
||||
<ProQuotaDialog
|
||||
currentModel="gemini-2.5-pro"
|
||||
failedModel="gemini-2.5-pro"
|
||||
fallbackModel="gemini-2.5-flash"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
|
||||
@@ -10,13 +10,13 @@ import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { Colors } from '../colors.js';
|
||||
|
||||
interface ProQuotaDialogProps {
|
||||
currentModel: string;
|
||||
failedModel: string;
|
||||
fallbackModel: string;
|
||||
onChoice: (choice: 'auth' | 'continue') => void;
|
||||
}
|
||||
|
||||
export function ProQuotaDialog({
|
||||
currentModel,
|
||||
failedModel,
|
||||
fallbackModel,
|
||||
onChoice,
|
||||
}: ProQuotaDialogProps): React.JSX.Element {
|
||||
@@ -38,7 +38,7 @@ export function ProQuotaDialog({
|
||||
return (
|
||||
<Box borderStyle="round" flexDirection="column" paddingX={1}>
|
||||
<Text bold color={Colors.AccentYellow}>
|
||||
Pro quota limit reached for {currentModel}.
|
||||
Pro quota limit reached for {failedModel}.
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box } from 'ink';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
|
||||
export const QuittingDisplay = () => {
|
||||
const uiState = useUIState();
|
||||
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
|
||||
|
||||
const availableTerminalHeight = terminalHeight;
|
||||
|
||||
if (!uiState.quittingMessages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{uiState.quittingMessages.map((item) => (
|
||||
<HistoryItemDisplay
|
||||
key={item.id}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight ? availableTerminalHeight : undefined
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
item={item}
|
||||
isPending={false}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -25,14 +25,31 @@ import { render } from 'ink-testing-library';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { SettingsDialog } from './SettingsDialog.js';
|
||||
import { LoadedSettings } from '../../config/settings.js';
|
||||
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import { VimModeProvider } from '../contexts/VimModeContext.js';
|
||||
import { KeypressProvider } from '../contexts/KeypressContext.js';
|
||||
import { act } from 'react';
|
||||
import { saveModifiedSettings, TEST_ONLY } from '../../utils/settingsUtils.js';
|
||||
import {
|
||||
getSettingsSchema,
|
||||
type SettingDefinition,
|
||||
type SettingsSchemaType,
|
||||
} from '../../config/settingsSchema.js';
|
||||
|
||||
// Mock the VimModeContext
|
||||
const mockToggleVimEnabled = vi.fn();
|
||||
const mockSetVimMode = vi.fn();
|
||||
|
||||
enum TerminalKeys {
|
||||
ENTER = '\u000D',
|
||||
TAB = '\t',
|
||||
UP_ARROW = '\u001B[A',
|
||||
DOWN_ARROW = '\u001B[B',
|
||||
LEFT_ARROW = '\u001B[D',
|
||||
RIGHT_ARROW = '\u001B[C',
|
||||
ESCAPE = '\u001B',
|
||||
}
|
||||
|
||||
const createMockSettings = (
|
||||
userSettings = {},
|
||||
systemSettings = {},
|
||||
@@ -67,26 +84,12 @@ const createMockSettings = (
|
||||
new Set(),
|
||||
);
|
||||
|
||||
vi.mock('../contexts/SettingsContext.js', async () => {
|
||||
const actual = await vi.importActual('../contexts/SettingsContext.js');
|
||||
let settings = createMockSettings({ 'a.string.setting': 'initial' });
|
||||
vi.mock('../../config/settingsSchema.js', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('../../config/settingsSchema.js')>();
|
||||
return {
|
||||
...actual,
|
||||
useSettings: () => ({
|
||||
settings,
|
||||
setSetting: (key: string, value: string) => {
|
||||
settings = createMockSettings({ [key]: value });
|
||||
},
|
||||
getSettingDefinition: (key: string) => {
|
||||
if (key === 'a.string.setting') {
|
||||
return {
|
||||
type: 'string',
|
||||
description: 'A string setting',
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
...original,
|
||||
getSettingsSchema: vi.fn(original.getSettingsSchema),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -136,7 +139,6 @@ describe('SettingsDialog', () => {
|
||||
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset keypress mock state (variables are commented out)
|
||||
// currentKeypressHandler = null;
|
||||
// isKeypressActive = false;
|
||||
@@ -146,6 +148,9 @@ describe('SettingsDialog', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
TEST_ONLY.clearFlattenedSchema();
|
||||
vi.clearAllMocks();
|
||||
vi.resetAllMocks();
|
||||
// Reset keypress mock state (variables are commented out)
|
||||
// currentKeypressHandler = null;
|
||||
// isKeypressActive = false;
|
||||
@@ -153,44 +158,6 @@ describe('SettingsDialog', () => {
|
||||
// console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
const createMockSettings = (
|
||||
userSettings = {},
|
||||
systemSettings = {},
|
||||
workspaceSettings = {},
|
||||
) =>
|
||||
new LoadedSettings(
|
||||
{
|
||||
settings: {
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
...systemSettings,
|
||||
},
|
||||
path: '/system/settings.json',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '/system/system-defaults.json',
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
...userSettings,
|
||||
},
|
||||
path: '/user/settings.json',
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
ui: { customThemes: {} },
|
||||
mcpServers: {},
|
||||
...workspaceSettings,
|
||||
},
|
||||
path: '/workspace/settings.json',
|
||||
},
|
||||
true,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
describe('Initial Rendering', () => {
|
||||
it('should render the settings dialog with default state', () => {
|
||||
const settings = createMockSettings();
|
||||
@@ -244,15 +211,18 @@ describe('SettingsDialog', () => {
|
||||
const settings = createMockSettings();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount } = render(
|
||||
const { stdin, unmount, lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// Press down arrow
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
await wait();
|
||||
act(() => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string); // Down arrow
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('● Disable Auto Update');
|
||||
|
||||
// The active index should have changed (tested indirectly through behavior)
|
||||
unmount();
|
||||
@@ -269,9 +239,9 @@ describe('SettingsDialog', () => {
|
||||
);
|
||||
|
||||
// First go down, then up
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string); // Down arrow
|
||||
await wait();
|
||||
stdin.write('\u001B[A'); // Up arrow
|
||||
stdin.write(TerminalKeys.UP_ARROW as string);
|
||||
await wait();
|
||||
|
||||
unmount();
|
||||
@@ -296,21 +266,25 @@ describe('SettingsDialog', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not navigate beyond bounds', async () => {
|
||||
it('wraps around when at the top of the list', async () => {
|
||||
const settings = createMockSettings();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount } = render(
|
||||
const { stdin, unmount, lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// Try to go up from first item
|
||||
stdin.write('\u001B[A'); // Up arrow
|
||||
act(() => {
|
||||
stdin.write(TerminalKeys.UP_ARROW);
|
||||
});
|
||||
|
||||
await wait();
|
||||
|
||||
// Should still be on first item
|
||||
expect(lastFrame()).toContain('● Folder Trust');
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -319,20 +293,142 @@ describe('SettingsDialog', () => {
|
||||
it('should toggle setting with Enter key', async () => {
|
||||
const settings = createMockSettings();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const { stdin, unmount } = render(
|
||||
const component = (
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
</KeypressProvider>
|
||||
);
|
||||
|
||||
const { stdin, unmount } = render(component);
|
||||
|
||||
// Press Enter to toggle current setting
|
||||
stdin.write('\u000D'); // Enter key
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string);
|
||||
await wait();
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
await wait();
|
||||
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
|
||||
new Set<string>(['general.disableAutoUpdate']),
|
||||
{
|
||||
general: {
|
||||
disableAutoUpdate: true,
|
||||
},
|
||||
},
|
||||
expect.any(LoadedSettings),
|
||||
SettingScope.User,
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('enum values', () => {
|
||||
enum StringEnum {
|
||||
FOO = 'foo',
|
||||
BAR = 'bar',
|
||||
BAZ = 'baz',
|
||||
}
|
||||
|
||||
const SETTING: SettingDefinition = {
|
||||
type: 'enum',
|
||||
label: 'Theme',
|
||||
options: [
|
||||
{
|
||||
label: 'Foo',
|
||||
value: StringEnum.FOO,
|
||||
},
|
||||
{
|
||||
label: 'Bar',
|
||||
value: StringEnum.BAR,
|
||||
},
|
||||
{
|
||||
label: 'Baz',
|
||||
value: StringEnum.BAZ,
|
||||
},
|
||||
],
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: StringEnum.BAR,
|
||||
description: 'The color theme for the UI.',
|
||||
showInDialog: true,
|
||||
};
|
||||
|
||||
const FAKE_SCHEMA: SettingsSchemaType = {
|
||||
ui: {
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
theme: {
|
||||
...SETTING,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as SettingsSchemaType;
|
||||
|
||||
it('toggles enum values with the enter key', async () => {
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(FAKE_SCHEMA);
|
||||
const settings = createMockSettings();
|
||||
const onSelect = vi.fn();
|
||||
const component = (
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>
|
||||
);
|
||||
|
||||
const { stdin, unmount } = render(component);
|
||||
|
||||
// Press Enter to toggle current setting
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string);
|
||||
await wait();
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
await wait();
|
||||
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
|
||||
new Set<string>(['ui.theme']),
|
||||
{
|
||||
ui: {
|
||||
theme: StringEnum.BAZ,
|
||||
},
|
||||
},
|
||||
expect.any(LoadedSettings),
|
||||
SettingScope.User,
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('loops back when reaching the end of an enum', async () => {
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(FAKE_SCHEMA);
|
||||
const settings = createMockSettings();
|
||||
settings.setValue(SettingScope.User, 'ui.theme', StringEnum.BAZ);
|
||||
const onSelect = vi.fn();
|
||||
const component = (
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>
|
||||
);
|
||||
|
||||
const { stdin, unmount } = render(component);
|
||||
|
||||
// Press Enter to toggle current setting
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string);
|
||||
await wait();
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
await wait();
|
||||
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
|
||||
new Set<string>(['ui.theme']),
|
||||
{
|
||||
ui: {
|
||||
theme: StringEnum.FOO,
|
||||
},
|
||||
},
|
||||
expect.any(LoadedSettings),
|
||||
SettingScope.User,
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('should toggle setting with Space key', async () => {
|
||||
const settings = createMockSettings();
|
||||
const onSelect = vi.fn();
|
||||
@@ -362,7 +458,7 @@ describe('SettingsDialog', () => {
|
||||
|
||||
// Navigate to vim mode setting and toggle it
|
||||
// This would require knowing the exact position, so we'll just test that the mock is called
|
||||
stdin.write('\u000D'); // Enter key
|
||||
stdin.write(TerminalKeys.ENTER as string); // Enter key
|
||||
await wait();
|
||||
|
||||
// The mock should potentially be called if vim mode was toggled
|
||||
@@ -382,7 +478,7 @@ describe('SettingsDialog', () => {
|
||||
);
|
||||
|
||||
// Switch to scope focus
|
||||
stdin.write('\t'); // Tab key
|
||||
stdin.write(TerminalKeys.TAB); // Tab key
|
||||
await wait();
|
||||
|
||||
// Select different scope (numbers 1-3 typically available)
|
||||
@@ -502,7 +598,7 @@ describe('SettingsDialog', () => {
|
||||
);
|
||||
|
||||
// Switch to scope selector
|
||||
stdin.write('\t'); // Tab
|
||||
stdin.write(TerminalKeys.TAB as string); // Tab
|
||||
await wait();
|
||||
|
||||
// Change scope
|
||||
@@ -547,7 +643,7 @@ describe('SettingsDialog', () => {
|
||||
);
|
||||
|
||||
// Try to toggle a setting (this might trigger vim mode toggle)
|
||||
stdin.write('\u000D'); // Enter
|
||||
stdin.write(TerminalKeys.ENTER as string); // Enter
|
||||
await wait();
|
||||
|
||||
// Should not crash
|
||||
@@ -567,13 +663,13 @@ describe('SettingsDialog', () => {
|
||||
);
|
||||
|
||||
// Toggle a setting
|
||||
stdin.write('\u000D'); // Enter
|
||||
stdin.write(TerminalKeys.ENTER as string); // Enter
|
||||
await wait();
|
||||
|
||||
// Toggle another setting
|
||||
stdin.write('\u001B[B'); // Down
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string); // Down
|
||||
await wait();
|
||||
stdin.write('\u000D'); // Enter
|
||||
stdin.write(TerminalKeys.ENTER as string); // Enter
|
||||
await wait();
|
||||
|
||||
// Should track multiple modified settings
|
||||
@@ -592,7 +688,7 @@ describe('SettingsDialog', () => {
|
||||
|
||||
// Navigate down many times to test scrolling
|
||||
for (let i = 0; i < 10; i++) {
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string); // Down arrow
|
||||
await wait(10);
|
||||
}
|
||||
|
||||
@@ -615,7 +711,7 @@ describe('SettingsDialog', () => {
|
||||
|
||||
// Navigate to and toggle vim mode setting
|
||||
// This would require knowing the exact position of vim mode setting
|
||||
stdin.write('\u000D'); // Enter
|
||||
stdin.write(TerminalKeys.ENTER as string); // Enter
|
||||
await wait();
|
||||
|
||||
unmount();
|
||||
@@ -653,7 +749,7 @@ describe('SettingsDialog', () => {
|
||||
);
|
||||
|
||||
// Toggle a non-restart-required setting (like hideTips)
|
||||
stdin.write('\u000D'); // Enter - toggle current setting
|
||||
stdin.write(TerminalKeys.ENTER as string); // Enter - toggle current setting
|
||||
await wait();
|
||||
|
||||
// Should save immediately without showing restart prompt
|
||||
@@ -750,8 +846,8 @@ describe('SettingsDialog', () => {
|
||||
|
||||
// Rapid navigation
|
||||
for (let i = 0; i < 5; i++) {
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
stdin.write('\u001B[A'); // Up arrow
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string);
|
||||
stdin.write(TerminalKeys.UP_ARROW as string);
|
||||
}
|
||||
await wait(100);
|
||||
|
||||
@@ -806,9 +902,9 @@ describe('SettingsDialog', () => {
|
||||
);
|
||||
|
||||
// Try to navigate when potentially at bounds
|
||||
stdin.write('\u001B[B'); // Down
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string);
|
||||
await wait();
|
||||
stdin.write('\u001B[A'); // Up
|
||||
stdin.write(TerminalKeys.UP_ARROW as string);
|
||||
await wait();
|
||||
|
||||
unmount();
|
||||
@@ -917,19 +1013,19 @@ describe('SettingsDialog', () => {
|
||||
);
|
||||
|
||||
// Toggle first setting (should require restart)
|
||||
stdin.write('\u000D'); // Enter
|
||||
stdin.write(TerminalKeys.ENTER as string); // Enter
|
||||
await wait();
|
||||
|
||||
// Navigate to next setting and toggle it (should not require restart - e.g., vimMode)
|
||||
stdin.write('\u001B[B'); // Down
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string); // Down
|
||||
await wait();
|
||||
stdin.write('\u000D'); // Enter
|
||||
stdin.write(TerminalKeys.ENTER as string); // Enter
|
||||
await wait();
|
||||
|
||||
// Navigate to another setting and toggle it (should also require restart)
|
||||
stdin.write('\u001B[B'); // Down
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string); // Down
|
||||
await wait();
|
||||
stdin.write('\u000D'); // Enter
|
||||
stdin.write(TerminalKeys.ENTER as string); // Enter
|
||||
await wait();
|
||||
|
||||
// The test verifies that all changes are preserved and the dialog still works
|
||||
@@ -948,13 +1044,13 @@ describe('SettingsDialog', () => {
|
||||
);
|
||||
|
||||
// Multiple scope changes
|
||||
stdin.write('\t'); // Tab to scope
|
||||
stdin.write(TerminalKeys.TAB as string); // Tab to scope
|
||||
await wait();
|
||||
stdin.write('2'); // Workspace
|
||||
await wait();
|
||||
stdin.write('\t'); // Tab to settings
|
||||
stdin.write(TerminalKeys.TAB as string); // Tab to settings
|
||||
await wait();
|
||||
stdin.write('\t'); // Tab to scope
|
||||
stdin.write(TerminalKeys.TAB as string); // Tab to scope
|
||||
await wait();
|
||||
stdin.write('1'); // User
|
||||
await wait();
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import {
|
||||
getDialogSettingKeys,
|
||||
getSettingValue,
|
||||
setPendingSettingValue,
|
||||
getDisplayValue,
|
||||
hasRestartRequiredSettings,
|
||||
@@ -28,11 +27,16 @@ import {
|
||||
getDefaultValue,
|
||||
setPendingSettingValueAny,
|
||||
getNestedValue,
|
||||
getEffectiveValue,
|
||||
} from '../../utils/settingsUtils.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import chalk from 'chalk';
|
||||
import { cpSlice, cpLen, stripUnsafeCharacters } from '../utils/textUtils.js';
|
||||
import {
|
||||
type SettingsValue,
|
||||
TOGGLE_TYPES,
|
||||
} from '../../config/settingsSchema.js';
|
||||
|
||||
interface SettingsDialogProps {
|
||||
settings: LoadedSettings;
|
||||
@@ -122,15 +126,33 @@ export function SettingsDialog({
|
||||
value: key,
|
||||
type: definition?.type,
|
||||
toggle: () => {
|
||||
if (definition?.type !== 'boolean') {
|
||||
// For non-boolean items, toggle will be handled via edit mode.
|
||||
if (!TOGGLE_TYPES.has(definition?.type)) {
|
||||
return;
|
||||
}
|
||||
const currentValue = getSettingValue(key, pendingSettings, {});
|
||||
const newValue = !currentValue;
|
||||
const currentValue = getEffectiveValue(key, pendingSettings, {});
|
||||
let newValue: SettingsValue;
|
||||
if (definition?.type === 'boolean') {
|
||||
newValue = !(currentValue as boolean);
|
||||
setPendingSettings((prev) =>
|
||||
setPendingSettingValue(key, newValue as boolean, prev),
|
||||
);
|
||||
} else if (definition?.type === 'enum' && definition.options) {
|
||||
const options = definition.options;
|
||||
const currentIndex = options?.findIndex(
|
||||
(opt) => opt.value === currentValue,
|
||||
);
|
||||
if (currentIndex !== -1 && currentIndex < options.length - 1) {
|
||||
newValue = options[currentIndex + 1].value;
|
||||
} else {
|
||||
newValue = options[0].value; // loop back to start.
|
||||
}
|
||||
setPendingSettings((prev) =>
|
||||
setPendingSettingValueAny(key, newValue, prev),
|
||||
);
|
||||
}
|
||||
|
||||
setPendingSettings((prev) =>
|
||||
setPendingSettingValue(key, newValue, prev),
|
||||
setPendingSettingValue(key, newValue as boolean, prev),
|
||||
);
|
||||
|
||||
if (!requiresRestart(key)) {
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { PrepareLabel } from './PrepareLabel.js';
|
||||
import { isSlashCommand } from '../utils/commandUtils.js';
|
||||
import { CommandKind } from '../commands/types.js';
|
||||
export interface Suggestion {
|
||||
label: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
matchedIndex?: number;
|
||||
commandKind?: CommandKind;
|
||||
}
|
||||
interface SuggestionsDisplayProps {
|
||||
suggestions: Suggestion[];
|
||||
@@ -53,21 +54,6 @@ export function SuggestionsDisplay({
|
||||
);
|
||||
const visibleSuggestions = suggestions.slice(startIndex, endIndex);
|
||||
|
||||
const isSlashCommandMode = isSlashCommand(userInput);
|
||||
let commandNameWidth = 0;
|
||||
|
||||
if (isSlashCommandMode) {
|
||||
const maxLabelLength = visibleSuggestions.length
|
||||
? Math.max(...visibleSuggestions.map((s) => s.label.length))
|
||||
: 0;
|
||||
|
||||
const maxAllowedWidth = Math.floor(width * 0.35);
|
||||
commandNameWidth = Math.max(
|
||||
15,
|
||||
Math.min(maxLabelLength + 2, maxAllowedWidth),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} width={width}>
|
||||
{scrollOffset > 0 && <Text color={Colors.Foreground}>▲</Text>}
|
||||
@@ -88,31 +74,33 @@ export function SuggestionsDisplay({
|
||||
return (
|
||||
<Box key={`${suggestion.value}-${originalIndex}`} width={width}>
|
||||
<Box flexDirection="row">
|
||||
{isSlashCommandMode ? (
|
||||
<>
|
||||
<Box width={commandNameWidth} flexShrink={0}>
|
||||
{labelElement}
|
||||
</Box>
|
||||
{suggestion.description ? (
|
||||
<Box flexGrow={1} marginLeft={1}>
|
||||
<Text color={textColor} wrap="wrap">
|
||||
{suggestion.description}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{labelElement}
|
||||
{suggestion.description ? (
|
||||
<Box flexGrow={1} marginLeft={1}>
|
||||
<Text color={textColor} wrap="wrap">
|
||||
{suggestion.description}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{(() => {
|
||||
const isSlashCommand = userInput.startsWith('/');
|
||||
return (
|
||||
<>
|
||||
{isSlashCommand ? (
|
||||
<Box flexShrink={0} paddingRight={2}>
|
||||
{labelElement}
|
||||
{suggestion.commandKind === CommandKind.MCP_PROMPT && (
|
||||
<Text color={Colors.Gray}> [MCP]</Text>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
labelElement
|
||||
)}
|
||||
{suggestion.description && (
|
||||
<Box
|
||||
flexGrow={1}
|
||||
paddingLeft={isSlashCommand ? undefined : 1}
|
||||
>
|
||||
<Text color={textColor} wrap="truncate">
|
||||
{suggestion.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ThemeDialog } from './ThemeDialog.js';
|
||||
import { LoadedSettings } from '../../config/settings.js';
|
||||
import { KeypressProvider } from '../contexts/KeypressContext.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
import { DEFAULT_THEME, themeManager } from '../themes/theme-manager.js';
|
||||
import { act } from 'react';
|
||||
|
||||
const createMockSettings = (
|
||||
userSettings = {},
|
||||
workspaceSettings = {},
|
||||
systemSettings = {},
|
||||
): LoadedSettings =>
|
||||
new LoadedSettings(
|
||||
{
|
||||
settings: { ui: { customThemes: {} }, ...systemSettings },
|
||||
path: '/system/settings.json',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '/system/system-defaults.json',
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
ui: { customThemes: {} },
|
||||
...userSettings,
|
||||
},
|
||||
path: '/user/settings.json',
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
ui: { customThemes: {} },
|
||||
...workspaceSettings,
|
||||
},
|
||||
path: '/workspace/settings.json',
|
||||
},
|
||||
true,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
describe('ThemeDialog Snapshots', () => {
|
||||
const baseProps = {
|
||||
onSelect: vi.fn(),
|
||||
onHighlight: vi.fn(),
|
||||
availableTerminalHeight: 40,
|
||||
terminalWidth: 120,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset theme manager to a known state
|
||||
themeManager.setActiveTheme(DEFAULT_THEME.name);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should render correctly in theme selection mode', () => {
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame } = render(
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ThemeDialog {...baseProps} settings={settings} />
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render correctly in scope selector mode', async () => {
|
||||
const settings = createMockSettings();
|
||||
const { lastFrame, stdin } = render(
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ThemeDialog {...baseProps} settings={settings} />
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
|
||||
// Press Tab to switch to scope selector mode
|
||||
act(() => {
|
||||
stdin.write('\t');
|
||||
});
|
||||
|
||||
// Need to wait for the state update to propagate
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -14,11 +14,9 @@ import { DiffRenderer } from './messages/DiffRenderer.js';
|
||||
import { colorizeCode } from '../utils/CodeColorizer.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import {
|
||||
getScopeItems,
|
||||
getScopeMessageForSetting,
|
||||
} from '../../utils/dialogScopeUtils.js';
|
||||
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { ScopeSelector } from './shared/ScopeSelector.js';
|
||||
|
||||
interface ThemeDialogProps {
|
||||
/** Callback function when a theme is selected */
|
||||
@@ -73,18 +71,14 @@ export function ThemeDialog({
|
||||
themeTypeDisplay: 'Custom',
|
||||
})),
|
||||
];
|
||||
const [selectInputKey, setSelectInputKey] = useState(Date.now());
|
||||
|
||||
// Find the index of the selected theme, but only if it exists in the list
|
||||
const selectedThemeName = settings.merged.ui?.theme || DEFAULT_THEME.name;
|
||||
const initialThemeIndex = themeItems.findIndex(
|
||||
(item) => item.value === selectedThemeName,
|
||||
(item) => item.value === highlightedThemeName,
|
||||
);
|
||||
// If not found, fall back to the first theme
|
||||
const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0;
|
||||
|
||||
const scopeItems = getScopeItems();
|
||||
|
||||
const handleThemeSelect = useCallback(
|
||||
(themeName: string) => {
|
||||
onSelect(themeName, selectedScope);
|
||||
@@ -99,25 +93,21 @@ export function ThemeDialog({
|
||||
|
||||
const handleScopeHighlight = useCallback((scope: SettingScope) => {
|
||||
setSelectedScope(scope);
|
||||
setSelectInputKey(Date.now());
|
||||
}, []);
|
||||
|
||||
const handleScopeSelect = useCallback(
|
||||
(scope: SettingScope) => {
|
||||
handleScopeHighlight(scope);
|
||||
setFocusedSection('theme'); // Reset focus to theme section
|
||||
onSelect(highlightedThemeName, scope);
|
||||
},
|
||||
[handleScopeHighlight],
|
||||
[onSelect, highlightedThemeName],
|
||||
);
|
||||
|
||||
const [focusedSection, setFocusedSection] = useState<'theme' | 'scope'>(
|
||||
'theme',
|
||||
);
|
||||
const [mode, setMode] = useState<'theme' | 'scope'>('theme');
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'tab') {
|
||||
setFocusedSection((prev) => (prev === 'theme' ? 'scope' : 'theme'));
|
||||
setMode((prev) => (prev === 'theme' ? 'scope' : 'theme'));
|
||||
}
|
||||
if (key.name === 'escape') {
|
||||
onSelect(undefined, selectedScope);
|
||||
@@ -152,20 +142,13 @@ export function ThemeDialog({
|
||||
|
||||
const DIALOG_PADDING = 2;
|
||||
const selectThemeHeight = themeItems.length + 1;
|
||||
const SCOPE_SELECTION_HEIGHT = 4; // Height for the scope selection section + margin.
|
||||
const SPACE_BETWEEN_THEME_SELECTION_AND_APPLY_TO = 1;
|
||||
const TAB_TO_SELECT_HEIGHT = 2;
|
||||
availableTerminalHeight = availableTerminalHeight ?? Number.MAX_SAFE_INTEGER;
|
||||
availableTerminalHeight -= 2; // Top and bottom borders.
|
||||
availableTerminalHeight -= TAB_TO_SELECT_HEIGHT;
|
||||
|
||||
let totalLeftHandSideHeight =
|
||||
DIALOG_PADDING +
|
||||
selectThemeHeight +
|
||||
SCOPE_SELECTION_HEIGHT +
|
||||
SPACE_BETWEEN_THEME_SELECTION_AND_APPLY_TO;
|
||||
let totalLeftHandSideHeight = DIALOG_PADDING + selectThemeHeight;
|
||||
|
||||
let showScopeSelection = true;
|
||||
let includePadding = true;
|
||||
|
||||
// Remove content from the LHS that can be omitted if it exceeds the available height.
|
||||
@@ -174,15 +157,6 @@ export function ThemeDialog({
|
||||
totalLeftHandSideHeight -= DIALOG_PADDING;
|
||||
}
|
||||
|
||||
if (totalLeftHandSideHeight > availableTerminalHeight) {
|
||||
// First, try hiding the scope selection
|
||||
totalLeftHandSideHeight -= SCOPE_SELECTION_HEIGHT;
|
||||
showScopeSelection = false;
|
||||
}
|
||||
|
||||
// Don't focus the scope selection if it is hidden due to height constraints.
|
||||
const currentFocusedSection = !showScopeSelection ? 'theme' : focusedSection;
|
||||
|
||||
// Vertical space taken by elements other than the two code blocks in the preview pane.
|
||||
// Includes "Preview" title, borders, and margin between blocks.
|
||||
const PREVIEW_PANE_FIXED_VERTICAL_SPACE = 8;
|
||||
@@ -217,94 +191,85 @@ export function ThemeDialog({
|
||||
paddingRight={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box flexDirection="row">
|
||||
{/* Left Column: Selection */}
|
||||
<Box flexDirection="column" width="45%" paddingRight={2}>
|
||||
<Text bold={currentFocusedSection === 'theme'} wrap="truncate">
|
||||
{currentFocusedSection === 'theme' ? '> ' : ' '}Select Theme{' '}
|
||||
<Text color={Colors.Gray}>{otherScopeModifiedMessage}</Text>
|
||||
</Text>
|
||||
<RadioButtonSelect
|
||||
key={selectInputKey}
|
||||
items={themeItems}
|
||||
initialIndex={safeInitialThemeIndex}
|
||||
onSelect={handleThemeSelect}
|
||||
onHighlight={handleThemeHighlight}
|
||||
isFocused={currentFocusedSection === 'theme'}
|
||||
maxItemsToShow={8}
|
||||
showScrollArrows={true}
|
||||
showNumbers={currentFocusedSection === 'theme'}
|
||||
/>
|
||||
{mode === 'theme' ? (
|
||||
<Box flexDirection="row">
|
||||
{/* Left Column: Selection */}
|
||||
<Box flexDirection="column" width="45%" paddingRight={2}>
|
||||
<Text bold={mode === 'theme'} wrap="truncate">
|
||||
{mode === 'theme' ? '> ' : ' '}Select Theme{' '}
|
||||
<Text color={Colors.Gray}>{otherScopeModifiedMessage}</Text>
|
||||
</Text>
|
||||
<RadioButtonSelect
|
||||
items={themeItems}
|
||||
initialIndex={safeInitialThemeIndex}
|
||||
onSelect={handleThemeSelect}
|
||||
onHighlight={handleThemeHighlight}
|
||||
isFocused={mode === 'theme'}
|
||||
maxItemsToShow={12}
|
||||
showScrollArrows={true}
|
||||
showNumbers={mode === 'theme'}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Scope Selection */}
|
||||
{showScopeSelection && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text bold={currentFocusedSection === 'scope'} wrap="truncate">
|
||||
{currentFocusedSection === 'scope' ? '> ' : ' '}Apply To
|
||||
</Text>
|
||||
<RadioButtonSelect
|
||||
items={scopeItems}
|
||||
initialIndex={0} // Default to User Settings
|
||||
onSelect={handleScopeSelect}
|
||||
onHighlight={handleScopeHighlight}
|
||||
isFocused={currentFocusedSection === 'scope'}
|
||||
showNumbers={currentFocusedSection === 'scope'}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Right Column: Preview */}
|
||||
<Box flexDirection="column" width="55%" paddingLeft={2}>
|
||||
<Text bold>Preview</Text>
|
||||
{/* Get the Theme object for the highlighted theme, fall back to default if not found */}
|
||||
{(() => {
|
||||
const previewTheme =
|
||||
themeManager.getTheme(
|
||||
highlightedThemeName || DEFAULT_THEME.name,
|
||||
) || DEFAULT_THEME;
|
||||
return (
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={Colors.Gray}
|
||||
paddingTop={includePadding ? 1 : 0}
|
||||
paddingBottom={includePadding ? 1 : 0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
{colorizeCode(
|
||||
`# function
|
||||
{/* Right Column: Preview */}
|
||||
<Box flexDirection="column" width="55%" paddingLeft={2}>
|
||||
<Text bold>Preview</Text>
|
||||
{/* Get the Theme object for the highlighted theme, fall back to default if not found */}
|
||||
{(() => {
|
||||
const previewTheme =
|
||||
themeManager.getTheme(
|
||||
highlightedThemeName || DEFAULT_THEME.name,
|
||||
) || DEFAULT_THEME;
|
||||
return (
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={Colors.Gray}
|
||||
paddingTop={includePadding ? 1 : 0}
|
||||
paddingBottom={includePadding ? 1 : 0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
{colorizeCode(
|
||||
`# function
|
||||
def fibonacci(n):
|
||||
a, b = 0, 1
|
||||
for _ in range(n):
|
||||
a, b = b, a + b
|
||||
return a`,
|
||||
'python',
|
||||
codeBlockHeight,
|
||||
colorizeCodeWidth,
|
||||
)}
|
||||
<Box marginTop={1} />
|
||||
<DiffRenderer
|
||||
diffContent={`--- a/util.py
|
||||
'python',
|
||||
codeBlockHeight,
|
||||
colorizeCodeWidth,
|
||||
)}
|
||||
<Box marginTop={1} />
|
||||
<DiffRenderer
|
||||
diffContent={`--- a/util.py
|
||||
+++ b/util.py
|
||||
@@ -1,2 +1,2 @@
|
||||
- print("Hello, " + name)
|
||||
+ print(f"Hello, {name}!")
|
||||
`}
|
||||
availableTerminalHeight={diffHeight}
|
||||
terminalWidth={colorizeCodeWidth}
|
||||
theme={previewTheme}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
availableTerminalHeight={diffHeight}
|
||||
terminalWidth={colorizeCodeWidth}
|
||||
theme={previewTheme}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<ScopeSelector
|
||||
onSelect={handleScopeSelect}
|
||||
onHighlight={handleScopeHighlight}
|
||||
isFocused={mode === 'scope'}
|
||||
initialScope={selectedScope}
|
||||
/>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray} wrap="truncate">
|
||||
(Use Enter to select
|
||||
{showScopeSelection ? ', Tab to change focus' : ''})
|
||||
(Use Enter to {mode === 'theme' ? 'select' : 'apply scope'}, Tab to{' '}
|
||||
{mode === 'theme' ? 'configure scope' : 'select theme'})
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ThemeDialog Snapshots > should render correctly in scope selector mode 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ > Apply To │
|
||||
│ ● 1. User Settings │
|
||||
│ 2. Workspace Settings │
|
||||
│ 3. System Settings │
|
||||
│ │
|
||||
│ (Use Enter to apply scope, Tab to select theme) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`ThemeDialog Snapshots > should render correctly in theme selection mode 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ > Select Theme Preview │
|
||||
│ ▲ ┌─────────────────────────────────────────────────┐ │
|
||||
│ 1. ANSI Dark │ │ │
|
||||
│ 2. Atom One Dark │ 1 # function │ │
|
||||
│ 3. Ayu Dark │ 2 def fibonacci(n): │ │
|
||||
│ ● 4. Default Dark │ 3 a, b = 0, 1 │ │
|
||||
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
|
||||
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
|
||||
│ 7. Shades Of Purple Dark │ 6 return a │ │
|
||||
│ 8. ANSI Light Light │ │ │
|
||||
│ 9. Ayu Light Light │ 1 - print("Hello, " + name) │ │
|
||||
│ 10. Default Light Light │ 1 + print(f"Hello, {name}!") │ │
|
||||
│ 11. GitHub Light Light │ │ │
|
||||
│ 12. Google Code Light └─────────────────────────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to configure scope) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -7,13 +7,16 @@
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Text } from 'ink';
|
||||
import type React from 'react';
|
||||
import { ToolGroupMessage } from './ToolGroupMessage.js';
|
||||
import { type IndividualToolCallDisplay, ToolCallStatus } from '../../types.js';
|
||||
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
import { ToolCallStatus } from '../../types.js';
|
||||
import type {
|
||||
Config,
|
||||
ToolCallConfirmationDetails,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { TOOL_STATUS } from '../../constants.js';
|
||||
import { ConfigContext } from '../../contexts/ConfigContext.js';
|
||||
|
||||
// Mock child components to isolate ToolGroupMessage behavior
|
||||
vi.mock('./ToolMessage.js', () => ({
|
||||
@@ -81,14 +84,21 @@ describe('<ToolGroupMessage />', () => {
|
||||
const baseProps = {
|
||||
groupId: 1,
|
||||
terminalWidth: 80,
|
||||
config: mockConfig,
|
||||
isFocused: true,
|
||||
};
|
||||
|
||||
// Helper to wrap component with required providers
|
||||
const renderWithProviders = (component: React.ReactElement) =>
|
||||
render(
|
||||
<ConfigContext.Provider value={mockConfig}>
|
||||
{component}
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
|
||||
describe('Golden Snapshots', () => {
|
||||
it('renders single successful tool call', () => {
|
||||
const toolCalls = [createToolCall()];
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -115,7 +125,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
status: ToolCallStatus.Error,
|
||||
}),
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -136,7 +146,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
},
|
||||
}),
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -151,7 +161,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
status: ToolCallStatus.Success,
|
||||
}),
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -178,7 +188,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
status: ToolCallStatus.Pending,
|
||||
}),
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -200,7 +210,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
resultDisplay: 'More output here',
|
||||
}),
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ToolGroupMessage
|
||||
{...baseProps}
|
||||
toolCalls={toolCalls}
|
||||
@@ -212,7 +222,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
|
||||
it('renders when not focused', () => {
|
||||
const toolCalls = [createToolCall()];
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ToolGroupMessage
|
||||
{...baseProps}
|
||||
toolCalls={toolCalls}
|
||||
@@ -230,7 +240,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
'This is a very long description that might cause wrapping issues',
|
||||
}),
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ToolGroupMessage
|
||||
{...baseProps}
|
||||
toolCalls={toolCalls}
|
||||
@@ -241,7 +251,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
});
|
||||
|
||||
it('renders empty tool calls array', () => {
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ToolGroupMessage {...baseProps} toolCalls={[]} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -251,7 +261,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
describe('Border Color Logic', () => {
|
||||
it('uses yellow border when tools are pending', () => {
|
||||
const toolCalls = [createToolCall({ status: ToolCallStatus.Pending })];
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
|
||||
);
|
||||
// The snapshot will capture the visual appearance including border color
|
||||
@@ -265,7 +275,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
status: ToolCallStatus.Success,
|
||||
}),
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -280,7 +290,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
status: ToolCallStatus.Success,
|
||||
}),
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -303,7 +313,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
resultDisplay: '', // No result
|
||||
}),
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ToolGroupMessage
|
||||
{...baseProps}
|
||||
toolCalls={toolCalls}
|
||||
@@ -340,7 +350,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
},
|
||||
}),
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
|
||||
);
|
||||
// Should only show confirmation for the first tool
|
||||
|
||||
@@ -6,21 +6,20 @@
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
import { ToolCallStatus } from '../../types.js';
|
||||
import { ToolMessage } from './ToolMessage.js';
|
||||
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
|
||||
import { Colors } from '../../colors.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { SHELL_COMMAND_NAME } from '../../constants.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
|
||||
interface ToolGroupMessageProps {
|
||||
groupId: number;
|
||||
toolCalls: IndividualToolCallDisplay[];
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
config: Config;
|
||||
isFocused?: boolean;
|
||||
}
|
||||
|
||||
@@ -29,15 +28,15 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
toolCalls,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
config,
|
||||
isFocused = true,
|
||||
}) => {
|
||||
const config = useConfig();
|
||||
const hasPending = !toolCalls.every(
|
||||
(t) => t.status === ToolCallStatus.Success,
|
||||
);
|
||||
const isShellCommand = toolCalls.some((t) => t.name === SHELL_COMMAND_NAME);
|
||||
const borderColor =
|
||||
hasPending || isShellCommand ? Colors.AccentYellow : Colors.Gray;
|
||||
hasPending || isShellCommand ? theme.status.warning : theme.border.default;
|
||||
|
||||
const staticHeight = /* border */ 2 + /* marginBottom */ 1;
|
||||
// This is a bit of a magic number, but it accounts for the border and
|
||||
@@ -121,6 +120,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
terminalWidth={innerWidth}
|
||||
/>
|
||||
)}
|
||||
{tool.outputFile && (
|
||||
<Box marginX={1}>
|
||||
<Text>Output too long and was saved to: {tool.outputFile}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { EnumSelector } from './EnumSelector.js';
|
||||
import type { SettingEnumOption } from '../../../config/settingsSchema.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
const LANGUAGE_OPTIONS: readonly SettingEnumOption[] = [
|
||||
{ label: 'English', value: 'en' },
|
||||
{ label: '中文 (简体)', value: 'zh' },
|
||||
{ label: 'Español', value: 'es' },
|
||||
{ label: 'Français', value: 'fr' },
|
||||
];
|
||||
|
||||
const NUMERIC_OPTIONS: readonly SettingEnumOption[] = [
|
||||
{ label: 'Low', value: 1 },
|
||||
{ label: 'Medium', value: 2 },
|
||||
{ label: 'High', value: 3 },
|
||||
];
|
||||
|
||||
describe('<EnumSelector />', () => {
|
||||
it('renders with string options and matches snapshot', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={LANGUAGE_OPTIONS}
|
||||
currentValue="en"
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders with numeric options and matches snapshot', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={NUMERIC_OPTIONS}
|
||||
currentValue={2}
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders inactive state and matches snapshot', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={LANGUAGE_OPTIONS}
|
||||
currentValue="zh"
|
||||
isActive={false}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders with single option and matches snapshot', () => {
|
||||
const singleOption: readonly SettingEnumOption[] = [
|
||||
{ label: 'Only Option', value: 'only' },
|
||||
];
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={singleOption}
|
||||
currentValue="only"
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders nothing when no options are provided', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={[]}
|
||||
currentValue=""
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
|
||||
it('handles currentValue not found in options', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={LANGUAGE_OPTIONS}
|
||||
currentValue="invalid"
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
// Should default to first option
|
||||
expect(lastFrame()).toContain('English');
|
||||
});
|
||||
|
||||
it('updates when currentValue changes externally', () => {
|
||||
const { rerender, lastFrame } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={LANGUAGE_OPTIONS}
|
||||
currentValue="en"
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('English');
|
||||
|
||||
rerender(
|
||||
<EnumSelector
|
||||
options={LANGUAGE_OPTIONS}
|
||||
currentValue="zh"
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('中文 (简体)');
|
||||
});
|
||||
|
||||
it('shows navigation arrows when multiple options available', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={LANGUAGE_OPTIONS}
|
||||
currentValue="en"
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('←');
|
||||
expect(lastFrame()).toContain('→');
|
||||
});
|
||||
|
||||
it('hides navigation arrows when single option available', () => {
|
||||
const singleOption: readonly SettingEnumOption[] = [
|
||||
{ label: 'Only Option', value: 'only' },
|
||||
];
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={singleOption}
|
||||
currentValue="only"
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).not.toContain('←');
|
||||
expect(lastFrame()).not.toContain('→');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
import type { SettingEnumOption } from '../../../config/settingsSchema.js';
|
||||
|
||||
interface EnumSelectorProps {
|
||||
options: readonly SettingEnumOption[];
|
||||
currentValue: string | number;
|
||||
isActive: boolean;
|
||||
onValueChange: (value: string | number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A left-right scrolling selector for enum values
|
||||
*/
|
||||
export function EnumSelector({
|
||||
options,
|
||||
currentValue,
|
||||
isActive,
|
||||
onValueChange: _onValueChange,
|
||||
}: EnumSelectorProps): React.JSX.Element {
|
||||
const [currentIndex, setCurrentIndex] = useState(() => {
|
||||
// Guard against empty options array
|
||||
if (!options || options.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const index = options.findIndex((option) => option.value === currentValue);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
|
||||
// Update index when currentValue changes externally
|
||||
useEffect(() => {
|
||||
// Guard against empty options array
|
||||
if (!options || options.length === 0) {
|
||||
return;
|
||||
}
|
||||
const index = options.findIndex((option) => option.value === currentValue);
|
||||
// Always update index, defaulting to 0 if value not found
|
||||
setCurrentIndex(index >= 0 ? index : 0);
|
||||
}, [currentValue, options]);
|
||||
|
||||
// Guard against empty options array
|
||||
if (!options || options.length === 0) {
|
||||
return <Box />;
|
||||
}
|
||||
|
||||
// Left/right navigation is handled by parent component
|
||||
// This component is purely for display
|
||||
// onValueChange is kept for interface compatibility but not used internally
|
||||
|
||||
const currentOption = options[currentIndex] || options[0];
|
||||
const canScrollLeft = options.length > 1;
|
||||
const canScrollRight = options.length > 1;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
<Text
|
||||
color={isActive && canScrollLeft ? Colors.AccentGreen : Colors.Gray}
|
||||
>
|
||||
{canScrollLeft ? '←' : ' '}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text
|
||||
color={isActive ? Colors.AccentGreen : Colors.Foreground}
|
||||
bold={isActive}
|
||||
>
|
||||
{currentOption.label}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text
|
||||
color={isActive && canScrollRight ? Colors.AccentGreen : Colors.Gray}
|
||||
>
|
||||
{canScrollRight ? '→' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Export the interface for external use
|
||||
export type { EnumSelectorProps };
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { SettingScope } from '../../../config/settings.js';
|
||||
import { getScopeItems } from '../../../utils/dialogScopeUtils.js';
|
||||
import { RadioButtonSelect } from './RadioButtonSelect.js';
|
||||
|
||||
interface ScopeSelectorProps {
|
||||
/** Callback function when a scope is selected */
|
||||
onSelect: (scope: SettingScope) => void;
|
||||
/** Callback function when a scope is highlighted */
|
||||
onHighlight: (scope: SettingScope) => void;
|
||||
/** Whether the component is focused */
|
||||
isFocused: boolean;
|
||||
/** The initial scope to select */
|
||||
initialScope: SettingScope;
|
||||
}
|
||||
|
||||
export function ScopeSelector({
|
||||
onSelect,
|
||||
onHighlight,
|
||||
isFocused,
|
||||
initialScope,
|
||||
}: ScopeSelectorProps): React.JSX.Element {
|
||||
const scopeItems = getScopeItems();
|
||||
|
||||
const initialIndex = scopeItems.findIndex(
|
||||
(item) => item.value === initialScope,
|
||||
);
|
||||
const safeInitialIndex = initialIndex >= 0 ? initialIndex : 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold={isFocused} wrap="truncate">
|
||||
{isFocused ? '> ' : ' '}Apply To
|
||||
</Text>
|
||||
<RadioButtonSelect
|
||||
items={scopeItems}
|
||||
initialIndex={safeInitialIndex}
|
||||
onSelect={onSelect}
|
||||
onHighlight={onHighlight}
|
||||
isFocused={isFocused}
|
||||
showNumbers={isFocused}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<EnumSelector /> > renders inactive state and matches snapshot 1`] = `"← 中文 (简体) →"`;
|
||||
|
||||
exports[`<EnumSelector /> > renders with numeric options and matches snapshot 1`] = `"← Medium →"`;
|
||||
|
||||
exports[`<EnumSelector /> > renders with single option and matches snapshot 1`] = `" Only Option"`;
|
||||
|
||||
exports[`<EnumSelector /> > renders with string options and matches snapshot 1`] = `"← English →"`;
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export interface AppState {
|
||||
version: string;
|
||||
startupWarnings: string[];
|
||||
}
|
||||
|
||||
export const AppContext = createContext<AppState | null>(null);
|
||||
|
||||
export const useAppContext = () => {
|
||||
const context = useContext(AppContext);
|
||||
if (!context) {
|
||||
throw new Error('useAppContext must be used within an AppProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useContext } from 'react';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
|
||||
export const ConfigContext = React.createContext<Config | undefined>(undefined);
|
||||
|
||||
export const useConfig = () => {
|
||||
const context = useContext(ConfigContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useConfig must be used within a ConfigProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -9,7 +9,15 @@ import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import type { Mock } from 'vitest';
|
||||
import { vi } from 'vitest';
|
||||
import type { Key } from './KeypressContext.js';
|
||||
import { KeypressProvider, useKeypressContext } from './KeypressContext.js';
|
||||
import {
|
||||
KeypressProvider,
|
||||
useKeypressContext,
|
||||
DRAG_COMPLETION_TIMEOUT_MS,
|
||||
// CSI_END_O,
|
||||
// SS3_END,
|
||||
SINGLE_QUOTE,
|
||||
DOUBLE_QUOTE,
|
||||
} from './KeypressContext.js';
|
||||
import { useStdin } from 'ink';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
@@ -740,3 +748,211 @@ describe('KeypressContext - Kitty Protocol', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Drag and Drop Handling', () => {
|
||||
let stdin: MockStdin;
|
||||
const mockSetRawMode = vi.fn();
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<KeypressProvider kittyProtocolEnabled={true}>{children}</KeypressProvider>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
stdin = new MockStdin();
|
||||
(useStdin as Mock).mockReturnValue({
|
||||
stdin,
|
||||
setRawMode: mockSetRawMode,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('drag start by quotes', () => {
|
||||
it('should start collecting when single quote arrives and not broadcast immediately', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
name: undefined,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: SINGLE_QUOTE,
|
||||
});
|
||||
});
|
||||
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should start collecting when double quote arrives and not broadcast immediately', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
name: undefined,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: DOUBLE_QUOTE,
|
||||
});
|
||||
});
|
||||
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('drag collection and completion', () => {
|
||||
it('should collect single character inputs during drag mode', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Start by single quote
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
name: undefined,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: SINGLE_QUOTE,
|
||||
});
|
||||
});
|
||||
|
||||
// Send single character
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
name: undefined,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: 'a',
|
||||
});
|
||||
});
|
||||
|
||||
// Character should not be immediately broadcast
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Fast-forward to completion timeout
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DRAG_COMPLETION_TIMEOUT_MS + 10);
|
||||
});
|
||||
|
||||
// Should broadcast the collected path as paste (includes starting quote)
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: '',
|
||||
paste: true,
|
||||
sequence: `${SINGLE_QUOTE}a`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should collect multiple characters and complete on timeout', async () => {
|
||||
const keyHandler = vi.fn();
|
||||
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(keyHandler);
|
||||
});
|
||||
|
||||
// Start by single quote
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
name: undefined,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: SINGLE_QUOTE,
|
||||
});
|
||||
});
|
||||
|
||||
// Send multiple characters
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
name: undefined,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: 'p',
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
name: undefined,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: 'a',
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
name: undefined,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: 't',
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
stdin.pressKey({
|
||||
name: undefined,
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: 'h',
|
||||
});
|
||||
});
|
||||
|
||||
// Characters should not be immediately broadcast
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Fast-forward to completion timeout
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DRAG_COMPLETION_TIMEOUT_MS + 10);
|
||||
});
|
||||
|
||||
// Should broadcast the collected path as paste (includes starting quote)
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: '',
|
||||
paste: true,
|
||||
sequence: `${SINGLE_QUOTE}path`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,9 @@ import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js';
|
||||
const ESC = '\u001B';
|
||||
export const PASTE_MODE_PREFIX = `${ESC}[200~`;
|
||||
export const PASTE_MODE_SUFFIX = `${ESC}[201~`;
|
||||
export const DRAG_COMPLETION_TIMEOUT_MS = 100; // Broadcast full path after 100ms if no more input
|
||||
export const SINGLE_QUOTE = "'";
|
||||
export const DOUBLE_QUOTE = '"';
|
||||
|
||||
export interface Key {
|
||||
name: string;
|
||||
@@ -86,6 +89,9 @@ export function KeypressProvider({
|
||||
}) {
|
||||
const { stdin, setRawMode } = useStdin();
|
||||
const subscribers = useRef<Set<KeypressHandler>>(new Set()).current;
|
||||
const isDraggingRef = useRef(false);
|
||||
const dragBufferRef = useRef('');
|
||||
const draggingTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const subscribe = useCallback(
|
||||
(handler: KeypressHandler) => {
|
||||
@@ -102,7 +108,17 @@ export function KeypressProvider({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setRawMode(true);
|
||||
const clearDraggingTimer = () => {
|
||||
if (draggingTimerRef.current) {
|
||||
clearTimeout(draggingTimerRef.current);
|
||||
draggingTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const wasRaw = stdin.isRaw;
|
||||
if (wasRaw === false) {
|
||||
setRawMode(true);
|
||||
}
|
||||
|
||||
const keypressStream = new PassThrough();
|
||||
let usePassthrough = false;
|
||||
@@ -395,6 +411,27 @@ export function KeypressProvider({
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
key.sequence === SINGLE_QUOTE ||
|
||||
key.sequence === DOUBLE_QUOTE ||
|
||||
isDraggingRef.current
|
||||
) {
|
||||
isDraggingRef.current = true;
|
||||
dragBufferRef.current += key.sequence;
|
||||
|
||||
clearDraggingTimer();
|
||||
draggingTimerRef.current = setTimeout(() => {
|
||||
isDraggingRef.current = false;
|
||||
const seq = dragBufferRef.current;
|
||||
dragBufferRef.current = '';
|
||||
if (seq) {
|
||||
broadcast({ ...key, name: '', paste: true, sequence: seq });
|
||||
}
|
||||
}, DRAG_COMPLETION_TIMEOUT_MS);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === 'return' && waitingForEnterAfterBackslash) {
|
||||
if (backslashTimeout) {
|
||||
clearTimeout(backslashTimeout);
|
||||
@@ -643,7 +680,9 @@ export function KeypressProvider({
|
||||
rl.close();
|
||||
|
||||
// Restore the terminal to its original state.
|
||||
setRawMode(false);
|
||||
if (wasRaw === false) {
|
||||
setRawMode(false);
|
||||
}
|
||||
|
||||
if (backslashTimeout) {
|
||||
clearTimeout(backslashTimeout);
|
||||
@@ -662,6 +701,23 @@ export function KeypressProvider({
|
||||
});
|
||||
pasteBuffer = Buffer.alloc(0);
|
||||
}
|
||||
|
||||
if (draggingTimerRef.current) {
|
||||
clearTimeout(draggingTimerRef.current);
|
||||
draggingTimerRef.current = null;
|
||||
}
|
||||
if (isDraggingRef.current && dragBufferRef.current) {
|
||||
broadcast({
|
||||
name: '',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: true,
|
||||
sequence: dragBufferRef.current,
|
||||
});
|
||||
isDraggingRef.current = false;
|
||||
dragBufferRef.current = '';
|
||||
}
|
||||
};
|
||||
}, [
|
||||
stdin,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
import { type Key } from '../hooks/useKeypress.js';
|
||||
import { type IdeIntegrationNudgeResult } from '../IdeIntegrationNudge.js';
|
||||
import { type FolderTrustChoice } from '../components/FolderTrustDialog.js';
|
||||
import { type AuthType, type EditorType } from '@google/gemini-cli-core';
|
||||
import { type SettingScope } from '../../config/settings.js';
|
||||
import type { AuthState } from '../types.js';
|
||||
|
||||
export interface UIActions {
|
||||
handleThemeSelect: (
|
||||
themeName: string | undefined,
|
||||
scope: SettingScope,
|
||||
) => void;
|
||||
handleThemeHighlight: (themeName: string | undefined) => void;
|
||||
handleAuthSelect: (
|
||||
authType: AuthType | undefined,
|
||||
scope: SettingScope,
|
||||
) => void;
|
||||
setAuthState: (state: AuthState) => void;
|
||||
onAuthError: (error: string) => void;
|
||||
handleEditorSelect: (
|
||||
editorType: EditorType | undefined,
|
||||
scope: SettingScope,
|
||||
) => void;
|
||||
exitEditorDialog: () => void;
|
||||
exitPrivacyNotice: () => void;
|
||||
closeSettingsDialog: () => void;
|
||||
setShellModeActive: (value: boolean) => void;
|
||||
vimHandleInput: (key: Key) => boolean;
|
||||
handleIdePromptComplete: (result: IdeIntegrationNudgeResult) => void;
|
||||
handleFolderTrustSelect: (choice: FolderTrustChoice) => void;
|
||||
setConstrainHeight: (value: boolean) => void;
|
||||
onEscapePromptChange: (show: boolean) => void;
|
||||
refreshStatic: () => void;
|
||||
handleFinalSubmit: (value: string) => void;
|
||||
handleClearScreen: () => void;
|
||||
onWorkspaceMigrationDialogOpen: () => void;
|
||||
onWorkspaceMigrationDialogClose: () => void;
|
||||
handleProQuotaChoice: (choice: 'auth' | 'continue') => void;
|
||||
}
|
||||
|
||||
export const UIActionsContext = createContext<UIActions | null>(null);
|
||||
|
||||
export const useUIActions = () => {
|
||||
const context = useContext(UIActionsContext);
|
||||
if (!context) {
|
||||
throw new Error('useUIActions must be used within a UIActionsProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
import type {
|
||||
HistoryItem,
|
||||
ThoughtSummary,
|
||||
ConsoleMessageItem,
|
||||
ShellConfirmationRequest,
|
||||
ConfirmationRequest,
|
||||
HistoryItemWithoutId,
|
||||
StreamingState,
|
||||
} from '../types.js';
|
||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import type { TextBuffer } from '../components/shared/text-buffer.js';
|
||||
import type {
|
||||
IdeContext,
|
||||
ApprovalMode,
|
||||
UserTierId,
|
||||
DetectedIde,
|
||||
FallbackIntent,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { DOMElement } from 'ink';
|
||||
import type { SessionStatsState } from '../contexts/SessionContext.js';
|
||||
import type { UpdateObject } from '../utils/updateCheck.js';
|
||||
|
||||
export interface ProQuotaDialogRequest {
|
||||
failedModel: string;
|
||||
fallbackModel: string;
|
||||
resolve: (intent: FallbackIntent) => void;
|
||||
}
|
||||
|
||||
export interface UIState {
|
||||
history: HistoryItem[];
|
||||
isThemeDialogOpen: boolean;
|
||||
themeError: string | null;
|
||||
isAuthenticating: boolean;
|
||||
isConfigInitialized: boolean;
|
||||
authError: string | null;
|
||||
isAuthDialogOpen: boolean;
|
||||
editorError: string | null;
|
||||
isEditorDialogOpen: boolean;
|
||||
showPrivacyNotice: boolean;
|
||||
corgiMode: boolean;
|
||||
debugMessage: string;
|
||||
quittingMessages: HistoryItem[] | null;
|
||||
isSettingsDialogOpen: boolean;
|
||||
slashCommands: readonly SlashCommand[];
|
||||
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
|
||||
commandContext: CommandContext;
|
||||
shellConfirmationRequest: ShellConfirmationRequest | null;
|
||||
confirmationRequest: ConfirmationRequest | null;
|
||||
geminiMdFileCount: number;
|
||||
streamingState: StreamingState;
|
||||
initError: string | null;
|
||||
pendingGeminiHistoryItems: HistoryItemWithoutId[];
|
||||
thought: ThoughtSummary | null;
|
||||
shellModeActive: boolean;
|
||||
userMessages: string[];
|
||||
buffer: TextBuffer;
|
||||
inputWidth: number;
|
||||
suggestionsWidth: number;
|
||||
isInputActive: boolean;
|
||||
shouldShowIdePrompt: boolean;
|
||||
isFolderTrustDialogOpen: boolean;
|
||||
isTrustedFolder: boolean | undefined;
|
||||
constrainHeight: boolean;
|
||||
showErrorDetails: boolean;
|
||||
filteredConsoleMessages: ConsoleMessageItem[];
|
||||
ideContextState: IdeContext | undefined;
|
||||
showToolDescriptions: boolean;
|
||||
ctrlCPressedOnce: boolean;
|
||||
ctrlDPressedOnce: boolean;
|
||||
showEscapePrompt: boolean;
|
||||
isFocused: boolean;
|
||||
elapsedTime: number;
|
||||
currentLoadingPhrase: string;
|
||||
historyRemountKey: number;
|
||||
messageQueue: string[];
|
||||
showAutoAcceptIndicator: ApprovalMode;
|
||||
showWorkspaceMigrationDialog: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
workspaceExtensions: any[]; // Extension[]
|
||||
// Quota-related state
|
||||
userTier: UserTierId | undefined;
|
||||
proQuotaRequest: ProQuotaDialogRequest | null;
|
||||
currentModel: string;
|
||||
contextFileNames: string[];
|
||||
errorCount: number;
|
||||
availableTerminalHeight: number | undefined;
|
||||
mainAreaWidth: number;
|
||||
staticAreaMaxItemHeight: number;
|
||||
staticExtraHeight: number;
|
||||
dialogsVisible: boolean;
|
||||
pendingHistoryItems: HistoryItemWithoutId[];
|
||||
nightly: boolean;
|
||||
branchName: string | undefined;
|
||||
sessionStats: SessionStatsState;
|
||||
terminalWidth: number;
|
||||
terminalHeight: number;
|
||||
mainControlsRef: React.MutableRefObject<DOMElement | null>;
|
||||
currentIDE: DetectedIde | null;
|
||||
updateInfo: UpdateObject | null;
|
||||
showIdeRestartPrompt: boolean;
|
||||
isRestarting: boolean;
|
||||
}
|
||||
|
||||
export const UIStateContext = createContext<UIState | null>(null);
|
||||
|
||||
export const useUIState = () => {
|
||||
const context = useContext(UIStateContext);
|
||||
if (!context) {
|
||||
throw new Error('useUIState must be used within a UIStateProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
StandardFileSystemService,
|
||||
ToolRegistry,
|
||||
COMMON_IGNORE_PATTERNS,
|
||||
DEFAULT_FILE_EXCLUDES,
|
||||
// DEFAULT_FILE_EXCLUDES,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as os from 'node:os';
|
||||
import { ToolCallStatus } from '../types.js';
|
||||
@@ -74,10 +74,10 @@ describe('handleAtCommand', () => {
|
||||
getDebugMode: () => false,
|
||||
getFileExclusions: () => ({
|
||||
getCoreIgnorePatterns: () => COMMON_IGNORE_PATTERNS,
|
||||
getDefaultExcludePatterns: () => DEFAULT_FILE_EXCLUDES,
|
||||
getGlobExcludes: () => COMMON_IGNORE_PATTERNS,
|
||||
buildExcludePatterns: () => DEFAULT_FILE_EXCLUDES,
|
||||
getReadManyFilesExcludes: () => DEFAULT_FILE_EXCLUDES,
|
||||
getDefaultExcludePatterns: () => [],
|
||||
getGlobExcludes: () => [],
|
||||
buildExcludePatterns: () => [],
|
||||
getReadManyFilesExcludes: () => [],
|
||||
}),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
} as unknown as Config;
|
||||
|
||||
@@ -4,6 +4,27 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useSlashCommandProcessor } from './slashCommandProcessor.js';
|
||||
import type {
|
||||
CommandContext,
|
||||
ConfirmShellCommandsActionReturn,
|
||||
SlashCommand,
|
||||
} from '../commands/types.js';
|
||||
import { CommandKind } from '../commands/types.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
|
||||
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
|
||||
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
|
||||
import {
|
||||
type GeminiClient,
|
||||
SlashCommandStatus,
|
||||
ToolConfirmationOutcome,
|
||||
makeFakeConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
const { logSlashCommand } = vi.hoisted(() => ({
|
||||
logSlashCommand: vi.fn(),
|
||||
}));
|
||||
@@ -68,26 +89,6 @@ vi.mock('../../utils/cleanup.js', () => ({
|
||||
runExitCleanup: mockRunExitCleanup,
|
||||
}));
|
||||
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
|
||||
import { useSlashCommandProcessor } from './slashCommandProcessor.js';
|
||||
import type {
|
||||
CommandContext,
|
||||
ConfirmShellCommandsActionReturn,
|
||||
SlashCommand,
|
||||
} from '../commands/types.js';
|
||||
import { CommandKind } from '../commands/types.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
|
||||
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
|
||||
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
|
||||
import {
|
||||
SlashCommandStatus,
|
||||
ToolConfirmationOutcome,
|
||||
makeFakeConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
function createTestCommand(
|
||||
overrides: Partial<SlashCommand>,
|
||||
kind: CommandKind = CommandKind.BUILT_IN,
|
||||
@@ -113,7 +114,7 @@ describe('useSlashCommandProcessor', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(vi.mocked(BuiltinCommandLoader) as Mock).mockClear();
|
||||
vi.mocked(BuiltinCommandLoader).mockClear();
|
||||
mockBuiltinLoadCommands.mockResolvedValue([]);
|
||||
mockFileLoadCommands.mockResolvedValue([]);
|
||||
mockMcpLoadCommands.mockResolvedValue([]);
|
||||
@@ -137,16 +138,19 @@ describe('useSlashCommandProcessor', () => {
|
||||
mockClearItems,
|
||||
mockLoadHistory,
|
||||
vi.fn(), // refreshStatic
|
||||
vi.fn(), // onDebugMessage
|
||||
mockOpenThemeDialog, // openThemeDialog
|
||||
mockOpenAuthDialog,
|
||||
vi.fn(), // openEditorDialog
|
||||
vi.fn(), // toggleCorgiMode
|
||||
mockSetQuittingMessages,
|
||||
vi.fn(), // openPrivacyNotice
|
||||
vi.fn(), // openSettingsDialog
|
||||
vi.fn(), // toggleVimEnabled
|
||||
setIsProcessing,
|
||||
vi.fn(), // setGeminiMdFileCount
|
||||
{
|
||||
openAuthDialog: mockOpenAuthDialog,
|
||||
openThemeDialog: mockOpenThemeDialog,
|
||||
openEditorDialog: vi.fn(),
|
||||
openPrivacyNotice: vi.fn(),
|
||||
openSettingsDialog: vi.fn(),
|
||||
quit: mockSetQuittingMessages,
|
||||
setDebugMessage: vi.fn(),
|
||||
toggleCorgiMode: vi.fn(),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -388,6 +392,12 @@ describe('useSlashCommandProcessor', () => {
|
||||
});
|
||||
|
||||
it('should handle "load_history" action', async () => {
|
||||
const mockClient = {
|
||||
setHistory: vi.fn(),
|
||||
stripThoughtsFromHistory: vi.fn(),
|
||||
} as unknown as GeminiClient;
|
||||
vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient);
|
||||
|
||||
const command = createTestCommand({
|
||||
name: 'load',
|
||||
action: vi.fn().mockResolvedValue({
|
||||
@@ -411,14 +421,11 @@ describe('useSlashCommandProcessor', () => {
|
||||
});
|
||||
|
||||
it('should strip thoughts when handling "load_history" action', async () => {
|
||||
const mockSetHistory = vi.fn();
|
||||
const mockGeminiClient = {
|
||||
setHistory: mockSetHistory,
|
||||
};
|
||||
vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGeminiClient as any,
|
||||
);
|
||||
const mockClient = {
|
||||
setHistory: vi.fn(),
|
||||
stripThoughtsFromHistory: vi.fn(),
|
||||
} as unknown as GeminiClient;
|
||||
vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient);
|
||||
|
||||
const historyWithThoughts = [
|
||||
{
|
||||
@@ -442,79 +449,28 @@ describe('useSlashCommandProcessor', () => {
|
||||
await result.current.handleSlashCommand('/loadwiththoughts');
|
||||
});
|
||||
|
||||
expect(mockSetHistory).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetHistory).toHaveBeenCalledWith(historyWithThoughts, {
|
||||
stripThoughts: true,
|
||||
});
|
||||
expect(mockClient.setHistory).toHaveBeenCalledTimes(1);
|
||||
expect(mockClient.stripThoughtsFromHistory).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
describe('with fake timers', () => {
|
||||
// This test needs to let the async `waitFor` complete with REAL timers
|
||||
// before switching to FAKE timers to test setTimeout.
|
||||
it('should handle a "quit" action', async () => {
|
||||
const quitAction = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ type: 'quit', messages: [] });
|
||||
const command = createTestCommand({
|
||||
name: 'exit',
|
||||
action: quitAction,
|
||||
});
|
||||
const result = setupProcessorHook([command]);
|
||||
it('should handle a "quit" action', async () => {
|
||||
const quitAction = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ type: 'quit', messages: ['bye'] });
|
||||
const command = createTestCommand({
|
||||
name: 'exit',
|
||||
action: quitAction,
|
||||
});
|
||||
const result = setupProcessorHook([command]);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.slashCommands).toHaveLength(1),
|
||||
);
|
||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
await result.current.handleSlashCommand('/exit');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
});
|
||||
|
||||
expect(mockSetQuittingMessages).toHaveBeenCalledWith([]);
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
await act(async () => {
|
||||
await result.current.handleSlashCommand('/exit');
|
||||
});
|
||||
|
||||
it('should call runExitCleanup when handling a "quit" action', async () => {
|
||||
const quitAction = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ type: 'quit', messages: [] });
|
||||
const command = createTestCommand({
|
||||
name: 'exit',
|
||||
action: quitAction,
|
||||
});
|
||||
const result = setupProcessorHook([command]);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.slashCommands).toHaveLength(1),
|
||||
);
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
await result.current.handleSlashCommand('/exit');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
});
|
||||
|
||||
expect(mockRunExitCleanup).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
expect(mockSetQuittingMessages).toHaveBeenCalledWith(['bye']);
|
||||
});
|
||||
|
||||
it('should handle "submit_prompt" action returned from a file-based command', async () => {
|
||||
const fileCommand = createTestCommand(
|
||||
{
|
||||
|
||||
@@ -20,12 +20,11 @@ import {
|
||||
IdeClient,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useSessionStats } from '../contexts/SessionContext.js';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
import type {
|
||||
Message,
|
||||
HistoryItemWithoutId,
|
||||
HistoryItem,
|
||||
SlashCommandProcessorResult,
|
||||
HistoryItem,
|
||||
} from '../types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
@@ -35,6 +34,17 @@ import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
|
||||
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
|
||||
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
|
||||
|
||||
interface SlashCommandProcessorActions {
|
||||
openAuthDialog: () => void;
|
||||
openThemeDialog: () => void;
|
||||
openEditorDialog: () => void;
|
||||
openPrivacyNotice: () => void;
|
||||
openSettingsDialog: () => void;
|
||||
quit: (messages: HistoryItem[]) => void;
|
||||
setDebugMessage: (message: string) => void;
|
||||
toggleCorgiMode: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to define and process slash commands (e.g., /help, /clear).
|
||||
*/
|
||||
@@ -45,17 +55,10 @@ export const useSlashCommandProcessor = (
|
||||
clearItems: UseHistoryManagerReturn['clearItems'],
|
||||
loadHistory: UseHistoryManagerReturn['loadHistory'],
|
||||
refreshStatic: () => void,
|
||||
onDebugMessage: (message: string) => void,
|
||||
openThemeDialog: () => void,
|
||||
openAuthDialog: () => void,
|
||||
openEditorDialog: () => void,
|
||||
toggleCorgiMode: () => void,
|
||||
setQuittingMessages: (message: HistoryItem[]) => void,
|
||||
openPrivacyNotice: () => void,
|
||||
openSettingsDialog: () => void,
|
||||
toggleVimEnabled: () => Promise<boolean>,
|
||||
setIsProcessing: (isProcessing: boolean) => void,
|
||||
setGeminiMdFileCount: (count: number) => void,
|
||||
actions: SlashCommandProcessorActions,
|
||||
) => {
|
||||
const session = useSessionStats();
|
||||
const [commands, setCommands] = useState<readonly SlashCommand[]>([]);
|
||||
@@ -177,10 +180,10 @@ export const useSlashCommandProcessor = (
|
||||
refreshStatic();
|
||||
},
|
||||
loadHistory,
|
||||
setDebugMessage: onDebugMessage,
|
||||
setDebugMessage: actions.setDebugMessage,
|
||||
pendingItem: pendingCompressionItem,
|
||||
setPendingItem: setPendingCompressionItem,
|
||||
toggleCorgiMode,
|
||||
toggleCorgiMode: actions.toggleCorgiMode,
|
||||
toggleVimEnabled,
|
||||
setGeminiMdFileCount,
|
||||
reloadCommands,
|
||||
@@ -200,10 +203,9 @@ export const useSlashCommandProcessor = (
|
||||
clearItems,
|
||||
refreshStatic,
|
||||
session.stats,
|
||||
onDebugMessage,
|
||||
actions,
|
||||
pendingCompressionItem,
|
||||
setPendingCompressionItem,
|
||||
toggleCorgiMode,
|
||||
toggleVimEnabled,
|
||||
sessionShellAllowlist,
|
||||
setGeminiMdFileCount,
|
||||
@@ -375,19 +377,19 @@ export const useSlashCommandProcessor = (
|
||||
case 'dialog':
|
||||
switch (result.dialog) {
|
||||
case 'auth':
|
||||
openAuthDialog();
|
||||
actions.openAuthDialog();
|
||||
return { type: 'handled' };
|
||||
case 'theme':
|
||||
openThemeDialog();
|
||||
actions.openThemeDialog();
|
||||
return { type: 'handled' };
|
||||
case 'editor':
|
||||
openEditorDialog();
|
||||
actions.openEditorDialog();
|
||||
return { type: 'handled' };
|
||||
case 'privacy':
|
||||
openPrivacyNotice();
|
||||
actions.openPrivacyNotice();
|
||||
return { type: 'handled' };
|
||||
case 'settings':
|
||||
openSettingsDialog();
|
||||
actions.openSettingsDialog();
|
||||
return { type: 'handled' };
|
||||
case 'help':
|
||||
return { type: 'handled' };
|
||||
@@ -399,9 +401,8 @@ export const useSlashCommandProcessor = (
|
||||
}
|
||||
}
|
||||
case 'load_history': {
|
||||
config
|
||||
?.getGeminiClient()
|
||||
?.setHistory(result.clientHistory, { stripThoughts: true });
|
||||
config?.getGeminiClient()?.setHistory(result.clientHistory);
|
||||
config?.getGeminiClient()?.stripThoughtsFromHistory();
|
||||
fullCommandContext.ui.clear();
|
||||
result.history.forEach((item, index) => {
|
||||
fullCommandContext.ui.addItem(item, index);
|
||||
@@ -409,11 +410,7 @@ export const useSlashCommandProcessor = (
|
||||
return { type: 'handled' };
|
||||
}
|
||||
case 'quit':
|
||||
setQuittingMessages(result.messages);
|
||||
setTimeout(async () => {
|
||||
await runExitCleanup();
|
||||
process.exit(0);
|
||||
}, 100);
|
||||
actions.quit(result.messages);
|
||||
return { type: 'handled' };
|
||||
|
||||
case 'submit_prompt':
|
||||
@@ -554,15 +551,10 @@ export const useSlashCommandProcessor = (
|
||||
[
|
||||
config,
|
||||
addItem,
|
||||
openAuthDialog,
|
||||
actions,
|
||||
commands,
|
||||
commandContext,
|
||||
addMessage,
|
||||
openThemeDialog,
|
||||
openPrivacyNotice,
|
||||
openEditorDialog,
|
||||
setQuittingMessages,
|
||||
openSettingsDialog,
|
||||
setShellConfirmationRequest,
|
||||
setSessionShellAllowlist,
|
||||
setIsProcessing,
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import { AuthType, type Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
clearCachedCredentialFile,
|
||||
getErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
|
||||
export const useAuthCommand = (
|
||||
settings: LoadedSettings,
|
||||
setAuthError: (error: string | null) => void,
|
||||
config: Config,
|
||||
) => {
|
||||
const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(
|
||||
settings.merged.security?.auth?.selectedType === undefined,
|
||||
);
|
||||
|
||||
const openAuthDialog = useCallback(() => {
|
||||
setIsAuthDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const authFlow = async () => {
|
||||
const authType = settings.merged.security?.auth?.selectedType;
|
||||
if (isAuthDialogOpen || !authType) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsAuthenticating(true);
|
||||
await config.refreshAuth(authType);
|
||||
console.log(`Authenticated via "${authType}".`);
|
||||
} catch (e) {
|
||||
setAuthError(`Failed to login. Message: ${getErrorMessage(e)}`);
|
||||
openAuthDialog();
|
||||
} finally {
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
};
|
||||
|
||||
void authFlow();
|
||||
}, [isAuthDialogOpen, settings, config, setAuthError, openAuthDialog]);
|
||||
|
||||
const handleAuthSelect = useCallback(
|
||||
async (authType: AuthType | undefined, scope: SettingScope) => {
|
||||
if (authType) {
|
||||
await clearCachedCredentialFile();
|
||||
|
||||
settings.setValue(scope, 'security.auth.selectedType', authType);
|
||||
if (
|
||||
authType === AuthType.LOGIN_WITH_GOOGLE &&
|
||||
config.isBrowserLaunchSuppressed()
|
||||
) {
|
||||
runExitCleanup();
|
||||
console.log(
|
||||
`
|
||||
----------------------------------------------------------------
|
||||
Logging in with Google... Please restart Gemini CLI to continue.
|
||||
----------------------------------------------------------------
|
||||
`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
setIsAuthDialogOpen(false);
|
||||
setAuthError(null);
|
||||
},
|
||||
[settings, setAuthError, config],
|
||||
);
|
||||
|
||||
const cancelAuthentication = useCallback(() => {
|
||||
setIsAuthenticating(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isAuthDialogOpen,
|
||||
openAuthDialog,
|
||||
handleAuthSelect,
|
||||
isAuthenticating,
|
||||
cancelAuthentication,
|
||||
};
|
||||
};
|
||||
@@ -26,12 +26,16 @@ describe('useFolderTrust', () => {
|
||||
let loadTrustedFoldersSpy: vi.SpyInstance;
|
||||
let isWorkspaceTrustedSpy: vi.SpyInstance;
|
||||
let onTrustChange: (isTrusted: boolean | undefined) => void;
|
||||
let refreshStatic: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSettings = {
|
||||
merged: {
|
||||
folderTrustFeature: true,
|
||||
folderTrust: undefined,
|
||||
security: {
|
||||
folderTrust: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
} as unknown as LoadedSettings;
|
||||
@@ -46,6 +50,7 @@ describe('useFolderTrust', () => {
|
||||
isWorkspaceTrustedSpy = vi.spyOn(trustedFolders, 'isWorkspaceTrusted');
|
||||
(process.cwd as vi.Mock).mockReturnValue('/test/path');
|
||||
onTrustChange = vi.fn();
|
||||
refreshStatic = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -55,7 +60,7 @@ describe('useFolderTrust', () => {
|
||||
it('should not open dialog when folder is already trusted', () => {
|
||||
isWorkspaceTrustedSpy.mockReturnValue(true);
|
||||
const { result } = renderHook(() =>
|
||||
useFolderTrust(mockSettings, onTrustChange),
|
||||
useFolderTrust(mockSettings, onTrustChange, refreshStatic),
|
||||
);
|
||||
expect(result.current.isFolderTrustDialogOpen).toBe(false);
|
||||
expect(onTrustChange).toHaveBeenCalledWith(true);
|
||||
@@ -64,7 +69,7 @@ describe('useFolderTrust', () => {
|
||||
it('should not open dialog when folder is already untrusted', () => {
|
||||
isWorkspaceTrustedSpy.mockReturnValue(false);
|
||||
const { result } = renderHook(() =>
|
||||
useFolderTrust(mockSettings, onTrustChange),
|
||||
useFolderTrust(mockSettings, onTrustChange, refreshStatic),
|
||||
);
|
||||
expect(result.current.isFolderTrustDialogOpen).toBe(false);
|
||||
expect(onTrustChange).toHaveBeenCalledWith(false);
|
||||
@@ -73,7 +78,7 @@ describe('useFolderTrust', () => {
|
||||
it('should open dialog when folder trust is undefined', () => {
|
||||
isWorkspaceTrustedSpy.mockReturnValue(undefined);
|
||||
const { result } = renderHook(() =>
|
||||
useFolderTrust(mockSettings, onTrustChange),
|
||||
useFolderTrust(mockSettings, onTrustChange, refreshStatic),
|
||||
);
|
||||
expect(result.current.isFolderTrustDialogOpen).toBe(true);
|
||||
expect(onTrustChange).toHaveBeenCalledWith(undefined);
|
||||
@@ -84,7 +89,7 @@ describe('useFolderTrust', () => {
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValueOnce(true);
|
||||
const { result } = renderHook(() =>
|
||||
useFolderTrust(mockSettings, onTrustChange),
|
||||
useFolderTrust(mockSettings, onTrustChange, refreshStatic),
|
||||
);
|
||||
|
||||
isWorkspaceTrustedSpy.mockReturnValue(true);
|
||||
@@ -106,7 +111,7 @@ describe('useFolderTrust', () => {
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValueOnce(true);
|
||||
const { result } = renderHook(() =>
|
||||
useFolderTrust(mockSettings, onTrustChange),
|
||||
useFolderTrust(mockSettings, onTrustChange, refreshStatic),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
@@ -126,7 +131,7 @@ describe('useFolderTrust', () => {
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValueOnce(false);
|
||||
const { result } = renderHook(() =>
|
||||
useFolderTrust(mockSettings, onTrustChange),
|
||||
useFolderTrust(mockSettings, onTrustChange, refreshStatic),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
@@ -145,7 +150,7 @@ describe('useFolderTrust', () => {
|
||||
it('should do nothing for default choice', () => {
|
||||
isWorkspaceTrustedSpy.mockReturnValue(undefined);
|
||||
const { result } = renderHook(() =>
|
||||
useFolderTrust(mockSettings, onTrustChange),
|
||||
useFolderTrust(mockSettings, onTrustChange, refreshStatic),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
@@ -163,7 +168,7 @@ describe('useFolderTrust', () => {
|
||||
it('should set isRestarting to true when trust status changes from false to true', () => {
|
||||
isWorkspaceTrustedSpy.mockReturnValueOnce(false).mockReturnValueOnce(true); // Initially untrusted, then trusted
|
||||
const { result } = renderHook(() =>
|
||||
useFolderTrust(mockSettings, onTrustChange),
|
||||
useFolderTrust(mockSettings, onTrustChange, refreshStatic),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
@@ -179,7 +184,7 @@ describe('useFolderTrust', () => {
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValueOnce(true); // Initially undefined, then trust
|
||||
const { result } = renderHook(() =>
|
||||
useFolderTrust(mockSettings, onTrustChange),
|
||||
useFolderTrust(mockSettings, onTrustChange, refreshStatic),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
@@ -189,4 +194,26 @@ describe('useFolderTrust', () => {
|
||||
expect(result.current.isRestarting).toBe(false);
|
||||
expect(result.current.isFolderTrustDialogOpen).toBe(false); // Dialog should close
|
||||
});
|
||||
|
||||
it('should call refreshStatic when dialog opens and closes', () => {
|
||||
isWorkspaceTrustedSpy.mockReturnValue(undefined);
|
||||
const { result } = renderHook(() =>
|
||||
useFolderTrust(mockSettings, onTrustChange, refreshStatic),
|
||||
);
|
||||
|
||||
// The hook runs, isFolderTrustDialogOpen becomes true, useEffect triggers.
|
||||
// It's called once on mount, and once when the dialog state changes.
|
||||
expect(refreshStatic).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.isFolderTrustDialogOpen).toBe(true);
|
||||
|
||||
// Now, simulate closing the dialog
|
||||
isWorkspaceTrustedSpy.mockReturnValue(true); // So the state update works
|
||||
act(() => {
|
||||
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER);
|
||||
});
|
||||
|
||||
// The state isFolderTrustDialogOpen becomes false, useEffect triggers again
|
||||
expect(refreshStatic).toHaveBeenCalledTimes(3);
|
||||
expect(result.current.isFolderTrustDialogOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { Settings, LoadedSettings } from '../../config/settings.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { FolderTrustChoice } from '../components/FolderTrustDialog.js';
|
||||
import {
|
||||
loadTrustedFolders,
|
||||
@@ -17,6 +17,7 @@ import * as process from 'node:process';
|
||||
export const useFolderTrust = (
|
||||
settings: LoadedSettings,
|
||||
onTrustChange: (isTrusted: boolean | undefined) => void,
|
||||
refreshStatic: () => void,
|
||||
) => {
|
||||
const [isTrusted, setIsTrusted] = useState<boolean | undefined>(undefined);
|
||||
const [isFolderTrustDialogOpen, setIsFolderTrustDialogOpen] = useState(false);
|
||||
@@ -25,17 +26,17 @@ export const useFolderTrust = (
|
||||
const folderTrust = settings.merged.security?.folderTrust?.enabled;
|
||||
|
||||
useEffect(() => {
|
||||
const trusted = isWorkspaceTrusted({
|
||||
security: {
|
||||
folderTrust: {
|
||||
enabled: folderTrust,
|
||||
},
|
||||
},
|
||||
} as Settings);
|
||||
const trusted = isWorkspaceTrusted(settings.merged);
|
||||
setIsTrusted(trusted);
|
||||
setIsFolderTrustDialogOpen(trusted === undefined);
|
||||
onTrustChange(trusted);
|
||||
}, [onTrustChange, folderTrust]);
|
||||
}, [folderTrust, onTrustChange, settings.merged]);
|
||||
|
||||
useEffect(() => {
|
||||
// When the folder trust dialog is about to open/close, we need to force a refresh
|
||||
// of the static content to ensure the Tips are hidden/shown correctly.
|
||||
refreshStatic();
|
||||
}, [isFolderTrustDialogOpen, refreshStatic]);
|
||||
|
||||
const handleFolderTrustSelect = useCallback(
|
||||
(choice: FolderTrustChoice) => {
|
||||
@@ -60,13 +61,13 @@ export const useFolderTrust = (
|
||||
}
|
||||
|
||||
trustedFolders.setValue(cwd, trustLevel);
|
||||
const newIsTrusted =
|
||||
const currentIsTrusted =
|
||||
trustLevel === TrustLevel.TRUST_FOLDER ||
|
||||
trustLevel === TrustLevel.TRUST_PARENT;
|
||||
setIsTrusted(newIsTrusted);
|
||||
onTrustChange(newIsTrusted);
|
||||
setIsTrusted(currentIsTrusted);
|
||||
onTrustChange(currentIsTrusted);
|
||||
|
||||
const needsRestart = wasTrusted !== newIsTrusted;
|
||||
const needsRestart = wasTrusted !== currentIsTrusted;
|
||||
if (needsRestart) {
|
||||
setIsRestarting(true);
|
||||
setIsFolderTrustDialogOpen(true);
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
parseAndFormatApiError,
|
||||
getCodeAssistServer,
|
||||
UserTierId,
|
||||
promptIdContext,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type Part, type PartListUnion, FinishReason } from '@google/genai';
|
||||
import type {
|
||||
@@ -95,7 +96,7 @@ export const useGeminiStream = (
|
||||
) => Promise<SlashCommandProcessorResult | false>,
|
||||
shellModeActive: boolean,
|
||||
getPreferredEditor: () => EditorType | undefined,
|
||||
onAuthError: () => void,
|
||||
onAuthError: (error: string) => void,
|
||||
performMemoryRefresh: () => Promise<void>,
|
||||
modelSwitchedFromQuotaError: boolean,
|
||||
setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
@@ -705,71 +706,72 @@ export const useGeminiStream = (
|
||||
if (!prompt_id) {
|
||||
prompt_id = config.getSessionId() + '########' + getPromptCount();
|
||||
}
|
||||
|
||||
const { queryToSend, shouldProceed } = await prepareQueryForGemini(
|
||||
query,
|
||||
userMessageTimestamp,
|
||||
abortSignal,
|
||||
prompt_id!,
|
||||
);
|
||||
|
||||
if (!shouldProceed || queryToSend === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options?.isContinuation) {
|
||||
startNewPrompt();
|
||||
setThought(null); // Reset thought when starting a new prompt
|
||||
}
|
||||
|
||||
setIsResponding(true);
|
||||
setInitError(null);
|
||||
|
||||
try {
|
||||
const stream = geminiClient.sendMessageStream(
|
||||
queryToSend,
|
||||
abortSignal,
|
||||
prompt_id!,
|
||||
);
|
||||
const processingStatus = await processGeminiStreamEvents(
|
||||
stream,
|
||||
return promptIdContext.run(prompt_id, async () => {
|
||||
const { queryToSend, shouldProceed } = await prepareQueryForGemini(
|
||||
query,
|
||||
userMessageTimestamp,
|
||||
abortSignal,
|
||||
prompt_id,
|
||||
);
|
||||
|
||||
if (processingStatus === StreamProcessingStatus.UserCancelled) {
|
||||
if (!shouldProceed || queryToSend === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingHistoryItemRef.current) {
|
||||
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
||||
setPendingHistoryItem(null);
|
||||
if (!options?.isContinuation) {
|
||||
startNewPrompt();
|
||||
setThought(null); // Reset thought when starting a new prompt
|
||||
}
|
||||
if (loopDetectedRef.current) {
|
||||
loopDetectedRef.current = false;
|
||||
handleLoopDetectedEvent();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
onAuthError();
|
||||
} else if (!isNodeError(error) || error.name !== 'AbortError') {
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: parseAndFormatApiError(
|
||||
getErrorMessage(error) || 'Unknown error',
|
||||
config.getContentGeneratorConfig()?.authType,
|
||||
undefined,
|
||||
config.getModel(),
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
),
|
||||
},
|
||||
userMessageTimestamp,
|
||||
|
||||
setIsResponding(true);
|
||||
setInitError(null);
|
||||
|
||||
try {
|
||||
const stream = geminiClient.sendMessageStream(
|
||||
queryToSend,
|
||||
abortSignal,
|
||||
prompt_id,
|
||||
);
|
||||
const processingStatus = await processGeminiStreamEvents(
|
||||
stream,
|
||||
userMessageTimestamp,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
if (processingStatus === StreamProcessingStatus.UserCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingHistoryItemRef.current) {
|
||||
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
||||
setPendingHistoryItem(null);
|
||||
}
|
||||
if (loopDetectedRef.current) {
|
||||
loopDetectedRef.current = false;
|
||||
handleLoopDetectedEvent();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
onAuthError('Session expired or is unauthorized.');
|
||||
} else if (!isNodeError(error) || error.name !== 'AbortError') {
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: parseAndFormatApiError(
|
||||
getErrorMessage(error) || 'Unknown error',
|
||||
config.getContentGeneratorConfig()?.authType,
|
||||
undefined,
|
||||
config.getModel(),
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
),
|
||||
},
|
||||
userMessageTimestamp,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsResponding(false);
|
||||
}
|
||||
} finally {
|
||||
setIsResponding(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
[
|
||||
streamingState,
|
||||
|
||||
@@ -54,6 +54,7 @@ vi.mock('readline', () => {
|
||||
|
||||
class MockStdin extends EventEmitter {
|
||||
isTTY = true;
|
||||
isRaw = false;
|
||||
setRawMode = vi.fn();
|
||||
on = this.addListener;
|
||||
removeListener = this.removeListener;
|
||||
|
||||
@@ -25,6 +25,7 @@ describe('useMessageQueue', () => {
|
||||
it('should initialize with empty queue', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMessageQueue({
|
||||
isConfigInitialized: true,
|
||||
streamingState: StreamingState.Idle,
|
||||
submitQuery: mockSubmitQuery,
|
||||
}),
|
||||
@@ -37,6 +38,7 @@ describe('useMessageQueue', () => {
|
||||
it('should add messages to queue', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMessageQueue({
|
||||
isConfigInitialized: true,
|
||||
streamingState: StreamingState.Responding,
|
||||
submitQuery: mockSubmitQuery,
|
||||
}),
|
||||
@@ -56,6 +58,7 @@ describe('useMessageQueue', () => {
|
||||
it('should filter out empty messages', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMessageQueue({
|
||||
isConfigInitialized: true,
|
||||
streamingState: StreamingState.Responding,
|
||||
submitQuery: mockSubmitQuery,
|
||||
}),
|
||||
@@ -77,6 +80,7 @@ describe('useMessageQueue', () => {
|
||||
it('should clear queue', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMessageQueue({
|
||||
isConfigInitialized: true,
|
||||
streamingState: StreamingState.Responding,
|
||||
submitQuery: mockSubmitQuery,
|
||||
}),
|
||||
@@ -98,6 +102,7 @@ describe('useMessageQueue', () => {
|
||||
it('should return queued messages as text with double newlines', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMessageQueue({
|
||||
isConfigInitialized: true,
|
||||
streamingState: StreamingState.Responding,
|
||||
submitQuery: mockSubmitQuery,
|
||||
}),
|
||||
@@ -118,6 +123,7 @@ describe('useMessageQueue', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ streamingState }) =>
|
||||
useMessageQueue({
|
||||
isConfigInitialized: true,
|
||||
streamingState,
|
||||
submitQuery: mockSubmitQuery,
|
||||
}),
|
||||
@@ -145,6 +151,7 @@ describe('useMessageQueue', () => {
|
||||
const { rerender } = renderHook(
|
||||
({ streamingState }) =>
|
||||
useMessageQueue({
|
||||
isConfigInitialized: true,
|
||||
streamingState,
|
||||
submitQuery: mockSubmitQuery,
|
||||
}),
|
||||
@@ -163,6 +170,7 @@ describe('useMessageQueue', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ streamingState }) =>
|
||||
useMessageQueue({
|
||||
isConfigInitialized: true,
|
||||
streamingState,
|
||||
submitQuery: mockSubmitQuery,
|
||||
}),
|
||||
@@ -187,6 +195,7 @@ describe('useMessageQueue', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ streamingState }) =>
|
||||
useMessageQueue({
|
||||
isConfigInitialized: true,
|
||||
streamingState,
|
||||
submitQuery: mockSubmitQuery,
|
||||
}),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { StreamingState } from '../types.js';
|
||||
|
||||
export interface UseMessageQueueOptions {
|
||||
isConfigInitialized: boolean;
|
||||
streamingState: StreamingState;
|
||||
submitQuery: (query: string) => void;
|
||||
}
|
||||
@@ -25,6 +26,7 @@ export interface UseMessageQueueReturn {
|
||||
* sends them when streaming completes.
|
||||
*/
|
||||
export function useMessageQueue({
|
||||
isConfigInitialized,
|
||||
streamingState,
|
||||
submitQuery,
|
||||
}: UseMessageQueueOptions): UseMessageQueueReturn {
|
||||
@@ -51,14 +53,18 @@ export function useMessageQueue({
|
||||
|
||||
// Process queued messages when streaming becomes idle
|
||||
useEffect(() => {
|
||||
if (streamingState === StreamingState.Idle && messageQueue.length > 0) {
|
||||
if (
|
||||
isConfigInitialized &&
|
||||
streamingState === StreamingState.Idle &&
|
||||
messageQueue.length > 0
|
||||
) {
|
||||
// Combine all messages with double newlines for clarity
|
||||
const combinedMessage = messageQueue.join('\n\n');
|
||||
// Clear the queue and submit
|
||||
setMessageQueue([]);
|
||||
submitQuery(combinedMessage);
|
||||
}
|
||||
}, [streamingState, messageQueue, submitQuery]);
|
||||
}, [isConfigInitialized, streamingState, messageQueue, submitQuery]);
|
||||
|
||||
return {
|
||||
messageQueue,
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
vi,
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import {
|
||||
type Config,
|
||||
type FallbackModelHandler,
|
||||
UserTierId,
|
||||
AuthType,
|
||||
isGenericQuotaExceededError,
|
||||
isProQuotaExceededError,
|
||||
makeFakeConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useQuotaAndFallback } from './useQuotaAndFallback.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import { AuthState, MessageType } from '../types.js';
|
||||
|
||||
// Mock the error checking functions from the core package to control test scenarios
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...original,
|
||||
isGenericQuotaExceededError: vi.fn(),
|
||||
isProQuotaExceededError: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Use a type alias for SpyInstance as it's not directly exported
|
||||
type SpyInstance = ReturnType<typeof vi.spyOn>;
|
||||
|
||||
describe('useQuotaAndFallback', () => {
|
||||
let mockConfig: Config;
|
||||
let mockHistoryManager: UseHistoryManagerReturn;
|
||||
let mockSetAuthState: Mock;
|
||||
let mockSetModelSwitchedFromQuotaError: Mock;
|
||||
let setFallbackHandlerSpy: SpyInstance;
|
||||
|
||||
const mockedIsGenericQuotaExceededError = isGenericQuotaExceededError as Mock;
|
||||
const mockedIsProQuotaExceededError = isProQuotaExceededError as Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = makeFakeConfig();
|
||||
|
||||
// Spy on the method that requires the private field and mock its return.
|
||||
// This is cleaner than modifying the config class for tests.
|
||||
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
model: 'gemini-pro',
|
||||
authType: AuthType.LOGIN_WITH_GOOGLE,
|
||||
});
|
||||
|
||||
mockHistoryManager = {
|
||||
addItem: vi.fn(),
|
||||
history: [],
|
||||
updateItem: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
};
|
||||
mockSetAuthState = vi.fn();
|
||||
mockSetModelSwitchedFromQuotaError = vi.fn();
|
||||
|
||||
setFallbackHandlerSpy = vi.spyOn(mockConfig, 'setFallbackModelHandler');
|
||||
vi.spyOn(mockConfig, 'setQuotaErrorOccurred');
|
||||
|
||||
mockedIsGenericQuotaExceededError.mockReturnValue(false);
|
||||
mockedIsProQuotaExceededError.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should register a fallback handler on initialization', () => {
|
||||
renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setAuthState: mockSetAuthState,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(setFallbackHandlerSpy).toHaveBeenCalledTimes(1);
|
||||
expect(setFallbackHandlerSpy.mock.calls[0][0]).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
describe('Fallback Handler Logic', () => {
|
||||
// Helper function to render the hook and extract the registered handler
|
||||
const getRegisteredHandler = (
|
||||
userTier: UserTierId = UserTierId.FREE,
|
||||
): FallbackModelHandler => {
|
||||
renderHook(
|
||||
(props) =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: props.userTier,
|
||||
setAuthState: mockSetAuthState,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
{ initialProps: { userTier } },
|
||||
);
|
||||
return setFallbackHandlerSpy.mock.calls[0][0] as FallbackModelHandler;
|
||||
};
|
||||
|
||||
it('should return null and take no action if already in fallback mode', async () => {
|
||||
vi.spyOn(mockConfig, 'isInFallbackMode').mockReturnValue(true);
|
||||
const handler = getRegisteredHandler();
|
||||
const result = await handler('gemini-pro', 'gemini-flash', new Error());
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null and take no action if authType is not LOGIN_WITH_GOOGLE', async () => {
|
||||
// Override the default mock from beforeEach for this specific test
|
||||
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
model: 'gemini-pro',
|
||||
authType: AuthType.USE_GEMINI,
|
||||
});
|
||||
|
||||
const handler = getRegisteredHandler();
|
||||
const result = await handler('gemini-pro', 'gemini-flash', new Error());
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('Automatic Fallback Scenarios', () => {
|
||||
const testCases = [
|
||||
{
|
||||
errorType: 'generic',
|
||||
tier: UserTierId.FREE,
|
||||
expectedMessageSnippets: [
|
||||
'Automatically switching from model-A to model-B',
|
||||
'upgrade to a Gemini Code Assist Standard or Enterprise plan',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorType: 'generic',
|
||||
tier: UserTierId.STANDARD, // Paid tier
|
||||
expectedMessageSnippets: [
|
||||
'Automatically switching from model-A to model-B',
|
||||
'switch to using a paid API key from AI Studio',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorType: 'other',
|
||||
tier: UserTierId.FREE,
|
||||
expectedMessageSnippets: [
|
||||
'Automatically switching from model-A to model-B for faster responses',
|
||||
'upgrade to a Gemini Code Assist Standard or Enterprise plan',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorType: 'other',
|
||||
tier: UserTierId.LEGACY, // Paid tier
|
||||
expectedMessageSnippets: [
|
||||
'Automatically switching from model-A to model-B for faster responses',
|
||||
'switch to using a paid API key from AI Studio',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { errorType, tier, expectedMessageSnippets } of testCases) {
|
||||
it(`should handle ${errorType} error for ${tier} tier correctly`, async () => {
|
||||
mockedIsGenericQuotaExceededError.mockReturnValue(
|
||||
errorType === 'generic',
|
||||
);
|
||||
|
||||
const handler = getRegisteredHandler(tier);
|
||||
const result = await handler(
|
||||
'model-A',
|
||||
'model-B',
|
||||
new Error('quota exceeded'),
|
||||
);
|
||||
|
||||
// Automatic fallbacks should return 'stop'
|
||||
expect(result).toBe('stop');
|
||||
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: MessageType.INFO }),
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
const message = (mockHistoryManager.addItem as Mock).mock.calls[0][0]
|
||||
.text;
|
||||
for (const snippet of expectedMessageSnippets) {
|
||||
expect(message).toContain(snippet);
|
||||
}
|
||||
|
||||
expect(mockSetModelSwitchedFromQuotaError).toHaveBeenCalledWith(true);
|
||||
expect(mockConfig.setQuotaErrorOccurred).toHaveBeenCalledWith(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('Interactive Fallback (Pro Quota Error)', () => {
|
||||
beforeEach(() => {
|
||||
mockedIsProQuotaExceededError.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should set an interactive request and wait for user choice', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setAuthState: mockSetAuthState,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
|
||||
// Call the handler but do not await it, to check the intermediate state
|
||||
const promise = handler(
|
||||
'gemini-pro',
|
||||
'gemini-flash',
|
||||
new Error('pro quota'),
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
|
||||
// The hook should now have a pending request for the UI to handle
|
||||
expect(result.current.proQuotaRequest).not.toBeNull();
|
||||
expect(result.current.proQuotaRequest?.failedModel).toBe('gemini-pro');
|
||||
|
||||
// Simulate the user choosing to continue with the fallback model
|
||||
act(() => {
|
||||
result.current.handleProQuotaChoice('continue');
|
||||
});
|
||||
|
||||
// The original promise from the handler should now resolve
|
||||
const intent = await promise;
|
||||
expect(intent).toBe('retry');
|
||||
|
||||
// The pending request should be cleared from the state
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle race conditions by stopping subsequent requests', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setAuthState: mockSetAuthState,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
|
||||
const promise1 = handler(
|
||||
'gemini-pro',
|
||||
'gemini-flash',
|
||||
new Error('pro quota 1'),
|
||||
);
|
||||
await act(async () => {});
|
||||
|
||||
const firstRequest = result.current.proQuotaRequest;
|
||||
expect(firstRequest).not.toBeNull();
|
||||
|
||||
const result2 = await handler(
|
||||
'gemini-pro',
|
||||
'gemini-flash',
|
||||
new Error('pro quota 2'),
|
||||
);
|
||||
|
||||
// The lock should have stopped the second request
|
||||
expect(result2).toBe('stop');
|
||||
expect(result.current.proQuotaRequest).toBe(firstRequest);
|
||||
|
||||
act(() => {
|
||||
result.current.handleProQuotaChoice('continue');
|
||||
});
|
||||
|
||||
const intent1 = await promise1;
|
||||
expect(intent1).toBe('retry');
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleProQuotaChoice', () => {
|
||||
beforeEach(() => {
|
||||
mockedIsProQuotaExceededError.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should do nothing if there is no pending pro quota request', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setAuthState: mockSetAuthState,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleProQuotaChoice('auth');
|
||||
});
|
||||
|
||||
expect(mockSetAuthState).not.toHaveBeenCalled();
|
||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should resolve intent to "auth" and trigger auth state update', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setAuthState: mockSetAuthState,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
const promise = handler(
|
||||
'gemini-pro',
|
||||
'gemini-flash',
|
||||
new Error('pro quota'),
|
||||
);
|
||||
await act(async () => {}); // Allow state to update
|
||||
|
||||
act(() => {
|
||||
result.current.handleProQuotaChoice('auth');
|
||||
});
|
||||
|
||||
const intent = await promise;
|
||||
expect(intent).toBe('auth');
|
||||
expect(mockSetAuthState).toHaveBeenCalledWith(AuthState.Updating);
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
});
|
||||
|
||||
it('should resolve intent to "retry" and add info message on continue', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useQuotaAndFallback({
|
||||
config: mockConfig,
|
||||
historyManager: mockHistoryManager,
|
||||
userTier: UserTierId.FREE,
|
||||
setAuthState: mockSetAuthState,
|
||||
setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError,
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = setFallbackHandlerSpy.mock
|
||||
.calls[0][0] as FallbackModelHandler;
|
||||
// The first `addItem` call is for the initial quota error message
|
||||
const promise = handler(
|
||||
'gemini-pro',
|
||||
'gemini-flash',
|
||||
new Error('pro quota'),
|
||||
);
|
||||
await act(async () => {}); // Allow state to update
|
||||
|
||||
act(() => {
|
||||
result.current.handleProQuotaChoice('continue');
|
||||
});
|
||||
|
||||
const intent = await promise;
|
||||
expect(intent).toBe('retry');
|
||||
expect(result.current.proQuotaRequest).toBeNull();
|
||||
|
||||
// Check for the second "Switched to fallback model" message
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2);
|
||||
const lastCall = (mockHistoryManager.addItem as Mock).mock.calls[1][0];
|
||||
expect(lastCall.type).toBe(MessageType.INFO);
|
||||
expect(lastCall.text).toContain('Switched to fallback model.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
AuthType,
|
||||
type Config,
|
||||
type FallbackModelHandler,
|
||||
type FallbackIntent,
|
||||
isGenericQuotaExceededError,
|
||||
isProQuotaExceededError,
|
||||
UserTierId,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { type UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import { AuthState, MessageType } from '../types.js';
|
||||
import { type ProQuotaDialogRequest } from '../contexts/UIStateContext.js';
|
||||
|
||||
interface UseQuotaAndFallbackArgs {
|
||||
config: Config;
|
||||
historyManager: UseHistoryManagerReturn;
|
||||
userTier: UserTierId | undefined;
|
||||
setAuthState: (state: AuthState) => void;
|
||||
setModelSwitchedFromQuotaError: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function useQuotaAndFallback({
|
||||
config,
|
||||
historyManager,
|
||||
userTier,
|
||||
setAuthState,
|
||||
setModelSwitchedFromQuotaError,
|
||||
}: UseQuotaAndFallbackArgs) {
|
||||
const [proQuotaRequest, setProQuotaRequest] =
|
||||
useState<ProQuotaDialogRequest | null>(null);
|
||||
const isDialogPending = useRef(false);
|
||||
|
||||
// Set up Flash fallback handler
|
||||
useEffect(() => {
|
||||
const fallbackHandler: FallbackModelHandler = async (
|
||||
failedModel,
|
||||
fallbackModel,
|
||||
error,
|
||||
): Promise<FallbackIntent | null> => {
|
||||
if (config.isInFallbackMode()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fallbacks are currently only handled for OAuth users.
|
||||
const contentGeneratorConfig = config.getContentGeneratorConfig();
|
||||
if (
|
||||
!contentGeneratorConfig ||
|
||||
contentGeneratorConfig.authType !== AuthType.LOGIN_WITH_GOOGLE
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use actual user tier if available; otherwise, default to FREE tier behavior (safe default)
|
||||
const isPaidTier =
|
||||
userTier === UserTierId.LEGACY || userTier === UserTierId.STANDARD;
|
||||
|
||||
let message: string;
|
||||
|
||||
if (error && isProQuotaExceededError(error)) {
|
||||
// Pro Quota specific messages (Interactive)
|
||||
if (isPaidTier) {
|
||||
message = `⚡ You have reached your daily ${failedModel} quota limit.
|
||||
⚡ You can choose to authenticate with a paid API key or continue with the fallback model.
|
||||
⚡ To continue accessing the ${failedModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
|
||||
} else {
|
||||
message = `⚡ You have reached your daily ${failedModel} quota limit.
|
||||
⚡ You can choose to authenticate with a paid API key or continue with the fallback model.
|
||||
⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
|
||||
⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
|
||||
⚡ You can switch authentication methods by typing /auth`;
|
||||
}
|
||||
} else if (error && isGenericQuotaExceededError(error)) {
|
||||
// Generic Quota (Automatic fallback)
|
||||
const actionMessage = `⚡ You have reached your daily quota limit.\n⚡ Automatically switching from ${failedModel} to ${fallbackModel} for the remainder of this session.`;
|
||||
|
||||
if (isPaidTier) {
|
||||
message = `${actionMessage}
|
||||
⚡ To continue accessing the ${failedModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
|
||||
} else {
|
||||
message = `${actionMessage}
|
||||
⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
|
||||
⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
|
||||
⚡ You can switch authentication methods by typing /auth`;
|
||||
}
|
||||
} else {
|
||||
// Consecutive 429s or other errors (Automatic fallback)
|
||||
const actionMessage = `⚡ Automatically switching from ${failedModel} to ${fallbackModel} for faster responses for the remainder of this session.`;
|
||||
|
||||
if (isPaidTier) {
|
||||
message = `${actionMessage}
|
||||
⚡ Possible reasons for this are that you have received multiple consecutive capacity errors or you have reached your daily ${failedModel} quota limit
|
||||
⚡ To continue accessing the ${failedModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
|
||||
} else {
|
||||
message = `${actionMessage}
|
||||
⚡ Possible reasons for this are that you have received multiple consecutive capacity errors or you have reached your daily ${failedModel} quota limit
|
||||
⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
|
||||
⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
|
||||
⚡ You can switch authentication methods by typing /auth`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add message to UI history
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: message,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
setModelSwitchedFromQuotaError(true);
|
||||
config.setQuotaErrorOccurred(true);
|
||||
|
||||
// Interactive Fallback for Pro quota
|
||||
if (error && isProQuotaExceededError(error)) {
|
||||
if (isDialogPending.current) {
|
||||
return 'stop'; // A dialog is already active, so just stop this request.
|
||||
}
|
||||
isDialogPending.current = true;
|
||||
|
||||
const intent: FallbackIntent = await new Promise<FallbackIntent>(
|
||||
(resolve) => {
|
||||
setProQuotaRequest({
|
||||
failedModel,
|
||||
fallbackModel,
|
||||
resolve,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return intent;
|
||||
}
|
||||
|
||||
return 'stop';
|
||||
};
|
||||
|
||||
config.setFallbackModelHandler(fallbackHandler);
|
||||
}, [config, historyManager, userTier, setModelSwitchedFromQuotaError]);
|
||||
|
||||
const handleProQuotaChoice = useCallback(
|
||||
(choice: 'auth' | 'continue') => {
|
||||
if (!proQuotaRequest) return;
|
||||
|
||||
const intent: FallbackIntent = choice === 'auth' ? 'auth' : 'retry';
|
||||
proQuotaRequest.resolve(intent);
|
||||
setProQuotaRequest(null);
|
||||
isDialogPending.current = false; // Reset the flag here
|
||||
|
||||
if (choice === 'auth') {
|
||||
setAuthState(AuthState.Updating);
|
||||
} else {
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Switched to fallback model. Tip: Press Ctrl+P (or Up Arrow) to recall your previous prompt and submit it again if you wish.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
},
|
||||
[proQuotaRequest, setAuthState, historyManager],
|
||||
);
|
||||
|
||||
return {
|
||||
proQuotaRequest,
|
||||
handleProQuotaChoice,
|
||||
};
|
||||
}
|
||||
@@ -140,7 +140,8 @@ export function useReactToolScheduler(
|
||||
getPreferredEditor,
|
||||
config,
|
||||
onEditorClose,
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any),
|
||||
[
|
||||
config,
|
||||
outputUpdateHandler,
|
||||
@@ -247,6 +248,7 @@ export function mapToDisplay(
|
||||
status: mapCoreStatusToDisplayStatus(trackedCall.status),
|
||||
resultDisplay: trackedCall.response.resultDisplay,
|
||||
confirmationDetails: undefined,
|
||||
outputFile: trackedCall.response.outputFile,
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user