Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 56d5925a34 | |||
| b2d6dc4e32 | |||
| ac95282758 | |||
| efeb9f7e7b | |||
| 5a65610fa6 | |||
| 447a854ad9 | |||
| b58d79c517 | |||
| daf3691841 | |||
| 517961b2eb | |||
| ec0161ad37 | |||
| cdf077da56 | |||
| 99e5164c82 | |||
| d0ebc81c28 | |||
| c67817f1a9 | |||
| c7d44e339b | |||
| b6fe265d0c | |||
| 81006a7f82 | |||
| 6055c47079 | |||
| 6c119110e4 | |||
| 4c533b1249 | |||
| 4a3d9414ef | |||
| 0df9498674 | |||
| 8f391585ab | |||
| e7b6326cfa | |||
| 1a70fdd364 | |||
| b316fcc44d | |||
| d1dc4902fd | |||
| d3766875f8 | |||
| 28935d1e6b | |||
| 244a608186 |
@@ -0,0 +1,69 @@
|
||||
name: 'Evals: PR Guidance'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'packages/core/src/**/*.ts'
|
||||
- '!**/*.test.ts'
|
||||
- '!**/*.test.tsx'
|
||||
|
||||
permissions:
|
||||
pull-requests: 'write'
|
||||
contents: 'read'
|
||||
|
||||
jobs:
|
||||
provide-guidance:
|
||||
name: 'Model Steering Guidance'
|
||||
runs-on: 'ubuntu-latest'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Set up Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Detect Steering Changes'
|
||||
id: 'detect'
|
||||
run: |
|
||||
STEERING_DETECTED=$(node scripts/changed_prompt.js --steering-only)
|
||||
echo "STEERING_DETECTED=$STEERING_DETECTED" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Analyze PR Content'
|
||||
if: "steps.detect.outputs.STEERING_DETECTED == 'true'"
|
||||
id: 'analysis'
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
run: |
|
||||
# Check for behavioral eval changes
|
||||
EVAL_CHANGES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep "^evals/" || true)
|
||||
if [ -z "$EVAL_CHANGES" ]; then
|
||||
echo "MISSING_EVALS=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Check if user is a maintainer (has write/admin access)
|
||||
USER_PERMISSION=$(gh api repos/${{ github.repository }}/collaborators/${{ github.actor }}/permission --jq '.permission')
|
||||
if [[ "$USER_PERMISSION" == "admin" || "$USER_PERMISSION" == "write" ]]; then
|
||||
echo "IS_MAINTAINER=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: 'Post Guidance Comment'
|
||||
if: "steps.detect.outputs.STEERING_DETECTED == 'true'"
|
||||
uses: 'thollander/actions-comment-pull-request@65f9e5c9a1f2cd378bd74b2e057c9736982a8e74' # ratchet:thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
comment-tag: 'eval-guidance-bot'
|
||||
message: |
|
||||
### 🧠 Model Steering Guidance
|
||||
|
||||
This PR modifies files that affect the model's behavior (prompts, tools, or instructions).
|
||||
|
||||
${{ steps.analysis.outputs.MISSING_EVALS == 'true' && '- ⚠️ **Consider adding Evals:** No behavioral evaluations (`evals/*.eval.ts`) were added or updated in this PR. Consider adding a test case to verify the new behavior and prevent regressions.' || '' }}
|
||||
${{ steps.analysis.outputs.IS_MAINTAINER == 'true' && '- 🚀 **Maintainer Reminder:** Please ensure that these changes do not regress results on benchmark evals before merging.' || '' }}
|
||||
|
||||
---
|
||||
*This is an automated guidance message triggered by steering logic signatures.*
|
||||
@@ -61,6 +61,7 @@ jobs:
|
||||
GEMINI_MODEL: '${{ matrix.model }}'
|
||||
RUN_EVALS: "${{ github.event.inputs.run_all != 'false' }}"
|
||||
TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}'
|
||||
VITEST_RETRY: 0
|
||||
run: |
|
||||
CMD="npm run test:all_evals"
|
||||
PATTERN="${TEST_NAME_PATTERN}"
|
||||
|
||||
@@ -5,19 +5,19 @@ and parameters.
|
||||
|
||||
## CLI commands
|
||||
|
||||
| Command | Description | Example |
|
||||
| ---------------------------------- | ---------------------------------- | ------------------------------------------------------------ |
|
||||
| `gemini` | Start interactive REPL | `gemini` |
|
||||
| `gemini -p "query"` | Query non-interactively | `gemini -p "summarize README.md"` |
|
||||
| `gemini "query"` | Query and continue interactively | `gemini "explain this project"` |
|
||||
| `cat file \| gemini` | Process piped content | `cat logs.txt \| gemini`<br>`Get-Content logs.txt \| gemini` |
|
||||
| `gemini -i "query"` | Execute and continue interactively | `gemini -i "What is the purpose of this project?"` |
|
||||
| `gemini -r "latest"` | Continue most recent session | `gemini -r "latest"` |
|
||||
| `gemini -r "latest" "query"` | Continue session with a new prompt | `gemini -r "latest" "Check for type errors"` |
|
||||
| `gemini -r "<session-id>" "query"` | Resume session by ID | `gemini -r "abc123" "Finish this PR"` |
|
||||
| `gemini update` | Update to latest version | `gemini update` |
|
||||
| `gemini extensions` | Manage extensions | See [Extensions Management](#extensions-management) |
|
||||
| `gemini mcp` | Configure MCP servers | See [MCP Server Management](#mcp-server-management) |
|
||||
| Command | Description | Example |
|
||||
| ---------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `gemini` | Start interactive REPL | `gemini` |
|
||||
| `gemini -p "query"` | Query non-interactively | `gemini -p "summarize README.md"` |
|
||||
| `gemini "query"` | Query and continue interactively | `gemini "explain this project"` |
|
||||
| `cat file \| gemini` | Process piped content | `cat logs.txt \| gemini`<br>`Get-Content logs.txt \| gemini` |
|
||||
| `gemini -i "query"` | Execute and continue interactively | `gemini -i "What is the purpose of this project?"` |
|
||||
| `gemini -r "latest"` | Continue the most recent session across all folders | `gemini -r "latest"` |
|
||||
| `gemini -r "latest" "query"` | Continue a previous session with a new prompt | `gemini -r "latest" "Check for type errors"` |
|
||||
| `gemini -r "<session-id>" "query"` | Resume a specific session by UUID from any folder | `gemini -r "a1b2c3d4-e5f6-7890-abcd-ef1234567890" "Finish this PR"` |
|
||||
| `gemini update` | Update to latest version | `gemini update` |
|
||||
| `gemini extensions` | Manage extensions | See [Extensions Management](#extensions-management) |
|
||||
| `gemini mcp` | Configure MCP servers | See [MCP Server Management](#mcp-server-management) |
|
||||
|
||||
### Positional arguments
|
||||
|
||||
@@ -60,9 +60,9 @@ These commands are available within the interactive REPL.
|
||||
| `--allowed-tools` | - | array | - | **Deprecated.** Use the [Policy Engine](../reference/policy-engine.md) instead. Tools that are allowed to run without confirmation (comma-separated or multiple flags) |
|
||||
| `--extensions` | `-e` | array | - | List of extensions to use. If not provided, all extensions are enabled (comma-separated or multiple flags) |
|
||||
| `--list-extensions` | `-l` | boolean | - | List all available extensions and exit |
|
||||
| `--resume` | `-r` | string | - | Resume a previous session. Use `"latest"` for most recent or index number (e.g. `--resume 5`) |
|
||||
| `--list-sessions` | - | boolean | - | List available sessions for the current project and exit |
|
||||
| `--delete-session` | - | string | - | Delete a session by index number (use `--list-sessions` to see available sessions) |
|
||||
| `--resume` | `-r` | string | - | Resume a previous session globally. Use `"latest"`, an index number from `--list-sessions`, or a full UUID (for example `--resume 5` or `--resume <uuid>`) |
|
||||
| `--list-sessions` | - | boolean | - | List available sessions across all folders and exit |
|
||||
| `--delete-session` | - | string | - | Delete a session globally by index number or UUID (use `--list-sessions` to see available sessions) |
|
||||
| `--include-directories` | - | array | - | Additional directories to include in the workspace (comma-separated or multiple flags) |
|
||||
| `--screen-reader` | - | boolean | - | Enable screen reader mode for accessibility |
|
||||
| `--output-format` | `-o` | string | `text` | The format of the CLI output. Choices: `text`, `json`, `stream-json` |
|
||||
|
||||
@@ -15,11 +15,12 @@ session.
|
||||
- All tool executions (inputs and outputs).
|
||||
- Token usage statistics (input, output, cached, etc.).
|
||||
- Assistant thoughts and reasoning summaries (when available).
|
||||
- **Location:** Sessions are stored in `~/.gemini/tmp/<project_hash>/chats/`,
|
||||
where `<project_hash>` is a unique identifier based on your project's root
|
||||
- **Location:** Sessions are stored under `~/.gemini/tmp/<project_hash>/chats/`,
|
||||
where `<project_hash>` is a unique identifier based on a project's root
|
||||
directory.
|
||||
- **Scope:** Sessions are project-specific. Switching directories to a different
|
||||
project switches to that project's session history.
|
||||
- **Scope:** Session storage remains organized per project, but session
|
||||
discovery is global. You can list, resume, and delete auto-saved sessions from
|
||||
any folder by index or UUID.
|
||||
|
||||
## Resuming sessions
|
||||
|
||||
@@ -38,20 +39,28 @@ sessions.
|
||||
gemini --resume
|
||||
```
|
||||
|
||||
This immediately loads the most recent session.
|
||||
This immediately loads the most recent session across all folders.
|
||||
|
||||
- **Resume by index:** List available sessions first (see
|
||||
[Listing sessions](#listing-sessions)), then use the index number:
|
||||
[Listing sessions](#listing-sessions)), then use the index number from the
|
||||
global session list:
|
||||
|
||||
```bash
|
||||
gemini --resume 1
|
||||
```
|
||||
|
||||
- **Resume by ID:** You can also provide the full session UUID:
|
||||
- **Resume by ID:** You can also provide the full session UUID from any folder:
|
||||
```bash
|
||||
gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890
|
||||
```
|
||||
|
||||
If the current folder is different from the session's original folder:
|
||||
|
||||
- **Interactive mode:** Gemini CLI shows the original folder, your current
|
||||
folder, and asks whether you want to resume the session in the current folder.
|
||||
- **Non-interactive mode:** Gemini CLI exits with guidance telling you to `cd`
|
||||
to the original folder and rerun the resume command there.
|
||||
|
||||
### From the interactive interface
|
||||
|
||||
While the CLI is running, use the `/resume` slash command to open the **Session
|
||||
@@ -73,12 +82,13 @@ Unique prefixes such as `/resum` and `/cha` resolve to the same grouped menu.
|
||||
The Session Browser provides an interactive interface where you can perform the
|
||||
following actions:
|
||||
|
||||
- **Browse:** Scroll through a list of your past sessions.
|
||||
- **Browse:** Scroll through a list of your past sessions across all folders.
|
||||
- **Preview:** See details like the session date, message count, and the first
|
||||
user prompt.
|
||||
user prompt, plus the original folder path.
|
||||
- **Search:** Press `/` to enter search mode, then type to filter sessions by ID
|
||||
or content.
|
||||
- **Select:** Press **Enter** to resume the selected session.
|
||||
- **Select:** Press **Enter** to resume the selected session. If the session was
|
||||
created in a different folder, Gemini CLI confirms before continuing there.
|
||||
- **Esc:** Press **Esc** to exit the Session Browser.
|
||||
|
||||
### Manual chat checkpoints
|
||||
@@ -109,8 +119,8 @@ space.
|
||||
|
||||
### Listing sessions
|
||||
|
||||
To see a list of all available sessions for the current project from the command
|
||||
line, use the `--list-sessions` flag:
|
||||
To see a list of all available sessions from the command line, use the
|
||||
`--list-sessions` flag:
|
||||
|
||||
```bash
|
||||
gemini --list-sessions
|
||||
@@ -119,11 +129,11 @@ gemini --list-sessions
|
||||
Output example:
|
||||
|
||||
```text
|
||||
Available sessions for this project (3):
|
||||
Available sessions (3):
|
||||
|
||||
1. Fix bug in auth (2 days ago) [a1b2c3d4]
|
||||
2. Refactor database schema (5 hours ago) [e5f67890]
|
||||
3. Update documentation (Just now) [abcd1234]
|
||||
1. Fix bug in auth (2 days ago) [a1b2c3d4-e5f6-7890-abcd-ef1234567890] @ /Users/alex/project-a
|
||||
2. Refactor database schema (5 hours ago) [e5f67890-1234-5678-9abc-def012345678] @ /Users/alex/project-b
|
||||
3. Update documentation (Just now) [abcd1234-5678-90ef-abcd-1234567890ef] @ /Users/alex/project-c
|
||||
```
|
||||
|
||||
### Deleting sessions
|
||||
@@ -131,7 +141,8 @@ Available sessions for this project (3):
|
||||
You can remove old or unwanted sessions to free up space or declutter your
|
||||
history.
|
||||
|
||||
**From the command line:** Use the `--delete-session` flag with an index or ID:
|
||||
**From the command line:** Use the `--delete-session` flag with an index or UUID
|
||||
from the global list:
|
||||
|
||||
```bash
|
||||
gemini --delete-session 2
|
||||
|
||||
@@ -904,6 +904,20 @@ Logs keychain availability checks.
|
||||
|
||||
- `available` (boolean)
|
||||
|
||||
##### `gemini_cli.startup_stats`
|
||||
|
||||
Logs detailed startup performance statistics.
|
||||
|
||||
<details>
|
||||
<summary>Attributes</summary>
|
||||
|
||||
- `phases` (json array of startup phases)
|
||||
- `os_platform` (string)
|
||||
- `os_release` (string)
|
||||
- `is_docker` (boolean)
|
||||
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
||||
### Metrics
|
||||
@@ -920,6 +934,20 @@ Gemini CLI exports several custom metrics.
|
||||
|
||||
Incremented once per CLI startup.
|
||||
|
||||
##### Onboarding
|
||||
|
||||
Tracks onboarding flow from authentication to the user
|
||||
|
||||
- `gemini_cli.onboarding.start` (Counter, Int): Incremented when the
|
||||
authentication flow begins.
|
||||
|
||||
- `gemini_cli.onboarding.success` (Counter, Int): Incremented when the user
|
||||
onboarding flow completes successfully.
|
||||
<details>
|
||||
<summary>Attributes (Success)</summary>
|
||||
|
||||
- `user_tier` (string)
|
||||
|
||||
##### Tools
|
||||
|
||||
##### `gemini_cli.tool.call.count`
|
||||
|
||||
@@ -26,6 +26,19 @@ gemini -r
|
||||
This restores your chat history and memory, so you can say "Continue with the
|
||||
next step" immediately.
|
||||
|
||||
### Scenario: Resume a session from another folder
|
||||
|
||||
If you know the session UUID, you can resume it from any folder:
|
||||
|
||||
```bash
|
||||
gemini -r a1b2c3d4-e5f6-7890-abcd-ef1234567890
|
||||
```
|
||||
|
||||
If that session was created in a different folder, Gemini CLI shows the original
|
||||
folder and asks whether you want to continue in the current one. If you say no,
|
||||
the CLI exits so you can `cd` to the original folder and rerun the command
|
||||
there.
|
||||
|
||||
### Scenario: Browse past sessions
|
||||
|
||||
If you want to find a specific conversation from yesterday, use the interactive
|
||||
@@ -33,11 +46,13 @@ browser.
|
||||
|
||||
**Command:** `/resume`
|
||||
|
||||
This opens a searchable list of all your past sessions. You'll see:
|
||||
This opens a searchable list of all your past sessions across all folders.
|
||||
You'll see:
|
||||
|
||||
- A timestamp (e.g., "2 hours ago").
|
||||
- The first user message (helping you identify the topic).
|
||||
- The number of turns in the conversation.
|
||||
- The original folder for each session.
|
||||
|
||||
Select a session and press **Enter** to load it.
|
||||
|
||||
|
||||
@@ -362,8 +362,12 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **Search:** Use `/` to search through conversation content across all
|
||||
sessions
|
||||
- **Session Browser:** Interactive interface showing all saved sessions with
|
||||
timestamps, message counts, and first user message for context
|
||||
timestamps, message counts, the first user message for context, and the
|
||||
original folder path
|
||||
- **Sorting:** Sort sessions by date or message count
|
||||
- **Cross-folder behavior:** `/resume` shows sessions across all folders. If you
|
||||
choose a session that was created in a different folder, Gemini CLI shows the
|
||||
original folder and asks whether you want to continue in the current folder.
|
||||
- **Note:** All conversations are automatically saved as you chat - no manual
|
||||
saving required. See [Session Management](../cli/session-management.md) for
|
||||
complete details.
|
||||
|
||||
@@ -1535,7 +1535,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
- **`experimental.enableAgents`** (boolean):
|
||||
- **Description:** Enable local and remote subagents.
|
||||
- **Default:** `true`
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.worktrees`** (boolean):
|
||||
@@ -2176,18 +2176,25 @@ for that specific session.
|
||||
- Lists all available extensions and exits.
|
||||
- **`--resume [session_id]`** (**`-r [session_id]`**):
|
||||
- Resume a previous chat session. Use "latest" for the most recent session,
|
||||
provide a session index number, or provide a full session UUID.
|
||||
provide a session index number from `--list-sessions`, or provide a full
|
||||
session UUID.
|
||||
- If no session_id is provided, defaults to "latest".
|
||||
- Session discovery is global, so you can resume a session from any folder.
|
||||
- If the current folder does not match the session's original folder,
|
||||
interactive mode asks for confirmation before resuming there. In
|
||||
non-interactive mode, the CLI exits with guidance to rerun from the original
|
||||
folder instead.
|
||||
- Example: `gemini --resume 5` or `gemini --resume latest` or
|
||||
`gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890` or `gemini --resume`
|
||||
- See [Session Management](../cli/session-management.md) for more details.
|
||||
- **`--list-sessions`**:
|
||||
- List all available chat sessions for the current project and exit.
|
||||
- List all available chat sessions across all folders and exit.
|
||||
- Shows session indices, dates, message counts, and preview of first user
|
||||
message.
|
||||
message, along with each session's original folder path when available.
|
||||
- Example: `gemini --list-sessions`
|
||||
- **`--delete-session <identifier>`**:
|
||||
- Delete a specific chat session by its index number or full session UUID.
|
||||
- Delete a specific chat session globally by its index number or full session
|
||||
UUID.
|
||||
- Use `--list-sessions` first to see available sessions, their indices, and
|
||||
UUIDs.
|
||||
- Example: `gemini --delete-session 3` or
|
||||
|
||||
@@ -35,6 +35,12 @@ const commonRestrictedSyntaxRules = [
|
||||
message:
|
||||
'Do not throw string literals or non-Error objects. Throw new Error("...") instead.',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
'UnaryExpression[operator="typeof"] > MemberExpression[computed=true][property.type="Literal"]',
|
||||
message:
|
||||
'Do not use typeof to check object properties. Define a TypeScript interface and a type guard function instead.',
|
||||
},
|
||||
];
|
||||
|
||||
export default tseslint.config(
|
||||
@@ -133,16 +139,7 @@ export default tseslint.config(
|
||||
'no-cond-assign': 'error',
|
||||
'no-debugger': 'error',
|
||||
'no-duplicate-case': 'error',
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
...commonRestrictedSyntaxRules,
|
||||
{
|
||||
selector:
|
||||
'UnaryExpression[operator="typeof"] > MemberExpression[computed=true][property.type="Literal"]',
|
||||
message:
|
||||
'Do not use typeof to check object properties. Define a TypeScript interface and a type guard function instead.',
|
||||
},
|
||||
],
|
||||
'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules],
|
||||
'no-unsafe-finally': 'error',
|
||||
'no-unused-expressions': 'off', // Disable base rule
|
||||
'@typescript-eslint/no-unused-expressions': [
|
||||
@@ -161,6 +158,7 @@ export default tseslint.config(
|
||||
'@typescript-eslint/await-thenable': ['error'],
|
||||
'@typescript-eslint/no-floating-promises': ['error'],
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': ['error'],
|
||||
'@typescript-eslint/no-misused-spread': ['error'],
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
|
||||
@@ -15,9 +15,26 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DEFAULT_GEMINI_MODEL } from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
* Config overrides for evals, with tool-restriction fields explicitly
|
||||
* forbidden. Evals must test against the full, default tool set to ensure
|
||||
* realistic behavior.
|
||||
*/
|
||||
interface EvalConfigOverrides {
|
||||
/** Restricting tools via excludeTools in evals is forbidden. */
|
||||
excludeTools?: never;
|
||||
/** Restricting tools via coreTools in evals is forbidden. */
|
||||
coreTools?: never;
|
||||
/** Restricting tools via allowedTools in evals is forbidden. */
|
||||
allowedTools?: never;
|
||||
/** Restricting tools via mainAgentTools in evals is forbidden. */
|
||||
mainAgentTools?: never;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AppEvalCase {
|
||||
name: string;
|
||||
configOverrides?: any;
|
||||
configOverrides?: EvalConfigOverrides;
|
||||
prompt: string;
|
||||
timeout?: number;
|
||||
files?: Record<string, string>;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('CliHelpAgent Delegation', () => {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should delegate to cli_help agent for subagent creation questions',
|
||||
params: {
|
||||
settings: {
|
||||
experimental: {
|
||||
enableAgents: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: 'Help me create a subagent in this project',
|
||||
timeout: 60000,
|
||||
assert: async (rig, _result) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const toolCallIndex = toolLogs.findIndex(
|
||||
(log) => log.toolRequest.name === 'cli_help',
|
||||
);
|
||||
expect(toolCallIndex).toBeGreaterThan(-1);
|
||||
expect(toolCallIndex).toBeLessThan(5); // Called within first 5 turns
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,6 @@ describe('generalist_delegation', () => {
|
||||
experimental: {
|
||||
enableAgents: true,
|
||||
},
|
||||
excludeTools: ['run_shell_command'],
|
||||
},
|
||||
files: {
|
||||
'file1.ts': 'console.log("no semi")',
|
||||
@@ -65,7 +64,6 @@ describe('generalist_delegation', () => {
|
||||
experimental: {
|
||||
enableAgents: true,
|
||||
},
|
||||
excludeTools: ['run_shell_command'],
|
||||
},
|
||||
files: {
|
||||
'src/a.ts': 'export const a = 1;',
|
||||
@@ -106,7 +104,6 @@ describe('generalist_delegation', () => {
|
||||
experimental: {
|
||||
enableAgents: true,
|
||||
},
|
||||
excludeTools: ['run_shell_command'],
|
||||
},
|
||||
files: {
|
||||
'README.md': 'This is a proyect.',
|
||||
@@ -141,7 +138,6 @@ describe('generalist_delegation', () => {
|
||||
experimental: {
|
||||
enableAgents: true,
|
||||
},
|
||||
excludeTools: ['run_shell_command'],
|
||||
},
|
||||
files: {
|
||||
'src/VERSION': '1.2.3',
|
||||
|
||||
@@ -15,7 +15,6 @@ describe('Model Steering Behavioral Evals', () => {
|
||||
appEvalTest('USUALLY_PASSES', {
|
||||
name: 'Corrective Hint: Model switches task based on hint during tool turn',
|
||||
configOverrides: {
|
||||
excludeTools: ['run_shell_command', 'ls', 'google_web_search'],
|
||||
modelSteering: true,
|
||||
},
|
||||
files: {
|
||||
@@ -55,7 +54,6 @@ describe('Model Steering Behavioral Evals', () => {
|
||||
appEvalTest('USUALLY_PASSES', {
|
||||
name: 'Suggestive Hint: Model incorporates user guidance mid-stream',
|
||||
configOverrides: {
|
||||
excludeTools: ['run_shell_command', 'ls', 'google_web_search'],
|
||||
modelSteering: true,
|
||||
},
|
||||
files: {},
|
||||
|
||||
@@ -16,9 +16,7 @@ describe('save_memory', () => {
|
||||
const rememberingFavoriteColor = "Agent remembers user's favorite color";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingFavoriteColor,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
|
||||
prompt: `remember that my favorite color is blue.
|
||||
|
||||
what is my favorite color? tell me that and surround it with $ symbol`,
|
||||
@@ -38,9 +36,7 @@ describe('save_memory', () => {
|
||||
const rememberingCommandRestrictions = 'Agent remembers command restrictions';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingCommandRestrictions,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
|
||||
prompt: `I don't want you to ever run npm commands.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
@@ -59,9 +55,7 @@ describe('save_memory', () => {
|
||||
const rememberingWorkflow = 'Agent remembers workflow preferences';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingWorkflow,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
|
||||
prompt: `I want you to always lint after building.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
@@ -81,9 +75,7 @@ describe('save_memory', () => {
|
||||
'Agent ignores temporary conversation details';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: ignoringTemporaryInformation,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
|
||||
prompt: `I'm going to get a coffee.`,
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
@@ -106,9 +98,7 @@ describe('save_memory', () => {
|
||||
const rememberingPetName = "Agent remembers user's pet's name";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingPetName,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
|
||||
prompt: `Please remember that my dog's name is Buddy.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
@@ -127,9 +117,7 @@ describe('save_memory', () => {
|
||||
const rememberingCommandAlias = 'Agent remembers custom command aliases';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingCommandAlias,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
|
||||
prompt: `When I say 'start server', you should run 'npm run dev'.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
@@ -149,18 +137,6 @@ describe('save_memory', () => {
|
||||
"Agent ignores workspace's database schema location";
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: ignoringDbSchemaLocation,
|
||||
params: {
|
||||
settings: {
|
||||
tools: {
|
||||
core: [
|
||||
'save_memory',
|
||||
'list_directory',
|
||||
'read_file',
|
||||
'run_shell_command',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`,
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
@@ -180,9 +156,7 @@ describe('save_memory', () => {
|
||||
"Agent remembers user's coding style preference";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingCodingStyle,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
|
||||
prompt: `I prefer to use tabs instead of spaces for indentation.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
@@ -202,18 +176,6 @@ describe('save_memory', () => {
|
||||
'Agent ignores workspace build artifact location';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: ignoringBuildArtifactLocation,
|
||||
params: {
|
||||
settings: {
|
||||
tools: {
|
||||
core: [
|
||||
'save_memory',
|
||||
'list_directory',
|
||||
'read_file',
|
||||
'run_shell_command',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`,
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
@@ -232,18 +194,6 @@ describe('save_memory', () => {
|
||||
const ignoringMainEntryPoint = "Agent ignores workspace's main entry point";
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: ignoringMainEntryPoint,
|
||||
params: {
|
||||
settings: {
|
||||
tools: {
|
||||
core: [
|
||||
'save_memory',
|
||||
'list_directory',
|
||||
'read_file',
|
||||
'run_shell_command',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: `The main entry point for this workspace is \`src/index.js\`.`,
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
@@ -262,9 +212,7 @@ describe('save_memory', () => {
|
||||
const rememberingBirthday = "Agent remembers user's birthday";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: rememberingBirthday,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
},
|
||||
|
||||
prompt: `My birthday is on June 15th.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||
|
||||
@@ -197,9 +197,25 @@ export function symlinkNodeModules(testDir: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings that are forbidden in evals. Evals should never restrict which
|
||||
* tools are available — they must test against the full, default tool set
|
||||
* to ensure realistic behavior.
|
||||
*/
|
||||
interface ForbiddenToolSettings {
|
||||
tools?: {
|
||||
/** Restricting core tools in evals is forbidden. */
|
||||
core?: never;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EvalCase {
|
||||
name: string;
|
||||
params?: Record<string, any>;
|
||||
params?: {
|
||||
settings?: ForbiddenToolSettings & Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
prompt: string;
|
||||
timeout?: number;
|
||||
files?: Record<string, string>;
|
||||
|
||||
@@ -16,6 +16,10 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
testTimeout: 300000, // 5 minutes
|
||||
// Retry in CI but not nightly to avoid blocking on API error.
|
||||
retry: process.env['VITEST_RETRY']
|
||||
? parseInt(process.env['VITEST_RETRY'], 10)
|
||||
: 3,
|
||||
reporters: ['default', 'json'],
|
||||
outputFile: {
|
||||
json: 'evals/logs/report.json',
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll open https://example.com and check the page title for you."},{"functionCall":{"name":"browser_agent","args":{"task":"Open https://example.com and get the page title"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":35,"totalTokenCount":135}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The page title of https://example.com is \"Example Domain\". The browser session has been completed and cleaned up successfully."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I have opened the page and the title is 'Example Domain'."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The task is complete. The page title is 'Example Domain'."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":20,"totalTokenCount":320}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Done."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":400,"candidatesTokenCount":5,"totalTokenCount":405}}]}
|
||||
|
||||
@@ -63,6 +63,9 @@ describe.skipIf(!chromeAvailable)('browser-policy', () => {
|
||||
rig.setup('browser-policy-skip-confirmation', {
|
||||
fakeResponsesPath: join(__dirname, 'browser-policy.responses'),
|
||||
settings: {
|
||||
experimental: {
|
||||
enableAgents: true,
|
||||
},
|
||||
agents: {
|
||||
overrides: {
|
||||
browser_agent: {
|
||||
@@ -175,4 +178,39 @@ priority = 200
|
||||
expect(output).toContain('browser_agent');
|
||||
expect(output).toContain('completed successfully');
|
||||
});
|
||||
|
||||
it('should show the visible warning when browser agent starts in existing session mode', async () => {
|
||||
rig.setup('browser-session-warning', {
|
||||
fakeResponsesPath: join(__dirname, 'browser-agent.cleanup.responses'),
|
||||
settings: {
|
||||
experimental: {
|
||||
enableAgents: true,
|
||||
},
|
||||
general: {
|
||||
enableAutoUpdateNotification: false,
|
||||
},
|
||||
agents: {
|
||||
overrides: {
|
||||
browser_agent: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
sessionMode: 'existing',
|
||||
headless: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const stdout = await rig.runCommand(['Open https://example.com'], {
|
||||
env: {
|
||||
GEMINI_API_KEY: 'fake-key',
|
||||
GEMINI_TELEMETRY_DISABLED: 'true',
|
||||
DEV: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
expect(stdout).toContain('saved logins will be visible');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig, poll, normalizePath } from './test-helper.js';
|
||||
import { join } from 'node:path';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
|
||||
describe('Hooks System Integration', () => {
|
||||
describe('Hooks System Integration', { timeout: 120000 }, () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -2016,6 +2017,10 @@ console.log(JSON.stringify({
|
||||
|
||||
// 3. Final setup with full settings
|
||||
rig.setup('Hook Disabling Multiple Ops', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.disabled-via-command.responses',
|
||||
),
|
||||
settings: {
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
@@ -2230,7 +2235,7 @@ console.log(JSON.stringify({
|
||||
|
||||
// The hook should have stopped execution message (returned from tool)
|
||||
expect(result).toContain(
|
||||
'Agent execution stopped: Emergency Stop triggered by hook',
|
||||
'Agent execution stopped by hook: Emergency Stop triggered by hook',
|
||||
);
|
||||
|
||||
// Tool should NOT be called successfully (it was blocked/stopped)
|
||||
@@ -2242,4 +2247,210 @@ console.log(JSON.stringify({
|
||||
expect(writeFileCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hooks "ask" Decision Integration', () => {
|
||||
it(
|
||||
'should force confirmation prompt when hook returns "ask" decision even in YOLO mode',
|
||||
{ timeout: 60000 },
|
||||
async () => {
|
||||
const testName =
|
||||
'should force confirmation prompt when hook returns "ask" decision even in YOLO mode';
|
||||
|
||||
// 1. Setup hook script that returns 'ask' decision
|
||||
const hookOutput = {
|
||||
decision: 'ask',
|
||||
systemMessage: 'Confirmation forced by security hook',
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'BeforeTool',
|
||||
},
|
||||
};
|
||||
|
||||
const hookScript = `console.log(JSON.stringify(${JSON.stringify(
|
||||
hookOutput,
|
||||
)}));`;
|
||||
|
||||
// Create script path predictably
|
||||
const scriptPath = join(os.tmpdir(), 'gemini-cli-tests-ask-hook.js');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
// 2. Setup rig with YOLO mode enabled but with the 'ask' hook
|
||||
rig.setup(testName, {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.allow-tool.responses',
|
||||
),
|
||||
settings: {
|
||||
debugMode: true,
|
||||
tools: {
|
||||
approval: 'yolo',
|
||||
},
|
||||
general: {
|
||||
enableAutoUpdateNotification: false,
|
||||
},
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
BeforeTool: [
|
||||
{
|
||||
matcher: 'write_file',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${scriptPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Bypass terminal setup prompt and other startup banners
|
||||
const stateDir = join(rig.homeDir!, '.gemini');
|
||||
if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(stateDir, 'state.json'),
|
||||
JSON.stringify({
|
||||
terminalSetupPromptShown: true,
|
||||
hasSeenScreenReaderNudge: true,
|
||||
tipsShown: 100,
|
||||
}),
|
||||
);
|
||||
|
||||
// 3. Run interactive and verify prompt appears despite YOLO mode
|
||||
const run = await rig.runInteractive();
|
||||
|
||||
// Wait for prompt to appear
|
||||
await run.expectText('Type your message', 30000);
|
||||
|
||||
// Send prompt that will trigger write_file
|
||||
await run.type('Create a file called ask-test.txt with content "test"');
|
||||
await run.type('\r');
|
||||
|
||||
// Wait for the FORCED confirmation prompt to appear
|
||||
// It should contain the system message from the hook
|
||||
await run.expectText('Confirmation forced by security hook', 30000);
|
||||
await run.expectText('Allow', 5000);
|
||||
|
||||
// 4. Approve the permission
|
||||
await run.type('y');
|
||||
await run.type('\r');
|
||||
|
||||
// Wait for command to execute
|
||||
await run.expectText('approved.txt', 30000);
|
||||
|
||||
// Should find the tool call
|
||||
const foundWriteFile = await rig.waitForToolCall('write_file');
|
||||
expect(foundWriteFile).toBeTruthy();
|
||||
|
||||
// File should be created
|
||||
const fileContent = rig.readFile('approved.txt');
|
||||
expect(fileContent).toBe('Approved content');
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
'should allow cancelling when hook forces "ask" decision',
|
||||
{ timeout: 60000 },
|
||||
async () => {
|
||||
const testName =
|
||||
'should allow cancelling when hook forces "ask" decision';
|
||||
const hookOutput = {
|
||||
decision: 'ask',
|
||||
systemMessage: 'Confirmation forced for cancellation test',
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'BeforeTool',
|
||||
},
|
||||
};
|
||||
|
||||
const hookScript = `console.log(JSON.stringify(${JSON.stringify(
|
||||
hookOutput,
|
||||
)}));`;
|
||||
|
||||
const scriptPath = join(
|
||||
os.tmpdir(),
|
||||
'gemini-cli-tests-ask-cancel-hook.js',
|
||||
);
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
rig.setup(testName, {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.allow-tool.responses',
|
||||
),
|
||||
settings: {
|
||||
debugMode: true,
|
||||
tools: {
|
||||
approval: 'yolo',
|
||||
},
|
||||
general: {
|
||||
enableAutoUpdateNotification: false,
|
||||
},
|
||||
hooksConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
hooks: {
|
||||
BeforeTool: [
|
||||
{
|
||||
matcher: 'write_file',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${scriptPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Bypass terminal setup prompt and other startup banners
|
||||
const stateDir = join(rig.homeDir!, '.gemini');
|
||||
if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(stateDir, 'state.json'),
|
||||
JSON.stringify({
|
||||
terminalSetupPromptShown: true,
|
||||
hasSeenScreenReaderNudge: true,
|
||||
tipsShown: 100,
|
||||
}),
|
||||
);
|
||||
|
||||
const run = await rig.runInteractive();
|
||||
|
||||
// Wait for prompt to appear
|
||||
await run.expectText('Type your message', 30000);
|
||||
|
||||
await run.type(
|
||||
'Create a file called cancel-test.txt with content "test"',
|
||||
);
|
||||
await run.type('\r');
|
||||
|
||||
await run.expectText(
|
||||
'Confirmation forced for cancellation test',
|
||||
30000,
|
||||
);
|
||||
|
||||
// 4. Deny the permission using option 4
|
||||
await run.type('4');
|
||||
await run.type('\r');
|
||||
|
||||
// Wait for cancellation message
|
||||
await run.expectText('Cancelled', 15000);
|
||||
|
||||
// Tool should NOT be called successfully
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const writeFileCalls = toolLogs.filter(
|
||||
(t) =>
|
||||
t.toolRequest.name === 'write_file' &&
|
||||
t.toolRequest.success === true,
|
||||
);
|
||||
expect(writeFileCalls).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"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 . --cache",
|
||||
"lint": "eslint . --cache --max-warnings 0",
|
||||
"lint:fix": "eslint . --fix --ext .ts,.tsx && eslint integration-tests --fix && eslint scripts --fix && npm run format",
|
||||
"lint:ci": "npm run lint:all",
|
||||
"lint:all": "node scripts/lint.js",
|
||||
|
||||
@@ -341,11 +341,11 @@ describe('loadConfig', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should default enableAgents to true when not provided', async () => {
|
||||
it('should default enableAgents to false when not provided', async () => {
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableAgents: true,
|
||||
enableAgents: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -127,7 +127,7 @@ export async function loadConfig(
|
||||
interactive: !isHeadlessMode(),
|
||||
enableInteractiveShell: !isHeadlessMode(),
|
||||
ptyInfo: 'auto',
|
||||
enableAgents: settings.experimental?.enableAgents ?? true,
|
||||
enableAgents: settings.experimental?.enableAgents ?? false,
|
||||
};
|
||||
|
||||
const fileService = new FileDiscoveryService(workspaceDir, {
|
||||
|
||||
@@ -54,6 +54,7 @@ export async function getMcpServersFromConfig(
|
||||
return;
|
||||
}
|
||||
mcpServers[key] = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...server,
|
||||
extension,
|
||||
};
|
||||
|
||||
@@ -1716,6 +1716,7 @@ describe('loadCliConfig with admin.mcp.config', () => {
|
||||
|
||||
const serverA = config.getMcpServers()?.['serverA'];
|
||||
expect(serverA).toEqual({
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...localMcpServers['serverA'],
|
||||
type: 'sse',
|
||||
url: 'https://admin-server-a.com/sse',
|
||||
@@ -1766,6 +1767,7 @@ describe('loadCliConfig with admin.mcp.config', () => {
|
||||
};
|
||||
const localMcpServersWithTools: Record<string, MCPServerConfig> = {
|
||||
serverA: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...localMcpServers['serverA'],
|
||||
includeTools: ['local_tool'],
|
||||
timeout: 1234,
|
||||
@@ -1808,6 +1810,7 @@ describe('loadCliConfig with admin.mcp.config', () => {
|
||||
};
|
||||
const localMcpServersWithTools: Record<string, MCPServerConfig> = {
|
||||
serverA: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...localMcpServers['serverA'],
|
||||
includeTools: ['local_tool'],
|
||||
},
|
||||
|
||||
@@ -305,13 +305,12 @@ export async function parseArguments(
|
||||
})
|
||||
.option('list-sessions', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'List available sessions for the current project and exit.',
|
||||
description: 'List available sessions across all projects and exit.',
|
||||
})
|
||||
.option('delete-session', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Delete a session by index number (use --list-sessions to see available sessions).',
|
||||
'Delete a session by index number or UUID (use --list-sessions to see available sessions).',
|
||||
})
|
||||
.option('include-directories', {
|
||||
type: 'array',
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
Storage: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...actual.Storage,
|
||||
getGlobalGeminiDir: () => '/virtual-home/.gemini',
|
||||
},
|
||||
|
||||
@@ -400,7 +400,7 @@ describe('SettingsSchema', () => {
|
||||
expect(setting).toBeDefined();
|
||||
expect(setting.type).toBe('boolean');
|
||||
expect(setting.category).toBe('Experimental');
|
||||
expect(setting.default).toBe(true);
|
||||
expect(setting.default).toBe(false);
|
||||
expect(setting.requiresRestart).toBe(true);
|
||||
expect(setting.showInDialog).toBe(false);
|
||||
expect(setting.description).toBe('Enable local and remote subagents.');
|
||||
|
||||
@@ -1922,7 +1922,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Enable Agents',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
default: false,
|
||||
description: 'Enable local and remote subagents.',
|
||||
showInDialog: false,
|
||||
},
|
||||
|
||||
@@ -81,6 +81,20 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
createCache:
|
||||
actual.createCache ??
|
||||
((<K, V>() => {
|
||||
const cache = new Map<K, V>();
|
||||
return {
|
||||
clear: () => cache.clear(),
|
||||
getOrCreate: (key: K, factory: () => V) => {
|
||||
if (!cache.has(key)) {
|
||||
cache.set(key, factory());
|
||||
}
|
||||
return cache.get(key)!;
|
||||
},
|
||||
};
|
||||
}) as typeof actual.createCache),
|
||||
recordSlowRender: vi.fn(),
|
||||
logUserPrompt: vi.fn(),
|
||||
writeToStdout: vi.fn((...args) =>
|
||||
@@ -126,6 +140,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
clearInstance: vi.fn(),
|
||||
},
|
||||
coreEvents: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...actual.coreEvents,
|
||||
emitFeedback: vi.fn(),
|
||||
emitConsoleLog: vi.fn(),
|
||||
@@ -723,8 +738,8 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
});
|
||||
|
||||
it('should handle session selector error', async () => {
|
||||
const { SessionSelector } = await import('./utils/sessionUtils.js');
|
||||
vi.mocked(SessionSelector).mockImplementation(
|
||||
const sessionUtils = await import('./utils/sessionUtils.js');
|
||||
vi.spyOn(sessionUtils, 'SessionSelector').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
resolveSession: vi
|
||||
@@ -780,16 +795,14 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
});
|
||||
|
||||
it('should start normally with a warning when no sessions found for resume', async () => {
|
||||
const { SessionSelector, SessionError } = await import(
|
||||
'./utils/sessionUtils.js'
|
||||
);
|
||||
vi.mocked(SessionSelector).mockImplementation(
|
||||
const sessionUtils = await import('./utils/sessionUtils.js');
|
||||
vi.spyOn(sessionUtils, 'SessionSelector').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
resolveSession: vi
|
||||
.fn()
|
||||
.mockRejectedValue(SessionError.noSessionsFound()),
|
||||
}) as unknown as InstanceType<typeof SessionSelector>,
|
||||
.mockRejectedValue(sessionUtils.SessionError.noSessionsFound()),
|
||||
}) as unknown as InstanceType<typeof sessionUtils.SessionSelector>,
|
||||
);
|
||||
|
||||
const processExitSpy = vi
|
||||
@@ -1043,6 +1056,16 @@ describe('gemini.tsx main function exit codes', () => {
|
||||
});
|
||||
|
||||
it('should exit with 42 for session resume failure', async () => {
|
||||
const sessionUtils = await import('./utils/sessionUtils.js');
|
||||
vi.spyOn(sessionUtils, 'SessionSelector').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
resolveSession: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Session not found')),
|
||||
}) as unknown as InstanceType<typeof sessionUtils.SessionSelector>,
|
||||
);
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||
createMockConfig({
|
||||
isInteractive: () => false,
|
||||
@@ -1062,29 +1085,78 @@ describe('gemini.tsx main function exit codes', () => {
|
||||
resume: 'invalid-session',
|
||||
} as unknown as CliArgs);
|
||||
|
||||
vi.mock('./utils/sessionUtils.js', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('./utils/sessionUtils.js')>();
|
||||
return {
|
||||
...original,
|
||||
SessionSelector: vi.fn().mockImplementation(() => ({
|
||||
resolveSession: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Session not found')),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined) as never);
|
||||
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
try {
|
||||
await main();
|
||||
expect.fail('Should have thrown MockProcessExitError');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(MockProcessExitError);
|
||||
expect((e as MockProcessExitError).code).toBe(42);
|
||||
} finally {
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
}
|
||||
|
||||
expect(emitFeedbackSpy).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.stringContaining('Error resuming session: Session not found'),
|
||||
);
|
||||
expect(processExitSpy).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('should exit with 42 when resuming from a different folder in non-interactive mode', async () => {
|
||||
const sessionUtils = await import('./utils/sessionUtils.js');
|
||||
vi.spyOn(sessionUtils, 'SessionSelector').mockImplementation(
|
||||
() =>
|
||||
({
|
||||
resolveSession: vi.fn().mockResolvedValue({
|
||||
sessionPath: '/tmp/original-project/chats/session.json',
|
||||
sessionData: {
|
||||
sessionId: 'cross-project-session',
|
||||
projectHash: 'test-project-hash',
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
lastUpdated: '2025-01-01T01:00:00.000Z',
|
||||
messages: [],
|
||||
},
|
||||
displayInfo: 'Cross-project session',
|
||||
originProjectPath: '/tmp/original-project',
|
||||
projectSlug: 'original-project',
|
||||
isOriginProjectMismatch: true,
|
||||
}),
|
||||
}) as unknown as InstanceType<typeof sessionUtils.SessionSelector>,
|
||||
);
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||
createMockConfig({
|
||||
isInteractive: () => false,
|
||||
getQuestion: () => 'test',
|
||||
getSandbox: () => undefined,
|
||||
}),
|
||||
);
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
createMockSettings({
|
||||
merged: { security: { auth: {} }, ui: {} },
|
||||
}),
|
||||
);
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
resume: 'cross-project-session',
|
||||
} as unknown as CliArgs);
|
||||
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined) as never);
|
||||
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
try {
|
||||
await main();
|
||||
} finally {
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
}
|
||||
|
||||
expect(emitFeedbackSpy).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.stringContaining('Cannot resume session cross-project-session'),
|
||||
);
|
||||
expect(processExitSpy).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('should exit with 42 for no input provided', async () => {
|
||||
@@ -1508,6 +1580,7 @@ describe('startInteractiveUI', () => {
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation(() => true);
|
||||
const mockConfigWithScreenReader = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...mockConfig,
|
||||
getScreenReader: () => screenReader,
|
||||
} as Config;
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
ValidationRequiredError,
|
||||
type AdminControlsSettings,
|
||||
debugLogger,
|
||||
escapeShellArg,
|
||||
getShellConfiguration,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||
@@ -213,12 +215,36 @@ export async function main() {
|
||||
loadSettingsHandle?.end();
|
||||
|
||||
// If a worktree is requested and enabled, set it up early.
|
||||
// This must be awaited before any other async tasks that depend on CWD (like loadCliConfig)
|
||||
// because setupWorktree calls process.chdir().
|
||||
const requestedWorktree = cliConfig.getRequestedWorktreeName(settings);
|
||||
let worktreeInfo: WorktreeInfo | undefined;
|
||||
if (requestedWorktree !== undefined) {
|
||||
const worktreeHandle = startupProfiler.start('setup_worktree');
|
||||
worktreeInfo = await setupWorktree(requestedWorktree || undefined);
|
||||
worktreeHandle?.end();
|
||||
}
|
||||
|
||||
const cleanupOpsHandle = startupProfiler.start('cleanup_ops');
|
||||
Promise.all([
|
||||
cleanupCheckpoints(),
|
||||
cleanupToolOutputFiles(settings.merged),
|
||||
cleanupBackgroundLogs(),
|
||||
])
|
||||
.catch((e) => {
|
||||
debugLogger.error('Early cleanup failed:', e);
|
||||
})
|
||||
.finally(() => {
|
||||
cleanupOpsHandle?.end();
|
||||
});
|
||||
|
||||
const parseArgsHandle = startupProfiler.start('parse_arguments');
|
||||
const argvPromise = parseArguments(settings.merged).finally(() => {
|
||||
parseArgsHandle?.end();
|
||||
});
|
||||
|
||||
const rawStartupWarningsPromise = getStartupWarnings();
|
||||
|
||||
// Report settings errors once during startup
|
||||
settings.errors.forEach((error) => {
|
||||
coreEvents.emitFeedback('warning', error.message);
|
||||
@@ -232,15 +258,7 @@ export async function main() {
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
cleanupCheckpoints(),
|
||||
cleanupToolOutputFiles(settings.merged),
|
||||
cleanupBackgroundLogs(),
|
||||
]);
|
||||
|
||||
const parseArgsHandle = startupProfiler.start('parse_arguments');
|
||||
const argv = await parseArguments(settings.merged);
|
||||
parseArgsHandle?.end();
|
||||
const argv = await argvPromise;
|
||||
|
||||
if (
|
||||
(argv.allowedTools && argv.allowedTools.length > 0) ||
|
||||
@@ -467,12 +485,10 @@ export async function main() {
|
||||
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
|
||||
});
|
||||
|
||||
// Cleanup sessions after config initialization
|
||||
try {
|
||||
await cleanupExpiredSessions(config, settings.merged);
|
||||
} catch (e) {
|
||||
// Launch cleanup expired sessions as a background task
|
||||
cleanupExpiredSessions(config, settings.merged).catch((e) => {
|
||||
debugLogger.error('Failed to cleanup expired sessions:', e);
|
||||
}
|
||||
});
|
||||
|
||||
if (config.getListExtensions()) {
|
||||
debugLogger.log('Installed extensions:');
|
||||
@@ -524,7 +540,9 @@ export async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
const terminalHandle = startupProfiler.start('setup_terminal');
|
||||
await setupTerminalAndTheme(config, settings);
|
||||
terminalHandle?.end();
|
||||
|
||||
const initAppHandle = startupProfiler.start('initialize_app');
|
||||
const initializationResult = await initializeApp(config, settings);
|
||||
@@ -548,7 +566,7 @@ export async function main() {
|
||||
isAlternateBufferEnabled(config),
|
||||
config.getScreenReader(),
|
||||
);
|
||||
const rawStartupWarnings = await getStartupWarnings();
|
||||
const rawStartupWarnings = await rawStartupWarningsPromise;
|
||||
const startupWarnings: StartupWarning[] = [
|
||||
...rawStartupWarnings.map((message) => ({
|
||||
id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`,
|
||||
@@ -566,9 +584,21 @@ export async function main() {
|
||||
const sessionSelector = new SessionSelector(config);
|
||||
try {
|
||||
const result = await sessionSelector.resolveSession(argv.resume);
|
||||
if (!config.isInteractive() && result.isOriginProjectMismatch) {
|
||||
const originalFolder = result.originProjectPath ?? 'unknown';
|
||||
const { shell } = getShellConfiguration();
|
||||
const rerunCommand = `cd ${escapeShellArg(originalFolder, shell)}\n gemini --resume ${escapeShellArg(result.sessionData.sessionId, shell)}`;
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Cannot resume session ${result.sessionData.sessionId} from "${process.cwd()}" in non-interactive mode.\n Original folder: ${originalFolder}\n Rerun from the original folder:\n ${rerunCommand}`,
|
||||
);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_INPUT_ERROR);
|
||||
}
|
||||
resumedSessionData = {
|
||||
conversation: result.sessionData,
|
||||
filePath: result.sessionPath,
|
||||
originProjectPath: result.originProjectPath,
|
||||
};
|
||||
// Use the existing session ID to continue recording to the same session
|
||||
config.setSessionId(resumedSessionData.conversation.sessionId);
|
||||
|
||||
@@ -1137,6 +1137,7 @@ describe('runNonInteractive', () => {
|
||||
|
||||
expect(
|
||||
processStderrSpy.mock.calls.some(
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
(call) => typeof call[0] === 'string' && call[0].includes('Cancelling'),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
@@ -266,6 +266,7 @@ describe('BuiltinCommandLoader', () => {
|
||||
|
||||
it('should include policies command when message bus integration is enabled', async () => {
|
||||
const mockConfigWithMessageBus = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...mockConfig,
|
||||
getEnableHooks: () => false,
|
||||
getMcpEnabled: () => true,
|
||||
|
||||
@@ -11,7 +11,11 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { AppContainer } from '../ui/AppContainer.js';
|
||||
import { renderWithProviders, type RenderInstance } from './render.js';
|
||||
import {
|
||||
renderWithProviders,
|
||||
type RenderInstance,
|
||||
persistentStateMock,
|
||||
} from './render.js';
|
||||
import {
|
||||
makeFakeConfig,
|
||||
type Config,
|
||||
@@ -95,6 +99,20 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
|
||||
return {
|
||||
...original,
|
||||
createCache:
|
||||
original.createCache ??
|
||||
((<K, V>() => {
|
||||
const cache = new Map<K, V>();
|
||||
return {
|
||||
clear: () => cache.clear(),
|
||||
getOrCreate: (key: K, factory: () => V) => {
|
||||
if (!cache.has(key)) {
|
||||
cache.set(key, factory());
|
||||
}
|
||||
return cache.get(key)!;
|
||||
},
|
||||
};
|
||||
}) as typeof original.createCache),
|
||||
ShellExecutionService: MockService,
|
||||
};
|
||||
});
|
||||
@@ -180,6 +198,11 @@ export class AppRig {
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
persistentStateMock.setData({
|
||||
terminalSetupPromptShown: true,
|
||||
tipsShown: 10,
|
||||
});
|
||||
|
||||
this.setupEnvironment();
|
||||
resetSettingsCacheForTesting();
|
||||
this.settings = this.createRigSettings();
|
||||
@@ -226,6 +249,8 @@ export class AppRig {
|
||||
private setupEnvironment() {
|
||||
// Stub environment variables to avoid interference from developer's machine
|
||||
vi.stubEnv('GEMINI_CLI_HOME', this.testDir);
|
||||
vi.stubEnv('TERM_PROGRAM', 'other');
|
||||
vi.stubEnv('VSCODE_GIT_IPC_HANDLE', '');
|
||||
if (this.options.fakeResponsesPath) {
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||
MockShellExecutionService.setPassthrough(false);
|
||||
@@ -291,7 +316,6 @@ export class AppRig {
|
||||
|
||||
const newContentGeneratorConfig = {
|
||||
authType: authMethod,
|
||||
|
||||
proxy: gcConfig.getProxy(),
|
||||
apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key',
|
||||
};
|
||||
|
||||
@@ -79,7 +79,7 @@ export async function toMatchSvgSnapshot(
|
||||
}
|
||||
|
||||
function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { isNot } = this as any;
|
||||
let pass = true;
|
||||
const invalidLines: Array<{ line: number; content: string }> = [];
|
||||
@@ -108,7 +108,6 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
expect.extend({
|
||||
toHaveOnlyValidCharacters,
|
||||
toMatchSvgSnapshot,
|
||||
|
||||
@@ -37,14 +37,12 @@ export const createMockCommandContext = (
|
||||
},
|
||||
services: {
|
||||
agentContext: null,
|
||||
|
||||
settings: {
|
||||
merged: defaultMergedSettings,
|
||||
setValue: vi.fn(),
|
||||
forScope: vi.fn().mockReturnValue({ settings: {} }),
|
||||
} as unknown as LoadedSettings,
|
||||
git: undefined as GitService | undefined,
|
||||
|
||||
logger: {
|
||||
log: vi.fn(),
|
||||
logMessage: vi.fn(),
|
||||
@@ -53,7 +51,6 @@ export const createMockCommandContext = (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any, // Cast because Logger is a class.
|
||||
},
|
||||
|
||||
ui: {
|
||||
addItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
@@ -72,7 +69,6 @@ export const createMockCommandContext = (
|
||||
} as any,
|
||||
session: {
|
||||
sessionShellAllowlist: new Set<string>(),
|
||||
|
||||
stats: {
|
||||
sessionStartTime: new Date(),
|
||||
lastPromptTokenCount: 0,
|
||||
@@ -98,7 +94,6 @@ export const createMockCommandContext = (
|
||||
for (const key in source) {
|
||||
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
const sourceValue = source[key];
|
||||
|
||||
const targetValue = output[key];
|
||||
|
||||
if (
|
||||
@@ -109,7 +104,6 @@ export const createMockCommandContext = (
|
||||
output[key] = merge(targetValue, sourceValue);
|
||||
} else {
|
||||
// If not, we do a direct assignment. This preserves Date objects and others.
|
||||
|
||||
output[key] = sourceValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,11 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getIdeMode: vi.fn().mockReturnValue(false),
|
||||
getFolderTrust: vi.fn().mockReturnValue(true),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
getPendingIncludeDirectories: vi.fn().mockReturnValue([]),
|
||||
clearPendingIncludeDirectories: vi.fn(),
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
addDirectories: vi.fn(),
|
||||
}),
|
||||
getCompressionThreshold: vi.fn().mockResolvedValue(undefined),
|
||||
getUserCaching: vi.fn().mockResolvedValue(false),
|
||||
getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false),
|
||||
|
||||
@@ -778,7 +778,6 @@ export async function renderHook<Result, Props>(
|
||||
generateSvg: () => string;
|
||||
}> {
|
||||
const result = { current: undefined as unknown as Result };
|
||||
|
||||
let currentProps = options?.initialProps as Props;
|
||||
|
||||
function TestComponent({
|
||||
|
||||
@@ -46,7 +46,6 @@ export const createMockSettings = (
|
||||
workspace,
|
||||
isTrusted,
|
||||
errors,
|
||||
|
||||
merged: mergedOverride,
|
||||
...settingsOverrides
|
||||
} = overrides;
|
||||
@@ -61,7 +60,6 @@ export const createMockSettings = (
|
||||
settings: settingsOverrides,
|
||||
originalSettings: settingsOverrides,
|
||||
},
|
||||
|
||||
(workspace as any) || { path: '', settings: {}, originalSettings: {} },
|
||||
isTrusted ?? true,
|
||||
errors || [],
|
||||
|
||||
@@ -65,6 +65,20 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
createCache:
|
||||
actual.createCache ??
|
||||
((<K, V>() => {
|
||||
const cache = new Map<K, V>();
|
||||
return {
|
||||
clear: () => cache.clear(),
|
||||
getOrCreate: (key: K, factory: () => V) => {
|
||||
if (!cache.has(key)) {
|
||||
cache.set(key, factory());
|
||||
}
|
||||
return cache.get(key)!;
|
||||
},
|
||||
};
|
||||
}) as typeof actual.createCache),
|
||||
coreEvents: mockCoreEvents,
|
||||
IdeClient: mockIdeClient,
|
||||
writeToStdout: vi.fn((...args) =>
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
type ConfirmationRequest,
|
||||
type PermissionConfirmationRequest,
|
||||
type QuotaStats,
|
||||
type ResumeContextSwitchConfirmationRequest,
|
||||
} from './types.js';
|
||||
import { checkPermissions } from './hooks/atCommandProcessor.js';
|
||||
import { MessageType, StreamingState } from './types.js';
|
||||
@@ -77,6 +78,8 @@ import {
|
||||
SessionStartSource,
|
||||
SessionEndReason,
|
||||
generateSummary,
|
||||
escapeShellArg,
|
||||
getShellConfiguration,
|
||||
type ConsentRequestPayload,
|
||||
type AgentsDiscoveredPayload,
|
||||
ChangeAuthRequestedError,
|
||||
@@ -251,6 +254,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const [customDialog, setCustomDialog] = useState<React.ReactNode | null>(
|
||||
null,
|
||||
);
|
||||
const [
|
||||
resumeContextSwitchConfirmationRequest,
|
||||
setResumeContextSwitchConfirmationRequest,
|
||||
] = useState<ResumeContextSwitchConfirmationRequest | null>(null);
|
||||
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
|
||||
const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false);
|
||||
const toggleBackgroundShellRef = useRef<() => void>(() => {});
|
||||
@@ -273,6 +280,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const [queueErrorMessage, setQueueErrorMessage] = useTimedMessage<string>(
|
||||
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
||||
);
|
||||
const canResumeOnStartupRef = useRef(true);
|
||||
|
||||
const [newAgents, setNewAgents] = useState<AgentDefinition[] | null>(null);
|
||||
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
|
||||
@@ -725,6 +733,46 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
// Session browser and resume functionality
|
||||
const isGeminiClientInitialized = config.getGeminiClient()?.isInitialized();
|
||||
const confirmResumeContextSwitch = useCallback(
|
||||
async ({
|
||||
source,
|
||||
sessionId,
|
||||
currentProjectPath,
|
||||
originProjectPath,
|
||||
}: {
|
||||
source: 'startup' | 'browser';
|
||||
sessionId: string;
|
||||
currentProjectPath: string;
|
||||
originProjectPath: string;
|
||||
}) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
const { shell } = getShellConfiguration();
|
||||
const rerunCommand = `cd ${escapeShellArg(originProjectPath, shell)}\ngemini --resume ${escapeShellArg(sessionId, shell)}`;
|
||||
const prompt =
|
||||
source === 'startup'
|
||||
? `Session ${sessionId} was created in:\n\n${originProjectPath}\n\nYou are currently in:\n\n${currentProjectPath}\n\nDo you want to resume it here instead? Choose "No" to exit and rerun from the original folder:\n\n${rerunCommand}`
|
||||
: `Session ${sessionId} was created in:\n\n${originProjectPath}\n\nYou are currently in:\n\n${currentProjectPath}\n\nDo you want to resume it here instead?`;
|
||||
|
||||
setResumeContextSwitchConfirmationRequest({
|
||||
prompt,
|
||||
onConfirm: () => {
|
||||
setResumeContextSwitchConfirmationRequest(null);
|
||||
resolve(true);
|
||||
},
|
||||
onDecline: () => {
|
||||
if (source !== 'startup') {
|
||||
setResumeContextSwitchConfirmationRequest(null);
|
||||
}
|
||||
resolve(false);
|
||||
},
|
||||
exitOnDecline: source === 'startup',
|
||||
declineExitMessage:
|
||||
'Session resume was canceled. Exiting so you can switch to the original folder and rerun the resume command.',
|
||||
exitCode: 0,
|
||||
});
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const { loadHistoryForResume, isResuming } = useSessionResume({
|
||||
config,
|
||||
@@ -734,6 +782,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
setQuittingMessages,
|
||||
resumedSessionData,
|
||||
isAuthenticating,
|
||||
canResumeOnStartup: () => canResumeOnStartupRef.current,
|
||||
confirmResumeContextSwitch,
|
||||
});
|
||||
const {
|
||||
isSessionBrowserOpen,
|
||||
@@ -1456,31 +1506,30 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const geminiClient = config.getGeminiClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
initialPrompt &&
|
||||
isConfigInitialized &&
|
||||
!initialPromptSubmitted.current &&
|
||||
!isAuthenticating &&
|
||||
!isAuthDialogOpen &&
|
||||
!isThemeDialogOpen &&
|
||||
!isEditorDialogOpen &&
|
||||
!showPrivacyNotice &&
|
||||
geminiClient?.isInitialized?.()
|
||||
) {
|
||||
void handleFinalSubmit(initialPrompt);
|
||||
initialPromptSubmitted.current = true;
|
||||
if (activePtyId) {
|
||||
try {
|
||||
ShellExecutionService.resizePty(
|
||||
activePtyId,
|
||||
Math.floor(terminalWidth * SHELL_WIDTH_FRACTION),
|
||||
Math.max(
|
||||
Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING),
|
||||
1,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
// This can happen in a race condition where the pty exits
|
||||
// right before we try to resize it.
|
||||
if (
|
||||
!(
|
||||
e instanceof Error &&
|
||||
e.message.includes('Cannot resize a pty that has already exited')
|
||||
)
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
initialPrompt,
|
||||
isConfigInitialized,
|
||||
handleFinalSubmit,
|
||||
isAuthenticating,
|
||||
isAuthDialogOpen,
|
||||
isThemeDialogOpen,
|
||||
isEditorDialogOpen,
|
||||
showPrivacyNotice,
|
||||
geminiClient,
|
||||
]);
|
||||
}, [terminalWidth, availableTerminalHeight, activePtyId]);
|
||||
|
||||
const [idePromptAnswered, setIdePromptAnswered] = useState(false);
|
||||
const [currentIDE, setCurrentIDE] = useState<IdeInfo | null>(null);
|
||||
@@ -1991,10 +2040,74 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}, [historyManager]);
|
||||
|
||||
const nightly = props.version.includes('nightly');
|
||||
const hasPendingIncludeDirectories =
|
||||
config.getPendingIncludeDirectories().length > 0;
|
||||
const hasStartupBlockingDialog =
|
||||
adminSettingsChanged ||
|
||||
showIdeRestartPrompt ||
|
||||
!!proQuotaRequest ||
|
||||
!!validationRequest ||
|
||||
isPolicyUpdateDialogOpen ||
|
||||
isFolderTrustDialogOpen ||
|
||||
!!resumeContextSwitchConfirmationRequest ||
|
||||
isAuthenticating ||
|
||||
authState === AuthState.AwaitingApiKeyInput ||
|
||||
isAuthDialogOpen ||
|
||||
showPrivacyNotice;
|
||||
const visibleShouldShowIdePrompt =
|
||||
!initialPrompt &&
|
||||
!isResuming &&
|
||||
!hasStartupBlockingDialog &&
|
||||
shouldShowIdePrompt;
|
||||
const visibleNewAgents =
|
||||
!initialPrompt && !isResuming && !hasStartupBlockingDialog
|
||||
? newAgents
|
||||
: null;
|
||||
|
||||
canResumeOnStartupRef.current = !hasStartupBlockingDialog;
|
||||
|
||||
const hasConfirmUpdateExtensionRequests =
|
||||
confirmUpdateExtensionRequests.length > 0;
|
||||
const hasLoopDetectionConfirmationRequest =
|
||||
!!loopDetectionConfirmationRequest;
|
||||
const hasBlockingUiForInitialPrompt =
|
||||
hasStartupBlockingDialog ||
|
||||
!!commandConfirmationRequest ||
|
||||
!!authConsentRequest ||
|
||||
!!permissionConfirmationRequest ||
|
||||
!!customDialog ||
|
||||
hasConfirmUpdateExtensionRequests ||
|
||||
hasLoopDetectionConfirmationRequest ||
|
||||
isThemeDialogOpen ||
|
||||
isSettingsDialogOpen ||
|
||||
isModelDialogOpen ||
|
||||
isAgentConfigDialogOpen ||
|
||||
isPermissionsDialogOpen ||
|
||||
isEditorDialogOpen ||
|
||||
isSessionBrowserOpen ||
|
||||
hasPendingIncludeDirectories;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
initialPrompt &&
|
||||
isConfigInitialized &&
|
||||
!initialPromptSubmitted.current &&
|
||||
!hasBlockingUiForInitialPrompt &&
|
||||
geminiClient?.isInitialized?.()
|
||||
) {
|
||||
void handleFinalSubmit(initialPrompt);
|
||||
initialPromptSubmitted.current = true;
|
||||
}
|
||||
}, [
|
||||
initialPrompt,
|
||||
isConfigInitialized,
|
||||
handleFinalSubmit,
|
||||
hasBlockingUiForInitialPrompt,
|
||||
geminiClient,
|
||||
]);
|
||||
|
||||
const dialogsVisible =
|
||||
shouldShowIdePrompt ||
|
||||
shouldShowIdePrompt ||
|
||||
visibleShouldShowIdePrompt ||
|
||||
isFolderTrustDialogOpen ||
|
||||
isPolicyUpdateDialogOpen ||
|
||||
adminSettingsChanged ||
|
||||
@@ -2002,6 +2115,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
!!authConsentRequest ||
|
||||
!!permissionConfirmationRequest ||
|
||||
!!customDialog ||
|
||||
!!resumeContextSwitchConfirmationRequest ||
|
||||
confirmUpdateExtensionRequests.length > 0 ||
|
||||
!!loopDetectionConfirmationRequest ||
|
||||
isThemeDialogOpen ||
|
||||
@@ -2020,7 +2134,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
!!emptyWalletRequest ||
|
||||
isSessionBrowserOpen ||
|
||||
authState === AuthState.AwaitingApiKeyInput ||
|
||||
!!newAgents;
|
||||
!!visibleNewAgents;
|
||||
|
||||
const pendingHistoryItems = useMemo(
|
||||
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
||||
@@ -2032,15 +2146,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
[pendingHistoryItems],
|
||||
);
|
||||
|
||||
const hasConfirmUpdateExtensionRequests =
|
||||
confirmUpdateExtensionRequests.length > 0;
|
||||
const hasLoopDetectionConfirmationRequest =
|
||||
!!loopDetectionConfirmationRequest;
|
||||
|
||||
const hasPendingActionRequired =
|
||||
hasPendingToolConfirmation ||
|
||||
!!commandConfirmationRequest ||
|
||||
!!authConsentRequest ||
|
||||
!!resumeContextSwitchConfirmationRequest ||
|
||||
hasConfirmUpdateExtensionRequests ||
|
||||
hasLoopDetectionConfirmationRequest ||
|
||||
!!proQuotaRequest ||
|
||||
@@ -2215,11 +2325,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
suggestionsWidth,
|
||||
isInputActive,
|
||||
isResuming,
|
||||
shouldShowIdePrompt,
|
||||
shouldShowIdePrompt: visibleShouldShowIdePrompt,
|
||||
isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false,
|
||||
folderDiscoveryResults,
|
||||
isPolicyUpdateDialogOpen,
|
||||
policyUpdateConfirmationRequest,
|
||||
resumeContextSwitchConfirmationRequest,
|
||||
isTrustedFolder,
|
||||
constrainHeight,
|
||||
showErrorDetails,
|
||||
@@ -2288,7 +2399,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
backgroundShellHeight,
|
||||
isBackgroundShellListOpen,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
newAgents: visibleNewAgents,
|
||||
showIsExpandableHint,
|
||||
hintMode:
|
||||
config.isModelSteeringEnabled() &&
|
||||
@@ -2342,11 +2453,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
suggestionsWidth,
|
||||
isInputActive,
|
||||
isResuming,
|
||||
shouldShowIdePrompt,
|
||||
visibleShouldShowIdePrompt,
|
||||
isFolderTrustDialogOpen,
|
||||
folderDiscoveryResults,
|
||||
isPolicyUpdateDialogOpen,
|
||||
policyUpdateConfirmationRequest,
|
||||
resumeContextSwitchConfirmationRequest,
|
||||
isTrustedFolder,
|
||||
constrainHeight,
|
||||
showErrorDetails,
|
||||
@@ -2415,7 +2527,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
activeBackgroundShellPid,
|
||||
backgroundShells,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
visibleNewAgents,
|
||||
showIsExpandableHint,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -42,6 +42,7 @@ describe('IdeIntegrationNudge', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(debugLogger.warn).mockImplementation((...args) => {
|
||||
if (
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof args[0] === 'string' &&
|
||||
/was not wrapped in act/.test(args[0])
|
||||
) {
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
exports[`App > Snapshots > renders default layout correctly 1`] = `
|
||||
"
|
||||
▝▜▄ Gemini CLI v1.2.3
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.2.3
|
||||
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
@@ -31,9 +34,6 @@ Tips for getting started:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -47,10 +47,13 @@ exports[`App > Snapshots > renders screen reader layout correctly 1`] = `
|
||||
"Notifications
|
||||
Footer
|
||||
|
||||
▝▜▄ Gemini CLI v1.2.3
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.2.3
|
||||
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
@@ -64,12 +67,12 @@ Composer
|
||||
|
||||
exports[`App > Snapshots > renders with dialogs visible 1`] = `
|
||||
"
|
||||
▝▜▄ Gemini CLI v1.2.3
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.2.3
|
||||
|
||||
|
||||
|
||||
@@ -107,10 +110,13 @@ DialogManager
|
||||
|
||||
exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = `
|
||||
"
|
||||
▝▜▄ Gemini CLI v1.2.3
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.2.3
|
||||
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
@@ -140,9 +146,6 @@ HistoryItemDisplay
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Notifications
|
||||
Composer
|
||||
"
|
||||
|
||||
@@ -42,6 +42,7 @@ describe('AuthInProgress', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(debugLogger.error).mockImplementation((...args) => {
|
||||
if (
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof args[0] === 'string' &&
|
||||
args[0].includes('was not wrapped in act')
|
||||
) {
|
||||
|
||||
@@ -25,6 +25,13 @@ describe('quitCommand', () => {
|
||||
|
||||
it('returns a QuitActionReturn object with the correct messages', () => {
|
||||
const mockContext = createMockCommandContext({
|
||||
services: {
|
||||
agentContext: {
|
||||
config: {
|
||||
getSessionId: () => 'test-session-id',
|
||||
},
|
||||
},
|
||||
},
|
||||
session: {
|
||||
stats: {
|
||||
sessionStartTime: new Date('2025-01-01T00:00:00Z'),
|
||||
@@ -47,6 +54,7 @@ describe('quitCommand', () => {
|
||||
{
|
||||
type: 'quit',
|
||||
duration: '1h 0m 0s',
|
||||
sessionId: 'test-session-id',
|
||||
id: expect.any(Number),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -29,6 +29,7 @@ export const quitCommand: SlashCommand = {
|
||||
{
|
||||
type: 'quit',
|
||||
duration: formatDuration(wallDuration),
|
||||
sessionId: context.services.agentContext?.config.getSessionId(),
|
||||
id: now,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -38,6 +38,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
coreEvents: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...actual.coreEvents,
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '../../test-utils/render.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { makeFakeConfig } from '@google/gemini-cli-core';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
vi.mock('../utils/terminalSetup.js', () => ({
|
||||
@@ -240,4 +241,27 @@ describe('<AppHeader />', () => {
|
||||
expect(session2.lastFrame()).not.toContain('Tips');
|
||||
session2.unmount();
|
||||
});
|
||||
|
||||
it('should render the full logo when logged out', async () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
|
||||
authType: undefined,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{
|
||||
config: mockConfig,
|
||||
uiState: {
|
||||
terminalWidth: 120,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Check for block characters from the logo
|
||||
expect(lastFrame()).toContain('▗█▀▀▜▙');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,9 @@ import { CliSpinner } from './CliSpinner.js';
|
||||
|
||||
import { isAppleTerminal } from '@google/gemini-cli-core';
|
||||
|
||||
import { longAsciiLogoCompactText } from './AsciiArt.js';
|
||||
import { getAsciiArtWidth } from '../utils/textUtils.js';
|
||||
|
||||
interface AppHeaderProps {
|
||||
version: string;
|
||||
showDetails?: boolean;
|
||||
@@ -41,6 +44,18 @@ const MAC_TERMINAL_ICON = `▝▜▄
|
||||
▗▟▀
|
||||
▗▟▀ `;
|
||||
|
||||
/**
|
||||
* The horizontal padding (in columns) required for metadata (version, identity, etc.)
|
||||
* when rendered alongside the ASCII logo.
|
||||
*/
|
||||
const LOGO_METADATA_PADDING = 20;
|
||||
|
||||
/**
|
||||
* The terminal width below which we switch to a narrow/column layout to prevent
|
||||
* UI elements from wrapping or overlapping.
|
||||
*/
|
||||
const NARROW_TERMINAL_BREAKPOINT = 60;
|
||||
|
||||
export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
@@ -49,70 +64,90 @@ export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
|
||||
const { bannerText } = useBanner(bannerData);
|
||||
const { showTips } = useTips();
|
||||
|
||||
const authType = config.getContentGeneratorConfig()?.authType;
|
||||
const loggedOut = !authType;
|
||||
|
||||
const showHeader = !(
|
||||
settings.merged.ui.hideBanner || config.getScreenReader()
|
||||
);
|
||||
|
||||
const ICON = isAppleTerminal() ? MAC_TERMINAL_ICON : DEFAULT_ICON;
|
||||
|
||||
if (!showDetails) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{showHeader && (
|
||||
<Box
|
||||
flexDirection="row"
|
||||
marginTop={1}
|
||||
marginBottom={1}
|
||||
paddingLeft={2}
|
||||
>
|
||||
<Box flexShrink={0}>
|
||||
<ThemedGradient>{ICON}</ThemedGradient>
|
||||
</Box>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
<Box>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Gemini CLI
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> v{version}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
let logoTextArt = '';
|
||||
if (loggedOut) {
|
||||
const widthOfLongLogo =
|
||||
getAsciiArtWidth(longAsciiLogoCompactText) + LOGO_METADATA_PADDING;
|
||||
|
||||
if (terminalWidth >= widthOfLongLogo) {
|
||||
logoTextArt = longAsciiLogoCompactText.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// If the terminal is too narrow to fit the icon and metadata (especially long nightly versions)
|
||||
// side-by-side, we switch to column mode to prevent wrapping.
|
||||
const isNarrow = terminalWidth < NARROW_TERMINAL_BREAKPOINT;
|
||||
|
||||
const renderLogo = () => (
|
||||
<Box flexDirection="row">
|
||||
<Box flexShrink={0}>
|
||||
<ThemedGradient>{ICON}</ThemedGradient>
|
||||
</Box>
|
||||
{logoTextArt && (
|
||||
<Box marginLeft={3}>
|
||||
<Text color={theme.text.primary}>{logoTextArt}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderMetadata = (isBelow = false) => (
|
||||
<Box marginLeft={isBelow ? 0 : 2} flexDirection="column">
|
||||
{/* Line 1: Gemini CLI vVersion [Updating] */}
|
||||
<Box>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Gemini CLI
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> v{version}</Text>
|
||||
{updateInfo && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
<CliSpinner /> Updating
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
{showDetails && (
|
||||
<>
|
||||
{/* Line 2: Blank */}
|
||||
<Box height={1} />
|
||||
|
||||
{/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */}
|
||||
{settings.merged.ui.showUserIdentity !== false && (
|
||||
<UserIdentity config={config} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const useColumnLayout = !!logoTextArt || isNarrow;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{showHeader && (
|
||||
<Box flexDirection="row" marginTop={1} marginBottom={1} paddingLeft={2}>
|
||||
<Box flexShrink={0}>
|
||||
<ThemedGradient>{ICON}</ThemedGradient>
|
||||
</Box>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
{/* Line 1: Gemini CLI vVersion [Updating] */}
|
||||
<Box>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Gemini CLI
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> v{version}</Text>
|
||||
{updateInfo && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
<CliSpinner /> Updating
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Line 2: Blank */}
|
||||
<Box height={1} />
|
||||
|
||||
{/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */}
|
||||
{settings.merged.ui.showUserIdentity !== false && (
|
||||
<UserIdentity config={config} />
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
flexDirection={useColumnLayout ? 'column' : 'row'}
|
||||
marginTop={1}
|
||||
marginBottom={1}
|
||||
paddingLeft={1}
|
||||
>
|
||||
{renderLogo()}
|
||||
{useColumnLayout ? (
|
||||
<Box marginTop={1}>{renderMetadata(true)}</Box>
|
||||
) : (
|
||||
renderMetadata(false)
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,14 +16,14 @@ export const shortAsciiLogo = `
|
||||
`;
|
||||
|
||||
export const longAsciiLogo = `
|
||||
███ █████████ ██████████ ██████ ██████ █████ ██████ █████ █████
|
||||
░░░███ ███░░░░░███░░███░░░░░█░░██████ ██████ ░░███ ░░██████ ░░███ ░░███
|
||||
░░░███ ███ ░░░ ░███ █ ░ ░███░█████░███ ░███ ░███░███ ░███ ░███
|
||||
░░░███ ░███ ░██████ ░███░░███ ░███ ░███ ░███░░███░███ ░███
|
||||
███░ ░███ █████ ░███░░█ ░███ ░░░ ░███ ░███ ░███ ░░██████ ░███
|
||||
███░ ░░███ ░░███ ░███ ░ █ ░███ ░███ ░███ ░███ ░░█████ ░███
|
||||
███░ ░░█████████ ██████████ █████ █████ █████ █████ ░░█████ █████
|
||||
░░░ ░░░░░░░░░ ░░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░
|
||||
█████████ ██████████ ██████ ██████ █████ ██████ █████ █████
|
||||
███░░░░░███░░███░░░░░█░░██████ █████ ░░███░░██████ ░░███ ░░███
|
||||
███ ░░░░░░░ ░███ █ ░ ░███░█████░███ ░███ ░███░███ ░███ ░███
|
||||
░███ ░██████ ░███░░███ ░███ ░███ ░███░░███░███ ░███
|
||||
░███ █████ ░███░░█ ░███ ░░░ ░███ ░███ ░███ ░░██████ ░███
|
||||
░░███ ░░███ ░███ ░ █ ░███ ░███ ░███ ░███ ░░█████ ░███
|
||||
░░█████████ ██████████ █████ █████ █████ █████ ░░████ █████
|
||||
░░░░░░░░░ ░░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░ ░░░░░
|
||||
`;
|
||||
|
||||
export const tinyAsciiLogo = `
|
||||
@@ -36,3 +36,24 @@ export const tinyAsciiLogo = `
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
`;
|
||||
|
||||
export const shortAsciiLogoCompactText = `
|
||||
▟▛▀▀█▖▜█▀▀▜▝██▙▗██▛▝█▛▝██▙ ▜█▘▜█▘
|
||||
▐█ ▐█▄▌ █▌▜█▘█▌ █▌ █▌▜▙▐█ ▐█
|
||||
▝█▖ ▜█▘▐█ ▘▗ █▌ █▌ █▌ █▌ ▜██ ▐█
|
||||
▝▀▀▀▀ ▀▀▀▀▀▝▀▀ ▝▀▀▝▀▀▝▀▀ ▀▀▘▀▀▘
|
||||
`;
|
||||
|
||||
export const longAsciiLogoCompactText = `
|
||||
▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
█▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
`;
|
||||
|
||||
export const tinyAsciiLogoCompactText = `
|
||||
▟▛▀▀█▖
|
||||
▐█
|
||||
▝█▖ ▜█▘
|
||||
▝▀▀▀▀
|
||||
`;
|
||||
|
||||
@@ -1453,4 +1453,42 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows at least 3 selection options even in small terminal heights', async () => {
|
||||
const questions: Question[] = [
|
||||
{
|
||||
question:
|
||||
'A very long question that would normally take up most of the space and squeeze the list if we did not have a heuristic to prevent it. This line is just to make it longer. And another one. Imagine this is a plan.',
|
||||
header: 'Test',
|
||||
type: QuestionType.CHOICE,
|
||||
options: [
|
||||
{ label: 'Option 1', description: 'Description 1' },
|
||||
{ label: 'Option 2', description: 'Description 2' },
|
||||
{ label: 'Option 3', description: 'Description 3' },
|
||||
{ label: 'Option 4', description: 'Description 4' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={80}
|
||||
availableHeight={12} // Very small height
|
||||
/>,
|
||||
{ width: 80 },
|
||||
);
|
||||
|
||||
await waitFor(async () => {
|
||||
await waitUntilReady();
|
||||
const frame = lastFrame();
|
||||
// Should show at least 3 options
|
||||
expect(frame).toContain('1. Option 1');
|
||||
expect(frame).toContain('2. Option 2');
|
||||
expect(frame).toContain('3. Option 3');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -849,11 +849,19 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
? Math.max(1, availableHeight - overhead)
|
||||
: undefined;
|
||||
|
||||
// Reserve space for at least 3 items if more selectionItems available.
|
||||
const reservedListHeight = Math.min(selectionItems.length * 2, 6);
|
||||
const questionHeightLimit =
|
||||
listHeight && !isAlternateBuffer
|
||||
? question.unconstrainedHeight
|
||||
? Math.max(1, listHeight - selectionItems.length * 2)
|
||||
: Math.min(15, Math.max(1, listHeight - DIALOG_PADDING))
|
||||
: Math.min(
|
||||
15,
|
||||
Math.max(
|
||||
1,
|
||||
listHeight - Math.max(DIALOG_PADDING, reservedListHeight),
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const maxItemsToShow =
|
||||
|
||||
@@ -25,6 +25,9 @@ vi.mock('./FolderTrustDialog.js', () => ({
|
||||
vi.mock('./ConsentPrompt.js', () => ({
|
||||
ConsentPrompt: () => <Text>ConsentPrompt</Text>,
|
||||
}));
|
||||
vi.mock('./ResumeContextSwitchDialog.js', () => ({
|
||||
ResumeContextSwitchDialog: () => <Text>ResumeContextSwitchDialog</Text>,
|
||||
}));
|
||||
vi.mock('./ThemeDialog.js', () => ({
|
||||
ThemeDialog: () => <Text>ThemeDialog</Text>,
|
||||
}));
|
||||
@@ -58,6 +61,9 @@ vi.mock('./ModelDialog.js', () => ({
|
||||
vi.mock('./IdeTrustChangeDialog.js', () => ({
|
||||
IdeTrustChangeDialog: () => <Text>IdeTrustChangeDialog</Text>,
|
||||
}));
|
||||
vi.mock('./NewAgentsNotification.js', () => ({
|
||||
NewAgentsNotification: () => <Text>NewAgentsNotification</Text>,
|
||||
}));
|
||||
vi.mock('./AgentConfigDialog.js', () => ({
|
||||
AgentConfigDialog: () => <Text>AgentConfigDialog</Text>,
|
||||
}));
|
||||
@@ -88,6 +94,7 @@ describe('DialogManager', () => {
|
||||
loopDetectionConfirmationRequest: null,
|
||||
confirmationRequest: null,
|
||||
consentRequest: null,
|
||||
resumeContextSwitchConfirmationRequest: null,
|
||||
isThemeDialogOpen: false,
|
||||
isSettingsDialogOpen: false,
|
||||
isModelDialogOpen: false,
|
||||
@@ -98,6 +105,8 @@ describe('DialogManager', () => {
|
||||
showPrivacyNotice: false,
|
||||
isPermissionsDialogOpen: false,
|
||||
isAgentConfigDialogOpen: false,
|
||||
newAgents: null,
|
||||
customDialog: null,
|
||||
selectedAgentName: undefined,
|
||||
selectedAgentDisplayName: undefined,
|
||||
selectedAgentDefinition: undefined,
|
||||
@@ -165,6 +174,16 @@ describe('DialogManager', () => {
|
||||
},
|
||||
'ConsentPrompt',
|
||||
],
|
||||
[
|
||||
{
|
||||
resumeContextSwitchConfirmationRequest: {
|
||||
prompt: 'resume here?',
|
||||
onConfirm: vi.fn(),
|
||||
onDecline: vi.fn(),
|
||||
},
|
||||
},
|
||||
'ResumeContextSwitchDialog',
|
||||
],
|
||||
[{ isThemeDialogOpen: true }, 'ThemeDialog'],
|
||||
[{ isSettingsDialogOpen: true }, 'SettingsDialog'],
|
||||
[{ isModelDialogOpen: true }, 'ModelDialog'],
|
||||
@@ -209,4 +228,46 @@ describe('DialogManager', () => {
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it('prioritizes folder trust ahead of resume context confirmation', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
<DialogManager {...defaultProps} />,
|
||||
{
|
||||
uiState: {
|
||||
...baseUiState,
|
||||
isFolderTrustDialogOpen: true,
|
||||
resumeContextSwitchConfirmationRequest: {
|
||||
prompt: 'resume here?',
|
||||
onConfirm: vi.fn(),
|
||||
onDecline: vi.fn(),
|
||||
},
|
||||
} as Partial<UIState> as UIState,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('FolderTrustDialog');
|
||||
expect(lastFrame()).not.toContain('ResumeContextSwitchDialog');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders custom dialogs only after higher-priority startup dialogs', async () => {
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
<DialogManager {...defaultProps} />,
|
||||
{
|
||||
uiState: {
|
||||
...baseUiState,
|
||||
resumeContextSwitchConfirmationRequest: {
|
||||
prompt: 'resume here?',
|
||||
onConfirm: vi.fn(),
|
||||
onDecline: vi.fn(),
|
||||
},
|
||||
customDialog: <Text>CustomDialog</Text>,
|
||||
} as Partial<UIState> as UIState,
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('ResumeContextSwitchDialog');
|
||||
expect(lastFrame()).not.toContain('CustomDialog');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
|
||||
import { NewAgentsNotification } from './NewAgentsNotification.js';
|
||||
import { AgentConfigDialog } from './AgentConfigDialog.js';
|
||||
import { PolicyUpdateDialog } from './PolicyUpdateDialog.js';
|
||||
import { ResumeContextSwitchDialog } from './ResumeContextSwitchDialog.js';
|
||||
|
||||
interface DialogManagerProps {
|
||||
addItem: UseHistoryManagerReturn['addItem'];
|
||||
@@ -66,14 +67,6 @@ export const DialogManager = ({
|
||||
if (uiState.showIdeRestartPrompt) {
|
||||
return <IdeTrustChangeDialog reason={uiState.ideTrustRestartReason} />;
|
||||
}
|
||||
if (uiState.newAgents) {
|
||||
return (
|
||||
<NewAgentsNotification
|
||||
agents={uiState.newAgents}
|
||||
onSelect={uiActions.handleNewAgentsSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.quota.proQuotaRequest) {
|
||||
return (
|
||||
<ProQuotaDialog
|
||||
@@ -126,11 +119,12 @@ export const DialogManager = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.shouldShowIdePrompt) {
|
||||
if (uiState.isPolicyUpdateDialogOpen) {
|
||||
return (
|
||||
<IdeIntegrationNudge
|
||||
ide={uiState.currentIDE!}
|
||||
onComplete={uiActions.handleIdePromptComplete}
|
||||
<PolicyUpdateDialog
|
||||
config={config}
|
||||
request={uiState.policyUpdateConfirmationRequest!}
|
||||
onClose={() => uiActions.setIsPolicyUpdateDialogOpen(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -143,12 +137,44 @@ export const DialogManager = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.isPolicyUpdateDialogOpen) {
|
||||
if (uiState.resumeContextSwitchConfirmationRequest) {
|
||||
return (
|
||||
<PolicyUpdateDialog
|
||||
<ResumeContextSwitchDialog
|
||||
prompt={uiState.resumeContextSwitchConfirmationRequest.prompt}
|
||||
onConfirm={uiState.resumeContextSwitchConfirmationRequest.onConfirm}
|
||||
onDecline={uiState.resumeContextSwitchConfirmationRequest.onDecline}
|
||||
exitOnDecline={
|
||||
uiState.resumeContextSwitchConfirmationRequest.exitOnDecline
|
||||
}
|
||||
declineExitMessage={
|
||||
uiState.resumeContextSwitchConfirmationRequest.declineExitMessage
|
||||
}
|
||||
exitCode={uiState.resumeContextSwitchConfirmationRequest.exitCode}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.showPrivacyNotice) {
|
||||
return (
|
||||
<PrivacyNotice
|
||||
onExit={() => uiActions.exitPrivacyNotice()}
|
||||
config={config}
|
||||
request={uiState.policyUpdateConfirmationRequest!}
|
||||
onClose={() => uiActions.setIsPolicyUpdateDialogOpen(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.shouldShowIdePrompt) {
|
||||
return (
|
||||
<IdeIntegrationNudge
|
||||
ide={uiState.currentIDE!}
|
||||
onComplete={uiActions.handleIdePromptComplete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.newAgents) {
|
||||
return (
|
||||
<NewAgentsNotification
|
||||
agents={uiState.newAgents}
|
||||
onSelect={uiActions.handleNewAgentsSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -334,14 +360,6 @@ export const DialogManager = ({
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (uiState.showPrivacyNotice) {
|
||||
return (
|
||||
<PrivacyNotice
|
||||
onExit={() => uiActions.exitPrivacyNotice()}
|
||||
config={config}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.isSessionBrowserOpen) {
|
||||
return (
|
||||
<SessionBrowser
|
||||
@@ -362,6 +380,9 @@ export const DialogManager = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.customDialog) {
|
||||
return uiState.customDialog;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ import * as SessionContext from '../contexts/SessionContext.js';
|
||||
import { type SessionStatsState } from '../contexts/SessionContext.js';
|
||||
import { Banner } from './Banner.js';
|
||||
import { Footer } from './Footer.js';
|
||||
import { Header } from './Header.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { ModelDialog } from './ModelDialog.js';
|
||||
import { StatsDisplay } from './StatsDisplay.js';
|
||||
|
||||
@@ -71,9 +71,9 @@ useSessionStatsMock.mockReturnValue({
|
||||
});
|
||||
|
||||
describe('Gradient Crash Regression Tests', () => {
|
||||
it('<Header /> should not crash when theme.ui.gradient is empty', async () => {
|
||||
it('<AppHeader /> should not crash when theme.ui.gradient is empty', async () => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<Header version="1.0.0" nightly={false} />,
|
||||
<AppHeader version="1.0.0" />,
|
||||
{
|
||||
width: 120,
|
||||
},
|
||||
|
||||
@@ -189,7 +189,10 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
<ModelMessage model={itemForDisplay.model} />
|
||||
)}
|
||||
{itemForDisplay.type === 'quit' && (
|
||||
<SessionSummaryDisplay duration={itemForDisplay.duration} />
|
||||
<SessionSummaryDisplay
|
||||
duration={itemForDisplay.duration}
|
||||
sessionId={itemForDisplay.sessionId}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'tool_group' && (
|
||||
<ToolGroupMessage
|
||||
|
||||
@@ -244,6 +244,11 @@ export const HooksDialog: React.FC<HooksDialogProps> = ({
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
(Press Esc to close)
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { ResumeContextSwitchDialog } from './ResumeContextSwitchDialog.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { MarkdownDisplay } from '../utils/MarkdownDisplay.js';
|
||||
|
||||
vi.mock('./shared/RadioButtonSelect.js', () => ({
|
||||
RadioButtonSelect: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/MarkdownDisplay.js', () => ({
|
||||
MarkdownDisplay: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
const mockedExit = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('node:process', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('node:process')>('node:process');
|
||||
return {
|
||||
...actual,
|
||||
exit: mockedExit,
|
||||
};
|
||||
});
|
||||
|
||||
const MockedRadioButtonSelect = vi.mocked(RadioButtonSelect);
|
||||
const MockedMarkdownDisplay = vi.mocked(MarkdownDisplay);
|
||||
|
||||
describe('ResumeContextSwitchDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders a string prompt with MarkdownDisplay', async () => {
|
||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
||||
<ResumeContextSwitchDialog
|
||||
prompt="Resume here?"
|
||||
terminalWidth={80}
|
||||
onConfirm={vi.fn()}
|
||||
onDecline={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
expect(MockedMarkdownDisplay).toHaveBeenCalledWith(
|
||||
{
|
||||
isPending: true,
|
||||
text: 'Resume here?',
|
||||
terminalWidth: 80,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('calls onConfirm when yes is selected', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const onDecline = vi.fn();
|
||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
||||
<ResumeContextSwitchDialog
|
||||
prompt="Resume here?"
|
||||
terminalWidth={80}
|
||||
onConfirm={onConfirm}
|
||||
onDecline={onDecline}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
|
||||
await act(async () => {
|
||||
onSelect(true);
|
||||
});
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(onDecline).not.toHaveBeenCalled();
|
||||
expect(mockedExit).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows an exit message and exits after no is selected in startup mode', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onConfirm = vi.fn();
|
||||
const onDecline = vi.fn();
|
||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||
<ResumeContextSwitchDialog
|
||||
prompt="Resume here?"
|
||||
terminalWidth={80}
|
||||
onConfirm={onConfirm}
|
||||
onDecline={onDecline}
|
||||
exitOnDecline={true}
|
||||
declineExitMessage="Session resume was canceled. Exiting so you can switch to the original folder and rerun the resume command."
|
||||
exitCode={0}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
|
||||
await act(async () => {
|
||||
onSelect(false);
|
||||
});
|
||||
|
||||
expect(onDecline).toHaveBeenCalledTimes(1);
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Session resume was canceled.');
|
||||
expect(lastFrame()).toContain('original folder and rerun the resume');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
|
||||
expect(mockedExit).toHaveBeenCalledWith(0);
|
||||
unmount();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not exit for browser decline', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const onDecline = vi.fn();
|
||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
||||
<ResumeContextSwitchDialog
|
||||
prompt={<Text>Resume here?</Text>}
|
||||
terminalWidth={80}
|
||||
onConfirm={onConfirm}
|
||||
onDecline={onDecline}
|
||||
exitOnDecline={false}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
|
||||
await act(async () => {
|
||||
onSelect(false);
|
||||
});
|
||||
|
||||
expect(onDecline).toHaveBeenCalledTimes(1);
|
||||
expect(mockedExit).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('treats escape as decline', async () => {
|
||||
const onDecline = vi.fn();
|
||||
const { stdin, waitUntilReady, unmount } = await renderWithProviders(
|
||||
<ResumeContextSwitchDialog
|
||||
prompt="Resume here?"
|
||||
terminalWidth={80}
|
||||
onConfirm={vi.fn()}
|
||||
onDecline={onDecline}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\u001b[27u');
|
||||
});
|
||||
await act(async () => {
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
expect(onDecline).toHaveBeenCalledTimes(1);
|
||||
expect(mockedExit).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { type ReactNode, useCallback, useState } from 'react';
|
||||
import * as process from 'node:process';
|
||||
import { MarkdownDisplay } from '../utils/MarkdownDisplay.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
|
||||
interface ResumeContextSwitchDialogProps {
|
||||
prompt: ReactNode;
|
||||
terminalWidth: number;
|
||||
onConfirm: () => void;
|
||||
onDecline: () => void;
|
||||
exitOnDecline?: boolean;
|
||||
declineExitMessage?: ReactNode;
|
||||
exitCode?: number;
|
||||
}
|
||||
|
||||
export const ResumeContextSwitchDialog = ({
|
||||
prompt,
|
||||
terminalWidth,
|
||||
onConfirm,
|
||||
onDecline,
|
||||
exitOnDecline = false,
|
||||
declineExitMessage = 'Session resume was canceled. Exiting so you can switch to the original folder and rerun the resume command.',
|
||||
exitCode = 0,
|
||||
}: ResumeContextSwitchDialogProps) => {
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(confirmed: boolean) => {
|
||||
if (confirmed) {
|
||||
onConfirm();
|
||||
return;
|
||||
}
|
||||
|
||||
onDecline();
|
||||
if (!exitOnDecline) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExiting(true);
|
||||
setTimeout(async () => {
|
||||
await runExitCleanup();
|
||||
process.exit(exitCode);
|
||||
}, 100);
|
||||
},
|
||||
[exitCode, exitOnDecline, onConfirm, onDecline],
|
||||
);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
handleSelect(false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: !isExiting },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.warning}
|
||||
flexDirection="column"
|
||||
paddingTop={1}
|
||||
paddingX={2}
|
||||
>
|
||||
{typeof prompt === 'string' ? (
|
||||
<MarkdownDisplay
|
||||
isPending={true}
|
||||
text={prompt}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
) : (
|
||||
prompt
|
||||
)}
|
||||
{!isExiting && (
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect
|
||||
items={[
|
||||
{ label: 'Yes', value: true, key: 'Yes' },
|
||||
{ label: 'No', value: false, key: 'No' },
|
||||
]}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{isExiting && (
|
||||
<Box marginTop={1}>
|
||||
{typeof declineExitMessage === 'string' ? (
|
||||
<Text color={theme.status.warning}>{declineExitMessage}</Text>
|
||||
) : (
|
||||
declineExitMessage
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -125,6 +125,7 @@ const createSession = (overrides: Partial<SessionInfo>): SessionInfo => ({
|
||||
id: 'session-id',
|
||||
file: 'session-id',
|
||||
fileName: 'session-id.json',
|
||||
sessionPath: '/tmp/test/chats/session-id.json',
|
||||
startTime: new Date().toISOString(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
messageCount: 1,
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { Config } from '@google/gemini-cli-core';
|
||||
import type { SessionInfo } from '../../utils/sessionUtils.js';
|
||||
import {
|
||||
formatRelativeTime,
|
||||
getGlobalChatsDirs,
|
||||
getSessionFiles,
|
||||
} from '../../utils/sessionUtils.js';
|
||||
|
||||
@@ -220,11 +221,17 @@ const SessionItem = ({
|
||||
const prefix = isActive ? '❯ ' : ' ';
|
||||
let additionalInfo = '';
|
||||
let matchDisplay = null;
|
||||
const projectLabel = session.originProjectPath
|
||||
? path.basename(session.originProjectPath)
|
||||
: session.projectSlug;
|
||||
|
||||
// Add "(current)" label for the current session
|
||||
if (session.isCurrentSession) {
|
||||
additionalInfo = ' (current)';
|
||||
}
|
||||
if (projectLabel) {
|
||||
additionalInfo += ` [${projectLabel}]`;
|
||||
}
|
||||
|
||||
// Show match snippets if searching and matches exist
|
||||
if (
|
||||
@@ -431,9 +438,8 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
|
||||
useEffect(() => {
|
||||
const loadSessions = async () => {
|
||||
try {
|
||||
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
|
||||
const sessionData = await getSessionFiles(
|
||||
chatsDir,
|
||||
await getGlobalChatsDirs(),
|
||||
config.getSessionId(),
|
||||
);
|
||||
setSessions(sessionData);
|
||||
@@ -454,12 +460,8 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
|
||||
const loadFullContent = async () => {
|
||||
if (isSearchMode && !hasLoadedFullContent) {
|
||||
try {
|
||||
const chatsDir = path.join(
|
||||
config.storage.getProjectTempDir(),
|
||||
'chats',
|
||||
);
|
||||
const sessionData = await getSessionFiles(
|
||||
chatsDir,
|
||||
await getGlobalChatsDirs(),
|
||||
config.getSessionId(),
|
||||
{ includeFullContent: true },
|
||||
);
|
||||
|
||||
@@ -111,6 +111,8 @@ export const filterSessions = (
|
||||
const titleMatch =
|
||||
session.displayName.toLowerCase().includes(lowerQuery) ||
|
||||
session.id.toLowerCase().includes(lowerQuery) ||
|
||||
session.originProjectPath?.toLowerCase().includes(lowerQuery) ||
|
||||
session.projectSlug?.toLowerCase().includes(lowerQuery) ||
|
||||
session.firstUserMessage.toLowerCase().includes(lowerQuery);
|
||||
|
||||
const contentMatch = session.fullContent
|
||||
|
||||
@@ -21,6 +21,20 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
createCache:
|
||||
actual.createCache ??
|
||||
((<K, V>() => {
|
||||
const cache = new Map<K, V>();
|
||||
return {
|
||||
clear: () => cache.clear(),
|
||||
getOrCreate: (key: K, factory: () => V) => {
|
||||
if (!cache.has(key)) {
|
||||
cache.set(key, factory());
|
||||
}
|
||||
return cache.get(key)!;
|
||||
},
|
||||
};
|
||||
}) as typeof actual.createCache),
|
||||
getShellConfiguration: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -5,31 +5,36 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { escapeShellArg, getShellConfiguration } from '@google/gemini-cli-core';
|
||||
import { StatsDisplay } from './StatsDisplay.js';
|
||||
import { useSessionStats } from '../contexts/SessionContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { escapeShellArg, getShellConfiguration } from '@google/gemini-cli-core';
|
||||
|
||||
interface SessionSummaryDisplayProps {
|
||||
duration: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
|
||||
duration,
|
||||
sessionId,
|
||||
}) => {
|
||||
const { stats } = useSessionStats();
|
||||
const config = useConfig();
|
||||
const { shell } = getShellConfiguration();
|
||||
|
||||
const worktreeSettings = config.getWorktreeSettings();
|
||||
|
||||
const escapedSessionId = escapeShellArg(stats.sessionId, shell);
|
||||
let footer = `To resume this session: gemini --resume ${escapedSessionId}`;
|
||||
const sessionIdForFooter = sessionId ?? stats.sessionId ?? '<session-id>';
|
||||
const escapedSessionId = escapeShellArg(sessionIdForFooter, shell);
|
||||
|
||||
let footer = `Tip: Resume from any folder using gemini --resume ${escapedSessionId} or /resume`;
|
||||
|
||||
if (worktreeSettings) {
|
||||
const escapedWorktreePath = escapeShellArg(worktreeSettings.path, shell);
|
||||
footer =
|
||||
`To resume work in this worktree: cd ${escapeShellArg(worktreeSettings.path, shell)} && gemini --resume ${escapedSessionId}\n` +
|
||||
`To remove manually: git worktree remove ${escapeShellArg(worktreeSettings.path, shell)}`;
|
||||
`Tip: Resume from any folder using gemini --resume ${escapedSessionId}\n` +
|
||||
`To resume work in this worktree: cd ${escapedWorktreePath} && gemini --resume ${escapedSessionId}\n` +
|
||||
`To remove manually: git worktree remove ${escapedWorktreePath}`;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -163,6 +163,7 @@ describe('ToolConfirmationQueue', () => {
|
||||
</Box>,
|
||||
{
|
||||
config: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...mockConfig,
|
||||
getUseAlternateBuffer: () => true,
|
||||
} as unknown as Config,
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with a tool awaiting confirmation > with_confirming_tool 1`] = `
|
||||
"
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v0.10.0
|
||||
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
@@ -22,10 +25,13 @@ Action Required (was prompted):
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with active and pending tool messages > with_history_and_pending 1`] = `
|
||||
"
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v0.10.0
|
||||
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
@@ -50,10 +56,13 @@ Tips for getting started:
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = `
|
||||
"
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v0.10.0
|
||||
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
@@ -66,10 +75,13 @@ Tips for getting started:
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with history but no pending items > with_history_no_pending 1`] = `
|
||||
"
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v0.10.0
|
||||
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
@@ -90,10 +102,13 @@ Tips for getting started:
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = `
|
||||
"
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v0.10.0
|
||||
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
@@ -110,10 +125,13 @@ Tips for getting started:
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with user and gemini messages > with_user_gemini_messages 1`] = `
|
||||
"
|
||||
▝▜▄ Gemini CLI v0.10.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v0.10.0
|
||||
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
exports[`<AppHeader /> > should not render the banner when no flags are set 1`] = `
|
||||
"
|
||||
▝▜▄ Gemini CLI v1.0.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.0.0
|
||||
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
@@ -18,10 +21,13 @@ Tips for getting started:
|
||||
|
||||
exports[`<AppHeader /> > should not render the default banner if shown count is 5 or more 1`] = `
|
||||
"
|
||||
▝▜▄ Gemini CLI v1.0.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.0.0
|
||||
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
@@ -34,10 +40,13 @@ Tips for getting started:
|
||||
|
||||
exports[`<AppHeader /> > should render the banner with default text 1`] = `
|
||||
"
|
||||
▝▜▄ Gemini CLI v1.0.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.0.0
|
||||
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ This is the default banner │
|
||||
@@ -53,10 +62,13 @@ Tips for getting started:
|
||||
|
||||
exports[`<AppHeader /> > should render the banner with warning text 1`] = `
|
||||
"
|
||||
▝▜▄ Gemini CLI v1.0.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.0.0
|
||||
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ There are capacity issues │
|
||||
@@ -69,3 +81,14 @@ Tips for getting started:
|
||||
4. Be specific for the best results
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<AppHeader /> > should render the full logo when logged out 1`] = `
|
||||
"
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.0.0
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -1,30 +1,34 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="224" viewBox="0 0 920 224">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="275" viewBox="0 0 920 275">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="224" fill="#000000" />
|
||||
<rect width="920" height="275" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
|
||||
<text x="180" y="19" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.0.0</text>
|
||||
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="27" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="0" y="121" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
|
||||
<text x="0" y="138" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
|
||||
<text x="90" y="138" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
|
||||
<text x="171" y="138" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
|
||||
<text x="0" y="155" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
|
||||
<text x="27" y="155" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
|
||||
<text x="72" y="155" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
|
||||
<text x="0" y="172" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
|
||||
<text x="0" y="189" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
|
||||
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="19" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛</text>
|
||||
<text x="27" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="36" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="45" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="36" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">█▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌</text>
|
||||
<text x="18" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="27" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="36" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="90" y="53" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌</text>
|
||||
<text x="9" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="90" y="70" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs"> ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀</text>
|
||||
<text x="9" y="104" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
|
||||
<text x="99" y="104" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.0.0</text>
|
||||
<text x="0" y="172" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
|
||||
<text x="0" y="189" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
|
||||
<text x="90" y="189" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
|
||||
<text x="171" y="189" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
|
||||
<text x="0" y="206" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
|
||||
<text x="27" y="206" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
|
||||
<text x="72" y="206" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
|
||||
<text x="0" y="223" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
|
||||
<text x="0" y="240" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.4 KiB |
@@ -1,31 +1,35 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="224" viewBox="0 0 920 224">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="275" viewBox="0 0 920 275">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="224" fill="#000000" />
|
||||
<rect width="920" height="275" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="81" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
|
||||
<text x="171" y="19" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.0.0</text>
|
||||
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="54" y="53" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="36" y="70" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="0" y="121" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
|
||||
<text x="0" y="138" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
|
||||
<text x="90" y="138" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
|
||||
<text x="171" y="138" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
|
||||
<text x="0" y="155" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
|
||||
<text x="27" y="155" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
|
||||
<text x="72" y="155" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
|
||||
<text x="0" y="172" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
|
||||
<text x="0" y="189" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
|
||||
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="81" y="19" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛</text>
|
||||
<text x="27" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="36" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="45" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="81" y="36" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">█▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌</text>
|
||||
<text x="27" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="36" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="45" y="53" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="81" y="53" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌</text>
|
||||
<text x="9" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="18" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="27" y="70" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="81" y="70" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs"> ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀</text>
|
||||
<text x="9" y="104" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
|
||||
<text x="99" y="104" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.0.0</text>
|
||||
<text x="0" y="172" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
|
||||
<text x="0" y="189" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
|
||||
<text x="90" y="189" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
|
||||
<text x="171" y="189" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
|
||||
<text x="0" y="206" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
|
||||
<text x="27" y="206" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
|
||||
<text x="72" y="206" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
|
||||
<text x="0" y="223" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
|
||||
<text x="0" y="240" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.5 KiB |
@@ -2,10 +2,13 @@
|
||||
|
||||
exports[`AppHeader Icon Rendering > renders the default icon in standard terminals 1`] = `
|
||||
"
|
||||
▝▜▄ Gemini CLI v1.0.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.0.0
|
||||
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
@@ -17,10 +20,13 @@ Tips for getting started:
|
||||
|
||||
exports[`AppHeader Icon Rendering > renders the symmetric icon in Apple Terminal 1`] = `
|
||||
"
|
||||
▝▜▄ Gemini CLI v1.0.0
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▗▟▀
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▗▟▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.0.0
|
||||
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
|
||||
@@ -11,6 +11,17 @@ Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Choice question placeholder > uses default placeholder when not provided 2`] = `
|
||||
"Select your preferred language:
|
||||
|
||||
1. TypeScript
|
||||
2. JavaScript
|
||||
● 3. Enter a custom value
|
||||
|
||||
Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 1`] = `
|
||||
"Select your preferred language:
|
||||
|
||||
@@ -22,6 +33,17 @@ Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 2`] = `
|
||||
"Select your preferred language:
|
||||
|
||||
1. TypeScript
|
||||
2. JavaScript
|
||||
● 3. Type another language...
|
||||
|
||||
Enter to submit · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 1`] = `
|
||||
"Choose an option
|
||||
|
||||
@@ -30,6 +52,22 @@ exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scrol
|
||||
Description 1
|
||||
2. Option 2
|
||||
Description 2
|
||||
3. Option 3
|
||||
Description 3
|
||||
▼
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 2`] = `
|
||||
"Choose an option
|
||||
|
||||
▲
|
||||
● 1. Option 1
|
||||
Description 1
|
||||
2. Option 2
|
||||
Description 2
|
||||
▼
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
@@ -75,6 +113,45 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 2`] = `
|
||||
"Choose an option
|
||||
|
||||
● 1. Option 1
|
||||
Description 1
|
||||
2. Option 2
|
||||
Description 2
|
||||
3. Option 3
|
||||
Description 3
|
||||
4. Option 4
|
||||
Description 4
|
||||
5. Option 5
|
||||
Description 5
|
||||
6. Option 6
|
||||
Description 6
|
||||
7. Option 7
|
||||
Description 7
|
||||
8. Option 8
|
||||
Description 8
|
||||
9. Option 9
|
||||
Description 9
|
||||
10. Option 10
|
||||
Description 10
|
||||
11. Option 11
|
||||
Description 11
|
||||
12. Option 12
|
||||
Description 12
|
||||
13. Option 13
|
||||
Description 13
|
||||
14. Option 14
|
||||
Description 14
|
||||
15. Option 15
|
||||
Description 15
|
||||
16. Enter a custom value
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = `
|
||||
"What should we name this component?
|
||||
|
||||
@@ -217,3 +294,19 @@ exports[`AskUserDialog > verifies "All of the above" visual state with snapshot
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > verifies "All of the above" visual state with snapshot 2`] = `
|
||||
"Which features?
|
||||
(Select all that apply)
|
||||
|
||||
1. [x] TypeScript
|
||||
2. [x] ESLint
|
||||
● 3. [x] All of the above
|
||||
Select all options
|
||||
4. [ ] Enter a custom value
|
||||
Done
|
||||
Finish selection
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -14,24 +14,12 @@ Spinner Initializing...
|
||||
|
||||
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 1`] = `
|
||||
"
|
||||
Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 2`] = `
|
||||
"
|
||||
Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
|
||||
Spinner Initializing...
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = `
|
||||
"
|
||||
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 2`] = `
|
||||
"
|
||||
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
|
||||
Spinner Initializing...
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -27,6 +27,33 @@ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > bubbles up Ctrl+C when feedback is empty while editing 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Type your feedback...
|
||||
|
||||
Enter to submit · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 1`] = `
|
||||
"Overview
|
||||
|
||||
@@ -54,6 +81,33 @@ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Add tests
|
||||
|
||||
Enter to submit · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > displays error state when file read fails 1`] = `
|
||||
" Error reading plan: File not found
|
||||
"
|
||||
@@ -140,6 +194,33 @@ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > bubbles up Ctrl+C when feedback is empty while editing 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Type your feedback...
|
||||
|
||||
Enter to submit · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 1`] = `
|
||||
"Overview
|
||||
|
||||
@@ -167,6 +248,33 @@ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 2`] = `
|
||||
"Overview
|
||||
|
||||
Add user authentication to the CLI application.
|
||||
|
||||
Implementation Steps
|
||||
|
||||
1. Create src/auth/AuthService.ts with login/logout methods
|
||||
2. Add session storage in src/storage/SessionStore.ts
|
||||
3. Update src/commands/index.ts to check auth status
|
||||
4. Add tests in src/auth/__tests__/
|
||||
|
||||
Files to Modify
|
||||
|
||||
- src/index.ts - Add auth middleware
|
||||
- src/config.ts - Add auth configuration options
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Add tests
|
||||
|
||||
Enter to submit · Ctrl+X to edit plan · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > displays error state when file read fails 1`] = `
|
||||
" Error reading plan: File not found
|
||||
"
|
||||
|
||||
@@ -6,6 +6,8 @@ exports[`HooksDialog > snapshots > renders empty hooks dialog 1`] = `
|
||||
│ │
|
||||
│ No hooks configured. │
|
||||
│ │
|
||||
│ (Press Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
@@ -31,6 +33,8 @@ exports[`HooksDialog > snapshots > renders hook using command as name when name
|
||||
│ Tip: Use /hooks enable <hook-name> or /hooks disable <hook-name> to toggle individual hooks. Use │
|
||||
│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
|
||||
│ │
|
||||
│ (Press Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
@@ -57,6 +61,8 @@ exports[`HooksDialog > snapshots > renders hook with all metadata (matcher, sequ
|
||||
│ Tip: Use /hooks enable <hook-name> or /hooks disable <hook-name> to toggle individual hooks. Use │
|
||||
│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
|
||||
│ │
|
||||
│ (Press Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
@@ -93,6 +99,8 @@ exports[`HooksDialog > snapshots > renders hooks grouped by event name with enab
|
||||
│ Tip: Use /hooks enable <hook-name> or /hooks disable <hook-name> to toggle individual hooks. Use │
|
||||
│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
|
||||
│ │
|
||||
│ (Press Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
@@ -119,6 +127,8 @@ exports[`HooksDialog > snapshots > renders single hook with security warning, so
|
||||
│ Tip: Use /hooks enable <hook-name> or /hooks disable <hook-name> to toggle individual hooks. Use │
|
||||
│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │
|
||||
│ │
|
||||
│ (Press Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -78,6 +78,27 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> [Pasted Text: 10 lines]
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 5`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> [Pasted Text: 10 lines]
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> [Pasted Text: 10 lines]
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> Type your message or @path/to/file
|
||||
|
||||
@@ -23,7 +23,7 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
|
||||
│ │
|
||||
│ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │
|
||||
│ │
|
||||
│ To resume this session: gemini --resume test-session │
|
||||
│ Tip: Resume from any folder using gemini --resume test-session or /resume │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -735,6 +735,15 @@ export const ToolConfirmationMessage: React.FC<
|
||||
paddingTop={0}
|
||||
paddingBottom={handlesOwnUI ? 0 : 1}
|
||||
>
|
||||
{/* System message from hook */}
|
||||
{confirmationDetails.systemMessage && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.status.warning}>
|
||||
{confirmationDetails.systemMessage}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{handlesOwnUI ? (
|
||||
bodyContent
|
||||
) : (
|
||||
|
||||
@@ -447,6 +447,28 @@ describe('BaseSelectionList', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should correctly calculate scroll offset during the initial render phase', async () => {
|
||||
// Verify that the component correctly calculates the scroll offset during the
|
||||
// initial render pass when starting with a high activeIndex.
|
||||
// List length 10, max items 3, activeIndex 9 (last item).
|
||||
const { unmount } = await renderScrollableList(9);
|
||||
|
||||
const renderedItemValues = mockRenderItem.mock.calls.map(
|
||||
(call) => call[0].value,
|
||||
);
|
||||
|
||||
// Item 1 (index 0) should not be rendered if the scroll offset is correctly
|
||||
// synchronized with the activeIndex from the start.
|
||||
expect(renderedItemValues).not.toContain('Item 1');
|
||||
|
||||
// The items at the end of the list should be rendered.
|
||||
expect(renderedItemValues).toContain('Item 8');
|
||||
expect(renderedItemValues).toContain('Item 9');
|
||||
expect(renderedItemValues).toContain('Item 10');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle maxItemsToShow larger than the list length', async () => {
|
||||
const { lastFrame, unmount } = await renderComponent(
|
||||
{ items: longList, maxItemsToShow: 15 },
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import {
|
||||
@@ -84,20 +84,27 @@ export function BaseSelectionList<
|
||||
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
|
||||
// Handle scrolling for long lists
|
||||
useEffect(() => {
|
||||
const newScrollOffset = Math.max(
|
||||
// Derive the effective scroll offset during render to avoid "no-selection" flicker.
|
||||
// This ensures that the visibleItems calculation uses an offset that includes activeIndex.
|
||||
let effectiveScrollOffset = scrollOffset;
|
||||
if (activeIndex < effectiveScrollOffset) {
|
||||
effectiveScrollOffset = activeIndex;
|
||||
} else if (activeIndex >= effectiveScrollOffset + maxItemsToShow) {
|
||||
effectiveScrollOffset = Math.max(
|
||||
0,
|
||||
Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow),
|
||||
);
|
||||
if (activeIndex < scrollOffset) {
|
||||
setScrollOffset(activeIndex);
|
||||
} else if (activeIndex >= scrollOffset + maxItemsToShow) {
|
||||
setScrollOffset(newScrollOffset);
|
||||
}
|
||||
}, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
|
||||
}
|
||||
|
||||
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
|
||||
// Synchronize state if it changed during derivation
|
||||
if (effectiveScrollOffset !== scrollOffset) {
|
||||
setScrollOffset(effectiveScrollOffset);
|
||||
}
|
||||
|
||||
const visibleItems = items.slice(
|
||||
effectiveScrollOffset,
|
||||
effectiveScrollOffset + maxItemsToShow,
|
||||
);
|
||||
const numberColumnWidth = String(items.length).length;
|
||||
|
||||
return (
|
||||
@@ -105,14 +112,18 @@ export function BaseSelectionList<
|
||||
{/* Use conditional coloring instead of conditional rendering */}
|
||||
{showScrollArrows && items.length > maxItemsToShow && (
|
||||
<Text
|
||||
color={scrollOffset > 0 ? theme.text.primary : theme.text.secondary}
|
||||
color={
|
||||
effectiveScrollOffset > 0
|
||||
? theme.text.primary
|
||||
: theme.text.secondary
|
||||
}
|
||||
>
|
||||
▲
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{visibleItems.map((item, index) => {
|
||||
const itemIndex = scrollOffset + index;
|
||||
const itemIndex = effectiveScrollOffset + index;
|
||||
const isSelected = activeIndex === itemIndex;
|
||||
|
||||
// Determine colors based on selection and disabled state
|
||||
@@ -182,7 +193,7 @@ export function BaseSelectionList<
|
||||
{showScrollArrows && items.length > maxItemsToShow && (
|
||||
<Text
|
||||
color={
|
||||
scrollOffset + maxItemsToShow < items.length
|
||||
effectiveScrollOffset + maxItemsToShow < items.length
|
||||
? theme.text.primary
|
||||
: theme.text.secondary
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ThoughtSummary,
|
||||
ConfirmationRequest,
|
||||
QuotaStats,
|
||||
ResumeContextSwitchConfirmationRequest,
|
||||
LoopDetectionConfirmationRequest,
|
||||
HistoryItemWithoutId,
|
||||
StreamingState,
|
||||
@@ -134,6 +135,7 @@ export interface UIState {
|
||||
commandContext: CommandContext;
|
||||
commandConfirmationRequest: ConfirmationRequest | null;
|
||||
authConsentRequest: ConfirmationRequest | null;
|
||||
resumeContextSwitchConfirmationRequest: ResumeContextSwitchConfirmationRequest | null;
|
||||
confirmUpdateExtensionRequests: ConfirmationRequest[];
|
||||
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
|
||||
permissionConfirmationRequest: PermissionConfirmationRequest | null;
|
||||
|
||||
@@ -505,7 +505,9 @@ export const useSlashCommandProcessor = (
|
||||
const props = result.props as Record<string, unknown>;
|
||||
if (
|
||||
!props ||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof props['name'] !== 'string' ||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
typeof props['displayName'] !== 'string' ||
|
||||
!props['definition']
|
||||
) {
|
||||
|
||||
@@ -674,6 +674,7 @@ describe('useAtCompletion', () => {
|
||||
multiDirTmpDirs.push(addedDir);
|
||||
|
||||
const multiDirConfig = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...mockConfig,
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
getDirectories: () => [cwdDir, addedDir],
|
||||
@@ -706,6 +707,7 @@ describe('useAtCompletion', () => {
|
||||
const directories = [cwdDir];
|
||||
|
||||
const dynamicConfig = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...mockConfig,
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
getDirectories: () => [...directories],
|
||||
@@ -750,6 +752,7 @@ describe('useAtCompletion', () => {
|
||||
multiDirTmpDirs.push(dir2);
|
||||
|
||||
const multiDirConfig = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...mockConfig,
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
getDirectories: () => [dir1, dir2],
|
||||
|
||||
@@ -32,7 +32,10 @@ import type {
|
||||
Config,
|
||||
EditorType,
|
||||
AnyToolInvocation,
|
||||
AnyDeclarativeTool,
|
||||
SpanMetadata,
|
||||
CompletedToolCall,
|
||||
ToolCallRequestInfo,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
CoreToolCallStatus,
|
||||
@@ -52,7 +55,11 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part, PartListUnion } from '@google/genai';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import type { SlashCommandProcessorResult } from '../types.js';
|
||||
import type {
|
||||
SlashCommandProcessorResult,
|
||||
HistoryItemWithoutId,
|
||||
HistoryItem,
|
||||
} from '../types.js';
|
||||
import { MessageType, StreamingState } from '../types.js';
|
||||
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
@@ -243,8 +250,10 @@ describe('useGeminiStream', () => {
|
||||
let mockMarkToolsAsSubmitted: Mock;
|
||||
let handleAtCommandSpy: MockInstance;
|
||||
|
||||
const emptyHistory: any[] = [];
|
||||
let capturedOnComplete: any = null;
|
||||
const emptyHistory: HistoryItem[] = [];
|
||||
let capturedOnComplete:
|
||||
| ((tools: CompletedToolCall[]) => Promise<void>)
|
||||
| null = null;
|
||||
const mockGetPreferredEditor = vi.fn(() => 'vscode' as EditorType);
|
||||
const mockOnAuthError = vi.fn();
|
||||
const mockPerformMemoryRefresh = vi.fn(() => Promise.resolve());
|
||||
@@ -403,13 +412,17 @@ describe('useGeminiStream', () => {
|
||||
lastToolCalls,
|
||||
mockScheduleToolCalls,
|
||||
mockMarkToolsAsSubmitted,
|
||||
(updater: any) => {
|
||||
(
|
||||
updater:
|
||||
| TrackedToolCall[]
|
||||
| ((prev: TrackedToolCall[]) => TrackedToolCall[]),
|
||||
) => {
|
||||
lastToolCalls =
|
||||
typeof updater === 'function' ? updater(lastToolCalls) : updater;
|
||||
rerender({ ...initialProps, toolCalls: lastToolCalls });
|
||||
},
|
||||
(...args: any[]) => {
|
||||
mockCancelAllToolCalls(...args);
|
||||
(signal: AbortSignal) => {
|
||||
mockCancelAllToolCalls(signal);
|
||||
lastToolCalls = lastToolCalls.map((tc) => {
|
||||
if (
|
||||
tc.status === CoreToolCallStatus.AwaitingApproval ||
|
||||
@@ -970,7 +983,7 @@ describe('useGeminiStream', () => {
|
||||
});
|
||||
|
||||
it('should stop agent execution immediately when a tool call returns STOP_EXECUTION error', async () => {
|
||||
const stopExecutionToolCalls: TrackedToolCall[] = [
|
||||
const stopExecutionToolCalls: TrackedCompletedToolCall[] = [
|
||||
{
|
||||
request: {
|
||||
callId: 'stop-call',
|
||||
@@ -1042,7 +1055,7 @@ describe('useGeminiStream', () => {
|
||||
});
|
||||
|
||||
it('should add a compact suppressed-error note before STOP_EXECUTION terminal info in low verbosity mode', async () => {
|
||||
const stopExecutionToolCalls: TrackedToolCall[] = [
|
||||
const stopExecutionToolCalls: TrackedCompletedToolCall[] = [
|
||||
{
|
||||
request: {
|
||||
callId: 'stop-call',
|
||||
@@ -1069,6 +1082,7 @@ describe('useGeminiStream', () => {
|
||||
} as unknown as TrackedCompletedToolCall,
|
||||
];
|
||||
const lowVerbositySettings = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...mockLoadedSettings,
|
||||
merged: {
|
||||
...mockLoadedSettings.merged,
|
||||
@@ -1922,6 +1936,120 @@ describe('useGeminiStream', () => {
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should record client-initiated tool calls in GeminiChat history', async () => {
|
||||
const { result, client: mockGeminiClient } = await renderTestHook();
|
||||
|
||||
mockHandleSlashCommand.mockResolvedValue({
|
||||
type: 'schedule_tool',
|
||||
toolName: 'activate_skill',
|
||||
toolArgs: { name: 'test-skill' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('/test-skill');
|
||||
});
|
||||
|
||||
// Simulate tool completion
|
||||
const completedTool = {
|
||||
request: {
|
||||
callId: 'test-call-id',
|
||||
name: 'activate_skill',
|
||||
args: { name: 'test-skill' },
|
||||
isClientInitiated: true,
|
||||
},
|
||||
status: CoreToolCallStatus.Success,
|
||||
invocation: {
|
||||
getDescription: () => 'Activating skill test-skill',
|
||||
},
|
||||
tool: {
|
||||
isOutputMarkdown: true,
|
||||
},
|
||||
response: {
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'activate_skill',
|
||||
response: { content: 'skill instructions' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as TrackedCompletedToolCall;
|
||||
|
||||
await act(async () => {
|
||||
if (capturedOnComplete) {
|
||||
await capturedOnComplete([completedTool]);
|
||||
}
|
||||
});
|
||||
|
||||
// Verify that the tool call and response were added to GeminiChat history
|
||||
expect(mockGeminiClient.addHistory).toHaveBeenCalledWith({
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: 'activate_skill',
|
||||
args: { name: 'test-skill' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockGeminiClient.addHistory).toHaveBeenCalledWith({
|
||||
role: 'user',
|
||||
parts: completedTool.response.responseParts,
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT record other client-initiated tool calls (like save_memory) in history', async () => {
|
||||
const { result, client: mockGeminiClient } = await renderTestHook();
|
||||
|
||||
mockHandleSlashCommand.mockResolvedValue({
|
||||
type: 'schedule_tool',
|
||||
toolName: 'save_memory',
|
||||
toolArgs: { fact: 'test fact' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('/memory add "test fact"');
|
||||
});
|
||||
|
||||
// Simulate tool completion
|
||||
const completedTool = {
|
||||
request: {
|
||||
callId: 'test-call-id',
|
||||
name: 'save_memory',
|
||||
args: { fact: 'test fact' },
|
||||
isClientInitiated: true,
|
||||
},
|
||||
status: CoreToolCallStatus.Success,
|
||||
invocation: {
|
||||
getDescription: () => 'Saving memory',
|
||||
},
|
||||
tool: {
|
||||
isOutputMarkdown: true,
|
||||
},
|
||||
response: {
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'save_memory',
|
||||
response: { success: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as TrackedCompletedToolCall;
|
||||
|
||||
await act(async () => {
|
||||
if (capturedOnComplete) {
|
||||
await capturedOnComplete([completedTool]);
|
||||
}
|
||||
});
|
||||
|
||||
// Verify that addHistory was NOT called
|
||||
expect(mockGeminiClient.addHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Memory Refresh on save_memory', () => {
|
||||
@@ -1949,7 +2077,7 @@ describe('useGeminiStream', () => {
|
||||
displayName: 'save_memory',
|
||||
description: 'Saves memory',
|
||||
build: vi.fn(),
|
||||
} as any,
|
||||
} as unknown as AnyDeclarativeTool,
|
||||
invocation: {
|
||||
getDescription: () => `Mock description`,
|
||||
} as unknown as AnyToolInvocation,
|
||||
@@ -2023,6 +2151,7 @@ describe('useGeminiStream', () => {
|
||||
);
|
||||
|
||||
const testConfig = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...mockConfig,
|
||||
getContentGenerator: vi.fn(),
|
||||
getContentGeneratorConfig: vi.fn(() => ({
|
||||
@@ -2188,7 +2317,7 @@ describe('useGeminiStream', () => {
|
||||
displayName: 'replace',
|
||||
description: 'Replace text',
|
||||
build: vi.fn(),
|
||||
} as any,
|
||||
} as unknown as AnyDeclarativeTool,
|
||||
invocation: {
|
||||
getDescription: () => 'Mock description',
|
||||
} as unknown as AnyToolInvocation,
|
||||
@@ -2229,7 +2358,7 @@ describe('useGeminiStream', () => {
|
||||
displayName: 'write_file',
|
||||
description: 'Write file',
|
||||
build: vi.fn(),
|
||||
} as any,
|
||||
} as unknown as AnyDeclarativeTool,
|
||||
invocation: {
|
||||
getDescription: () => 'Mock description',
|
||||
} as unknown as AnyToolInvocation,
|
||||
@@ -2574,14 +2703,14 @@ describe('useGeminiStream', () => {
|
||||
|
||||
it('should flush pending text rationale before scheduling tool calls to ensure correct history order', async () => {
|
||||
const addItemOrder: string[] = [];
|
||||
let capturedOnComplete: any;
|
||||
let capturedOnComplete: (tools: CompletedToolCall[]) => Promise<void>;
|
||||
|
||||
const mockScheduleToolCalls = vi.fn(async (requests) => {
|
||||
addItemOrder.push('scheduleToolCalls_START');
|
||||
// Simulate tools completing and triggering onComplete immediately.
|
||||
// This mimics the behavior that caused the regression where tool results
|
||||
// were added to history during the await scheduleToolCalls(...) block.
|
||||
const tools = requests.map((r: any) => ({
|
||||
const tools = requests.map((r: ToolCallRequestInfo) => ({
|
||||
request: r,
|
||||
status: CoreToolCallStatus.Success,
|
||||
tool: { displayName: r.name, name: r.name },
|
||||
@@ -2596,7 +2725,7 @@ describe('useGeminiStream', () => {
|
||||
addItemOrder.push('scheduleToolCalls_END');
|
||||
});
|
||||
|
||||
mockAddItem.mockImplementation((item: any) => {
|
||||
mockAddItem.mockImplementation((item: HistoryItemWithoutId) => {
|
||||
addItemOrder.push(`addItem:${item.type}`);
|
||||
});
|
||||
|
||||
@@ -2826,6 +2955,7 @@ describe('useGeminiStream', () => {
|
||||
describe('Thought Reset', () => {
|
||||
it('should keep full thinking entries in history when mode is full', async () => {
|
||||
const fullThinkingSettings: LoadedSettings = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...mockLoadedSettings,
|
||||
merged: {
|
||||
...mockLoadedSettings.merged,
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
getPlanModeExitMessage,
|
||||
isBackgroundExecutionData,
|
||||
Kind,
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Config,
|
||||
@@ -548,11 +549,9 @@ export const useGeminiStream = (
|
||||
if (tc.request.name === ASK_USER_TOOL_NAME && isInProgress) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
tc.status !== 'scheduled' &&
|
||||
tc.status !== 'validating' &&
|
||||
tc.status !== 'awaiting_approval'
|
||||
);
|
||||
// ToolGroupMessage now shows all non-canceled tools, so they are visible
|
||||
// in pending and we need to draw the closing border for them.
|
||||
return true;
|
||||
});
|
||||
|
||||
if (
|
||||
@@ -1658,7 +1657,7 @@ export const useGeminiStream = (
|
||||
) {
|
||||
let awaitingApprovalCalls = toolCalls.filter(
|
||||
(call): call is TrackedWaitingToolCall =>
|
||||
call.status === 'awaiting_approval',
|
||||
call.status === 'awaiting_approval' && !call.request.forcedAsk,
|
||||
);
|
||||
|
||||
// For AUTO_EDIT mode, only approve edit tools (replace, write_file)
|
||||
@@ -1722,6 +1721,36 @@ export const useGeminiStream = (
|
||||
);
|
||||
if (clientTools.length > 0) {
|
||||
markToolsAsSubmitted(clientTools.map((t) => t.request.callId));
|
||||
|
||||
if (geminiClient) {
|
||||
for (const tool of clientTools) {
|
||||
// Only manually record skill activations in the chat history.
|
||||
// Other client-initiated tools (like save_memory) update the system
|
||||
// prompt/context and don't strictly need to be in the history.
|
||||
if (tool.request.name !== ACTIVATE_SKILL_TOOL_NAME) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add both the call (model turn) and the result (user turn) to history.
|
||||
// Client-initiated calls are essentially "synthetic" turns that let
|
||||
// subsequent model calls understand what just happened in the UI.
|
||||
await geminiClient.addHistory({
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: tool.request.name,
|
||||
args: tool.request.args,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await geminiClient.addHistory({
|
||||
role: 'user',
|
||||
parts: tool.response.responseParts,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Identify new, successful save_memory calls that we haven't processed yet.
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
convertSessionToHistoryFormats,
|
||||
} from './useSessionBrowser.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { getSessionFiles, type SessionInfo } from '../../utils/sessionUtils.js';
|
||||
import { type SessionInfo } from '../../utils/sessionUtils.js';
|
||||
import {
|
||||
type Config,
|
||||
type ConversationRecord,
|
||||
@@ -42,6 +41,20 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
createCache:
|
||||
actual.createCache ??
|
||||
((<K, V>() => {
|
||||
const cache = new Map<K, V>();
|
||||
return {
|
||||
clear: () => cache.clear(),
|
||||
getOrCreate: (key: K, factory: () => V) => {
|
||||
if (!cache.has(key)) {
|
||||
cache.set(key, factory());
|
||||
}
|
||||
return cache.get(key)!;
|
||||
},
|
||||
};
|
||||
}) as typeof actual.createCache),
|
||||
uiTelemetryService: {
|
||||
clear: vi.fn(),
|
||||
hydrate: vi.fn(),
|
||||
@@ -49,45 +62,36 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const MOCKED_PROJECT_TEMP_DIR = '/test/project/temp';
|
||||
const MOCKED_CHATS_DIR = '/test/project/temp/chats';
|
||||
const MOCKED_SESSION_ID = 'test-session-123';
|
||||
const MOCKED_CURRENT_SESSION_ID = 'current-session-id';
|
||||
const MOCKED_SESSION_PATH =
|
||||
'/test/project/temp/chats/session-2025-01-01-test-session-123.json';
|
||||
|
||||
describe('useSessionBrowser', () => {
|
||||
const mockedFs = vi.mocked(fs);
|
||||
const mockedPath = vi.mocked(path);
|
||||
const mockedGetSessionFiles = vi.mocked(getSessionFiles);
|
||||
|
||||
const mockConfig = {
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn(),
|
||||
},
|
||||
setSessionId: vi.fn(),
|
||||
getSessionId: vi.fn(),
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
getChatRecordingService: vi.fn().mockReturnValue({
|
||||
deleteSession: vi.fn(),
|
||||
deleteSessionByPath: vi.fn(),
|
||||
}),
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockOnLoadHistory = vi.fn();
|
||||
const mockOnLoadHistory = vi.fn().mockResolvedValue(true);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.spyOn(coreEvents, 'emitFeedback').mockImplementation(() => {});
|
||||
mockedPath.join.mockImplementation((...args) => args.join('/'));
|
||||
vi.mocked(mockConfig.storage.getProjectTempDir).mockReturnValue(
|
||||
MOCKED_PROJECT_TEMP_DIR,
|
||||
);
|
||||
vi.mocked(mockConfig.getSessionId).mockReturnValue(
|
||||
MOCKED_CURRENT_SESSION_ID,
|
||||
);
|
||||
mockOnLoadHistory.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('should successfully resume a session', async () => {
|
||||
const MOCKED_FILENAME = 'session-2025-01-01-test-session-123.json';
|
||||
const mockConversation: ConversationRecord = {
|
||||
sessionId: 'existing-session-456',
|
||||
messages: [{ type: 'user', content: 'Hello' } as MessageRecord],
|
||||
@@ -95,9 +99,8 @@ describe('useSessionBrowser', () => {
|
||||
|
||||
const mockSession = {
|
||||
id: MOCKED_SESSION_ID,
|
||||
fileName: MOCKED_FILENAME,
|
||||
sessionPath: MOCKED_SESSION_PATH,
|
||||
} as SessionInfo;
|
||||
mockedGetSessionFiles.mockResolvedValue([mockSession]);
|
||||
mockedFs.readFile.mockResolvedValue(JSON.stringify(mockConversation));
|
||||
|
||||
const { result } = await renderHook(() =>
|
||||
@@ -107,10 +110,7 @@ describe('useSessionBrowser', () => {
|
||||
await act(async () => {
|
||||
await result.current.handleResumeSession(mockSession);
|
||||
});
|
||||
expect(mockedFs.readFile).toHaveBeenCalledWith(
|
||||
`${MOCKED_CHATS_DIR}/${MOCKED_FILENAME}`,
|
||||
'utf8',
|
||||
);
|
||||
expect(mockedFs.readFile).toHaveBeenCalledWith(MOCKED_SESSION_PATH, 'utf8');
|
||||
expect(mockConfig.setSessionId).toHaveBeenCalledWith(
|
||||
'existing-session-456',
|
||||
);
|
||||
@@ -120,10 +120,9 @@ describe('useSessionBrowser', () => {
|
||||
});
|
||||
|
||||
it('should handle file read error', async () => {
|
||||
const MOCKED_FILENAME = 'session-2025-01-01-test-session-123.json';
|
||||
const mockSession = {
|
||||
id: MOCKED_SESSION_ID,
|
||||
fileName: MOCKED_FILENAME,
|
||||
sessionPath: MOCKED_SESSION_PATH,
|
||||
} as SessionInfo;
|
||||
mockedFs.readFile.mockRejectedValue(new Error('File not found'));
|
||||
|
||||
@@ -144,10 +143,9 @@ describe('useSessionBrowser', () => {
|
||||
});
|
||||
|
||||
it('should handle JSON parse error', async () => {
|
||||
const MOCKED_FILENAME = 'invalid.json';
|
||||
const mockSession = {
|
||||
id: MOCKED_SESSION_ID,
|
||||
fileName: MOCKED_FILENAME,
|
||||
sessionPath: MOCKED_SESSION_PATH,
|
||||
} as SessionInfo;
|
||||
mockedFs.readFile.mockResolvedValue('invalid json');
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ export const useSessionBrowser = (
|
||||
uiHistory: HistoryItemWithoutId[],
|
||||
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
|
||||
resumedSessionData: ResumedSessionData,
|
||||
) => Promise<void>,
|
||||
source?: 'startup' | 'browser',
|
||||
) => Promise<boolean>,
|
||||
) => {
|
||||
const [isSessionBrowserOpen, setIsSessionBrowserOpen] = useState(false);
|
||||
|
||||
@@ -51,14 +52,13 @@ export const useSessionBrowser = (
|
||||
handleResumeSession: useCallback(
|
||||
async (session: SessionInfo) => {
|
||||
try {
|
||||
const chatsDir = path.join(
|
||||
config.storage.getProjectTempDir(),
|
||||
'chats',
|
||||
);
|
||||
|
||||
const fileName = session.fileName;
|
||||
|
||||
const originalFilePath = path.join(chatsDir, fileName);
|
||||
const originalFilePath =
|
||||
session.sessionPath ??
|
||||
path.join(
|
||||
config.storage.getProjectTempDir(),
|
||||
'chats',
|
||||
session.fileName,
|
||||
);
|
||||
|
||||
// Load up the conversation.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
@@ -68,24 +68,33 @@ export const useSessionBrowser = (
|
||||
|
||||
// Use the old session's ID to continue it.
|
||||
const existingSessionId = conversation.sessionId;
|
||||
const currentSessionId = config.getSessionId();
|
||||
config.setSessionId(existingSessionId);
|
||||
uiTelemetryService.hydrate(conversation);
|
||||
|
||||
const resumedSessionData = {
|
||||
conversation,
|
||||
filePath: originalFilePath,
|
||||
originProjectPath:
|
||||
session.originProjectPath ?? conversation.originProjectPath,
|
||||
};
|
||||
|
||||
// We've loaded it; tell the UI about it.
|
||||
setIsSessionBrowserOpen(false);
|
||||
const historyData = convertSessionToHistoryFormats(
|
||||
conversation.messages,
|
||||
);
|
||||
await onLoadHistory(
|
||||
const didResume = await onLoadHistory(
|
||||
historyData.uiHistory,
|
||||
convertSessionToClientHistory(conversation.messages),
|
||||
resumedSessionData,
|
||||
'browser',
|
||||
);
|
||||
if (!didResume) {
|
||||
config.setSessionId(currentSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// We've loaded it; tell the UI about it.
|
||||
setIsSessionBrowserOpen(false);
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback('error', 'Error resuming session:', error);
|
||||
setIsSessionBrowserOpen(false);
|
||||
@@ -108,7 +117,14 @@ export const useSessionBrowser = (
|
||||
.getGeminiClient()
|
||||
?.getChatRecordingService();
|
||||
if (chatRecordingService) {
|
||||
chatRecordingService.deleteSession(session.file);
|
||||
if (session.sessionPath) {
|
||||
chatRecordingService.deleteSessionByPath(
|
||||
session.sessionPath,
|
||||
session.id,
|
||||
);
|
||||
} else {
|
||||
chatRecordingService.deleteSession(session.file);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback('error', 'Error deleting session:', error);
|
||||
|
||||
@@ -25,6 +25,7 @@ describe('useSessionResume', () => {
|
||||
|
||||
const mockConfig = {
|
||||
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/current/project'),
|
||||
};
|
||||
|
||||
const createMockHistoryManager = (): UseHistoryManagerReturn => ({
|
||||
@@ -48,6 +49,8 @@ describe('useSessionResume', () => {
|
||||
setQuittingMessages: mockSetQuittingMessages,
|
||||
resumedSessionData: undefined,
|
||||
isAuthenticating: false,
|
||||
confirmResumeContextSwitch: undefined,
|
||||
canResumeOnStartup: undefined,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -261,6 +264,77 @@ describe('useSessionResume', () => {
|
||||
|
||||
expect(mockAddDirectories).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should confirm before resuming from a different project root', async () => {
|
||||
const confirmResumeContextSwitch = vi.fn().mockResolvedValue(true);
|
||||
const { result } = await renderHook(() =>
|
||||
useSessionResume({
|
||||
...getDefaultProps(),
|
||||
confirmResumeContextSwitch,
|
||||
}),
|
||||
);
|
||||
|
||||
const resumedData: ResumedSessionData = {
|
||||
conversation: {
|
||||
sessionId: 'test-123',
|
||||
projectHash: 'project-123',
|
||||
originProjectPath: '/original/project',
|
||||
startTime: '2025-01-01T00:00:00Z',
|
||||
lastUpdated: '2025-01-01T01:00:00Z',
|
||||
messages: [] as MessageRecord[],
|
||||
},
|
||||
filePath: '/path/to/session.json',
|
||||
originProjectPath: '/original/project',
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadHistoryForResume(
|
||||
[],
|
||||
[],
|
||||
resumedData,
|
||||
'startup',
|
||||
);
|
||||
});
|
||||
|
||||
expect(confirmResumeContextSwitch).toHaveBeenCalledWith({
|
||||
source: 'startup',
|
||||
sessionId: 'test-123',
|
||||
currentProjectPath: '/current/project',
|
||||
originProjectPath: '/original/project',
|
||||
});
|
||||
expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith([], resumedData);
|
||||
});
|
||||
|
||||
it('should abort resume when context switch confirmation is rejected', async () => {
|
||||
const confirmResumeContextSwitch = vi.fn().mockResolvedValue(false);
|
||||
const { result } = await renderHook(() =>
|
||||
useSessionResume({
|
||||
...getDefaultProps(),
|
||||
confirmResumeContextSwitch,
|
||||
}),
|
||||
);
|
||||
|
||||
const resumedData: ResumedSessionData = {
|
||||
conversation: {
|
||||
sessionId: 'test-123',
|
||||
projectHash: 'project-123',
|
||||
originProjectPath: '/original/project',
|
||||
startTime: '2025-01-01T00:00:00Z',
|
||||
lastUpdated: '2025-01-01T01:00:00Z',
|
||||
messages: [] as MessageRecord[],
|
||||
},
|
||||
filePath: '/path/to/session.json',
|
||||
originProjectPath: '/original/project',
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadHistoryForResume([], [], resumedData);
|
||||
});
|
||||
|
||||
expect(confirmResumeContextSwitch).toHaveBeenCalled();
|
||||
expect(mockHistoryManager.clearItems).not.toHaveBeenCalled();
|
||||
expect(mockGeminiClient.resumeChat).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('callback stability', () => {
|
||||
@@ -341,6 +415,48 @@ describe('useSessionResume', () => {
|
||||
expect(mockGeminiClient.resumeChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should wait for startup blockers to clear before auto-resuming', async () => {
|
||||
const conversation: ConversationRecord = {
|
||||
sessionId: 'auto-resume-blocked',
|
||||
projectHash: 'project-123',
|
||||
startTime: '2025-01-01T00:00:00Z',
|
||||
lastUpdated: '2025-01-01T01:00:00Z',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
timestamp: '2025-01-01T00:01:00Z',
|
||||
content: 'Blocked until startup is ready',
|
||||
type: 'user',
|
||||
},
|
||||
] as MessageRecord[],
|
||||
};
|
||||
let startupReady = false;
|
||||
|
||||
const { rerender } = await renderHook(() =>
|
||||
useSessionResume({
|
||||
...getDefaultProps(),
|
||||
resumedSessionData: {
|
||||
conversation,
|
||||
filePath: '/path/to/session.json',
|
||||
},
|
||||
canResumeOnStartup: () => startupReady,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockHistoryManager.clearItems).not.toHaveBeenCalled();
|
||||
expect(mockGeminiClient.resumeChat).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
startupReady = true;
|
||||
rerender();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHistoryManager.clearItems).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockGeminiClient.resumeChat).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not resume when Gemini client is not initialized', async () => {
|
||||
const conversation: ConversationRecord = {
|
||||
sessionId: 'auto-resume-123',
|
||||
|
||||
@@ -15,6 +15,16 @@ import type { Part } from '@google/genai';
|
||||
import type { HistoryItemWithoutId } from '../types.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import { convertSessionToHistoryFormats } from './useSessionBrowser.js';
|
||||
import { isSameProjectPath } from '../../utils/sessionUtils.js';
|
||||
|
||||
export type ResumeSource = 'startup' | 'browser';
|
||||
|
||||
export interface ResumeContextSwitchConfirmation {
|
||||
source: ResumeSource;
|
||||
sessionId: string;
|
||||
currentProjectPath: string;
|
||||
originProjectPath: string;
|
||||
}
|
||||
|
||||
interface UseSessionResumeParams {
|
||||
config: Config;
|
||||
@@ -24,6 +34,10 @@ interface UseSessionResumeParams {
|
||||
setQuittingMessages: (messages: null) => void;
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
isAuthenticating: boolean;
|
||||
canResumeOnStartup?: () => boolean;
|
||||
confirmResumeContextSwitch?: (
|
||||
details: ResumeContextSwitchConfirmation,
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,6 +53,8 @@ export function useSessionResume({
|
||||
setQuittingMessages,
|
||||
resumedSessionData,
|
||||
isAuthenticating,
|
||||
canResumeOnStartup = () => true,
|
||||
confirmResumeContextSwitch,
|
||||
}: UseSessionResumeParams) {
|
||||
const [isResuming, setIsResuming] = useState(false);
|
||||
|
||||
@@ -56,10 +72,31 @@ export function useSessionResume({
|
||||
uiHistory: HistoryItemWithoutId[],
|
||||
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
|
||||
resumedData: ResumedSessionData,
|
||||
) => {
|
||||
source: ResumeSource = 'browser',
|
||||
): Promise<boolean> => {
|
||||
// Wait for the client.
|
||||
if (!isGeminiClientInitialized) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const originProjectPath =
|
||||
resumedData.originProjectPath ??
|
||||
resumedData.conversation.originProjectPath;
|
||||
const currentProjectPath = config.getProjectRoot();
|
||||
const requiresConfirmation =
|
||||
!!originProjectPath &&
|
||||
!isSameProjectPath(originProjectPath, currentProjectPath);
|
||||
|
||||
if (requiresConfirmation && confirmResumeContextSwitch) {
|
||||
const confirmed = await confirmResumeContextSwitch({
|
||||
source,
|
||||
sessionId: resumedData.conversation.sessionId,
|
||||
currentProjectPath,
|
||||
originProjectPath,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setIsResuming(true);
|
||||
@@ -85,17 +122,24 @@ export function useSessionResume({
|
||||
|
||||
// Give the history to the Gemini client.
|
||||
await config.getGeminiClient()?.resumeChat(clientHistory, resumedData);
|
||||
return true;
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
'Failed to resume session. Please try again.',
|
||||
error,
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
setIsResuming(false);
|
||||
}
|
||||
},
|
||||
[config, isGeminiClientInitialized, setQuittingMessages],
|
||||
[
|
||||
config,
|
||||
confirmResumeContextSwitch,
|
||||
isGeminiClientInitialized,
|
||||
setQuittingMessages,
|
||||
],
|
||||
);
|
||||
|
||||
// Handle interactive resume from the command line (-r/--resume without -p/--prompt-interactive).
|
||||
@@ -105,6 +149,7 @@ export function useSessionResume({
|
||||
if (
|
||||
resumedSessionData &&
|
||||
!isAuthenticating &&
|
||||
canResumeOnStartup() &&
|
||||
isGeminiClientInitialized &&
|
||||
!hasLoadedResumedSession.current
|
||||
) {
|
||||
@@ -116,11 +161,13 @@ export function useSessionResume({
|
||||
historyData.uiHistory,
|
||||
convertSessionToClientHistory(resumedSessionData.conversation.messages),
|
||||
resumedSessionData,
|
||||
'startup',
|
||||
);
|
||||
}
|
||||
}, [
|
||||
resumedSessionData,
|
||||
isAuthenticating,
|
||||
canResumeOnStartup,
|
||||
isGeminiClientInitialized,
|
||||
loadHistoryForResume,
|
||||
]);
|
||||
|
||||
@@ -194,6 +194,7 @@ export class KeyBinding {
|
||||
|
||||
const key = remains;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
const isSingleChar = [...key].length === 1;
|
||||
|
||||
if (!isSingleChar && !KeyBinding.VALID_LONG_KEYS.has(key.toLowerCase())) {
|
||||
|
||||
@@ -69,9 +69,7 @@ export const DefaultAppLayout: React.FC = () => {
|
||||
<Notifications />
|
||||
<CopyModeWarning />
|
||||
|
||||
{uiState.customDialog ? (
|
||||
uiState.customDialog
|
||||
) : uiState.dialogsVisible ? (
|
||||
{uiState.dialogsVisible ? (
|
||||
<DialogManager
|
||||
terminalWidth={uiState.terminalWidth}
|
||||
addItem={uiState.historyManager.addItem}
|
||||
|
||||
@@ -226,6 +226,7 @@ export type HistoryItemModel = HistoryItemBase & {
|
||||
export type HistoryItemQuit = HistoryItemBase & {
|
||||
type: 'quit';
|
||||
duration: string;
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
export type HistoryItemToolGroup = HistoryItemBase & {
|
||||
@@ -495,6 +496,15 @@ export interface ConfirmationRequest {
|
||||
onConfirm: (confirm: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ResumeContextSwitchConfirmationRequest {
|
||||
prompt: ReactNode;
|
||||
onConfirm: () => void;
|
||||
onDecline: () => void;
|
||||
exitOnDecline?: boolean;
|
||||
declineExitMessage?: ReactNode;
|
||||
exitCode?: number;
|
||||
}
|
||||
|
||||
export interface LoopDetectionConfirmationRequest {
|
||||
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
|
||||
}
|
||||
|
||||
@@ -1,32 +1,45 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="207" viewBox="0 0 920 207">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="343" viewBox="0 0 920 343">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="207" fill="#000000" />
|
||||
<rect width="920" height="343" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
|
||||
<text x="180" y="19" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.2.3</text>
|
||||
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="27" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="0" y="104" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="121" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
|
||||
<text x="855" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="155" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
|
||||
<text x="855" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="172" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="19" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛</text>
|
||||
<text x="27" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="36" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="45" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="36" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">█▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌</text>
|
||||
<text x="18" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="27" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="36" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="90" y="53" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌</text>
|
||||
<text x="9" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="90" y="70" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs"> ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀</text>
|
||||
<text x="9" y="104" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
|
||||
<text x="99" y="104" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.2.3</text>
|
||||
<text x="0" y="155" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
|
||||
<text x="0" y="172" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
|
||||
<text x="90" y="172" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
|
||||
<text x="171" y="172" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
|
||||
<text x="0" y="189" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
|
||||
<text x="27" y="189" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
|
||||
<text x="72" y="189" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
|
||||
<text x="0" y="206" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
|
||||
<text x="0" y="223" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
|
||||
<text x="0" y="240" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="257" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
|
||||
<text x="855" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="291" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
|
||||
<text x="855" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 5.0 KiB |
@@ -1,32 +1,45 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="207" viewBox="0 0 920 207">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="343" viewBox="0 0 920 343">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="207" fill="#000000" />
|
||||
<rect width="920" height="343" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
|
||||
<text x="180" y="19" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.2.3</text>
|
||||
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="27" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="0" y="104" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="121" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="121" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="121" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">run_shell_command</text>
|
||||
<text x="855" y="121" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="138" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="138" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="155" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="155" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Running command...</text>
|
||||
<text x="855" y="155" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="172" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="19" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛</text>
|
||||
<text x="27" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="36" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="45" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="36" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">█▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌</text>
|
||||
<text x="18" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="27" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="36" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="90" y="53" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌</text>
|
||||
<text x="9" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="90" y="70" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs"> ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀</text>
|
||||
<text x="9" y="104" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
|
||||
<text x="99" y="104" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.2.3</text>
|
||||
<text x="0" y="155" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
|
||||
<text x="0" y="172" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
|
||||
<text x="90" y="172" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
|
||||
<text x="171" y="172" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
|
||||
<text x="0" y="189" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
|
||||
<text x="27" y="189" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
|
||||
<text x="72" y="189" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
|
||||
<text x="0" y="206" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
|
||||
<text x="0" y="223" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
|
||||
<text x="0" y="240" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="257" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="257" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="257" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">run_shell_command</text>
|
||||
<text x="855" y="257" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="274" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="274" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="291" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="291" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Running command...</text>
|
||||
<text x="855" y="291" fill="#87afff" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#87afff" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 5.0 KiB |
@@ -1,32 +1,45 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="207" viewBox="0 0 920 207">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="343" viewBox="0 0 920 343">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="207" fill="#000000" />
|
||||
<rect width="920" height="343" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
|
||||
<text x="180" y="19" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.2.3</text>
|
||||
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="27" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="0" y="104" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="121" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
|
||||
<text x="855" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="155" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
|
||||
<text x="855" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="172" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="19" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛</text>
|
||||
<text x="27" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="36" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▜</text>
|
||||
<text x="45" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs">▄</text>
|
||||
<text x="90" y="36" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">█▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌</text>
|
||||
<text x="18" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▗</text>
|
||||
<text x="27" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs">▟</text>
|
||||
<text x="36" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="90" y="53" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌</text>
|
||||
<text x="9" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs">▝</text>
|
||||
<text x="18" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs">▀</text>
|
||||
<text x="90" y="70" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs"> ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀</text>
|
||||
<text x="9" y="104" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
|
||||
<text x="99" y="104" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.2.3</text>
|
||||
<text x="0" y="155" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
|
||||
<text x="0" y="172" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
|
||||
<text x="90" y="172" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
|
||||
<text x="171" y="172" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
|
||||
<text x="0" y="189" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
|
||||
<text x="27" y="189" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
|
||||
<text x="72" y="189" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
|
||||
<text x="0" y="206" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
|
||||
<text x="0" y="223" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
|
||||
<text x="0" y="240" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">⊶</text>
|
||||
<text x="45" y="257" fill="#ffffff" textLength="153" lengthAdjust="spacingAndGlyphs" font-weight="bold">google_web_search</text>
|
||||
<text x="855" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="855" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="291" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs">Searching...</text>
|
||||
<text x="855" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#ffffaf" textLength="864" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────╯</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 5.0 KiB |
@@ -2,11 +2,19 @@
|
||||
|
||||
exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for a pending search dialog (google_web_search) 1`] = `
|
||||
"
|
||||
▝▜▄ Gemini CLI v1.2.3
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.2.3
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ google_web_search │
|
||||
│ │
|
||||
@@ -16,11 +24,19 @@ exports[`MainContent tool group border SVG snapshots > should render SVG snapsho
|
||||
|
||||
exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for a shell tool 1`] = `
|
||||
"
|
||||
▝▜▄ Gemini CLI v1.2.3
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.2.3
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ run_shell_command │
|
||||
│ │
|
||||
@@ -30,11 +46,19 @@ exports[`MainContent tool group border SVG snapshots > should render SVG snapsho
|
||||
|
||||
exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for an empty slice following a search tool 1`] = `
|
||||
"
|
||||
▝▜▄ Gemini CLI v1.2.3
|
||||
▝▜▄
|
||||
▗▟▀
|
||||
▝▀
|
||||
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||
|
||||
Gemini CLI v1.2.3
|
||||
|
||||
|
||||
Tips for getting started:
|
||||
1. Create GEMINI.md files to customize your interactions
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊶ google_web_search │
|
||||
│ │
|
||||
|
||||
@@ -13,12 +13,14 @@ import {
|
||||
disableModifyOtherKeys,
|
||||
enableBracketedPasteMode,
|
||||
disableBracketedPasteMode,
|
||||
disableMouseEvents,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { parseColor } from '../themes/color-utils.js';
|
||||
|
||||
export type TerminalBackgroundColor = string | undefined;
|
||||
|
||||
const TERMINAL_CLEANUP_SEQUENCE = '\x1b[<u\x1b[>4;0m\x1b[?2004l';
|
||||
const TERMINAL_CLEANUP_SEQUENCE =
|
||||
'\x1b[<u\x1b[>4;0m\x1b[?2004l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l';
|
||||
|
||||
export function cleanupTerminalOnExit() {
|
||||
try {
|
||||
@@ -33,6 +35,7 @@ export function cleanupTerminalOnExit() {
|
||||
disableKittyKeyboardProtocol();
|
||||
disableModifyOtherKeys();
|
||||
disableBracketedPasteMode();
|
||||
disableMouseEvents();
|
||||
}
|
||||
|
||||
export class TerminalCapabilityManager {
|
||||
|
||||
@@ -502,7 +502,6 @@ export function useTerminalSetupPrompt({
|
||||
if (hasBeenPrompted) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
|
||||
@@ -514,6 +514,7 @@ describe('textUtils', () => {
|
||||
const b = sanitized.b as { c: string; d: Array<string | object> };
|
||||
expect(b.c).toBe('\\u001b[32mgreen\\u001b[0m');
|
||||
expect(b.d[0]).toBe('\\u001b[33myellow\\u001b[0m');
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
if (typeof b.d[1] === 'object' && b.d[1] !== null) {
|
||||
const e = b.d[1] as { e: string };
|
||||
expect(e.e).toBe('\\u001b[34mblue\\u001b[0m');
|
||||
|
||||
@@ -40,8 +40,8 @@ const agentStrategy: FeatureToggleStrategy = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Enables an agent by ensuring it is enabled in any writable scope (User and Workspace).
|
||||
* It sets `agents.overrides.<agentName>.enabled` to `true`.
|
||||
* Enables an agent by setting `agents.overrides.<agentName>.enabled` to `true`
|
||||
* in available writable scopes (User and Workspace).
|
||||
*/
|
||||
export function enableAgent(
|
||||
settings: LoadedSettings,
|
||||
@@ -59,7 +59,8 @@ export function enableAgent(
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables an agent by setting `agents.overrides.<agentName>.enabled` to `false` in the specified scope.
|
||||
* Disables an agent by setting `agents.overrides.<agentName>.enabled` to `false`
|
||||
* in the specified scope.
|
||||
*/
|
||||
export function disableAgent(
|
||||
settings: LoadedSettings,
|
||||
|
||||
@@ -72,6 +72,46 @@ describe('cleanup', () => {
|
||||
expect(asyncFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should run cleanupFunctions BEFORE draining stdin and BEFORE runSyncCleanup', async () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
// Cleanup function
|
||||
registerCleanup(() => {
|
||||
callOrder.push('cleanup');
|
||||
});
|
||||
|
||||
// Sync cleanup function (e.g. setRawMode(false))
|
||||
registerSyncCleanup(() => {
|
||||
callOrder.push('sync');
|
||||
});
|
||||
|
||||
// Mock stdin.resume to track drainStdin
|
||||
const originalResume = process.stdin.resume;
|
||||
process.stdin.resume = vi.fn().mockImplementation(() => {
|
||||
callOrder.push('drain');
|
||||
return process.stdin;
|
||||
});
|
||||
|
||||
// Mock stdin properties for drainStdin
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await runExitCleanup();
|
||||
} finally {
|
||||
process.stdin.resume = originalResume;
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: originalIsTTY,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
expect(callOrder).toEqual(['drain', 'drain', 'sync', 'cleanup']);
|
||||
});
|
||||
|
||||
it('should continue running cleanup functions even if one throws an error', async () => {
|
||||
const errorFn = vi.fn().mockImplementation(() => {
|
||||
throw new Error('test error');
|
||||
@@ -183,6 +223,7 @@ describe('signal and TTY handling', () => {
|
||||
|
||||
const sigtermHandlers = processOnHandlers.get('SIGTERM') || [];
|
||||
expect(sigtermHandlers.length).toBeGreaterThan(0);
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
expect(typeof sigtermHandlers[0]).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,7 +59,7 @@ export function registerTelemetryConfig(config: Config) {
|
||||
|
||||
export async function runExitCleanup() {
|
||||
// drain stdin to prevent printing garbage on exit
|
||||
// https://github.com/google-gemini/gemini-cli/issues/1680
|
||||
// https://github.com/google-gemini/gemini-cli/issues/16801
|
||||
await drainStdin();
|
||||
|
||||
runSyncCleanup();
|
||||
|
||||