Compare commits

...

30 Commits

Author SHA1 Message Date
jacob314 68e33a1103 fix(test): regenerate settings documentation and schema 2026-04-23 14:34:43 -07:00
jacob314 19601955fd feat(routing): availability-aware auto-routing with best-effort pro
Adds settings and logic to detect slow/hanging Pro model requests, marking them as temporarily unavailable and automatically triggering a fallback to Flash. Introduces a proTimeoutMinutes and bestEffortPro strategy configuration.
2026-04-23 13:50:24 -07:00
hsm207 ff28d55100 fix: fatal hard-crash on loop detection via unhandled AbortError (#20108)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-04-23 16:51:21 +00:00
Emily Hedlund dba9b9a0ff feat(cli): secure .env loading and enforce workspace trust in headless mode (#25814)
Co-authored-by: galz10 <galzahavi@google.com>
Co-authored-by: davidapierce <davidapierce@google.com>
2026-04-23 16:09:14 +00:00
cynthialong0-0 a007f64d20 fix(core): only show list suggestion if the partial input is empty (#25821) 2026-04-23 14:07:06 +00:00
gemini-cli-robot d1c91f5267 chore(release): bump version to 0.41.0-nightly.20260423.gaa05b4583 (#25847) 2026-04-23 04:28:26 +00:00
Sandy Tao aa05b4583d fix(release): exclude ripgrep binaries from npm tarballs (#25841) 2026-04-22 21:01:45 -07:00
Sandy Tao 5318610c1d fix(core): support jsonl session logs in memory and summary services (#25816) 2026-04-22 23:07:39 +00:00
Sandy Tao 9c0a6864da fix(devtools): reduce memory usage and defer connection (#24496) 2026-04-22 15:21:58 -07:00
Dev Randalpura 2e12c34009 fix(ui): removed background color for input (#25339) 2026-04-22 20:27:09 +00:00
Horizon_Architect_07 2a52611e71 Fix/command injection shell (#24170)
Co-authored-by: David Pierce <davidapierce@google.com>
2026-04-22 20:00:44 +00:00
xoma-zver 1c43deee07 fix(core): handle line endings in ignore file parsing (#23895)
Co-authored-by: cynthialong0-0 <82900738+cynthialong0-0@users.noreply.github.com>
2026-04-22 18:09:36 +00:00
Kishan Patel 0758a5eb28 fix(core): use dynamic CLI version for IDE client instead of hardcoded '1.0.0' (#24414)
Co-authored-by: cynthialong0-0 <82900738+cynthialong0-0@users.noreply.github.com>
2026-04-22 16:43:48 +00:00
mini2s 607180bfb2 fix(cli): fix "/clear (new)" command (#25801) 2026-04-22 15:20:22 +00:00
Sandy Tao 6edfba481f refactor(memory): replace MemoryManagerAgent with prompt-driven memory editing across four tiers (#25716) 2026-04-22 01:21:55 +00:00
Sandy Tao ffb28c772b test(e2e): default integration tests to Flash Preview (#25753) 2026-04-21 22:21:52 +00:00
Adam Weidman d6f88f8720 fix(core): remove duplicate initialize call on agents refreshed (#25670) 2026-04-21 20:17:21 +00:00
Jason Matthew Suhari 194c779f9b fix(cli): start auto memory in ACP sessions (#25626) 2026-04-21 20:06:30 +00:00
Vedant Mahajan 189c0ac0a0 feat: add /new as alias for /clear and refine command description (#17865) 2026-04-21 20:04:40 +00:00
euxaristia c47233a474 fix(core): disable detached mode in Bun to prevent immediate SIGHUP of child processes (#22620) 2026-04-21 20:01:28 +00:00
JAYADITYA 8999a885f0 fix(cli): ensure theme dialog labels are rendered for all themes (#24599)
Co-authored-by: cynthialong0-0 <82900738+cynthialong0-0@users.noreply.github.com>
2026-04-21 19:57:15 +00:00
Coco Sheng 93a8d9001c fix(cli): use newline in shell command wrapping to avoid breaking heredocs (#25537) 2026-04-21 19:12:50 +00:00
PRAS Samin cdc5cccc13 feat: detect new files in @ recommendations with watcher based updates (#25256) 2026-04-21 18:35:14 +00:00
Mahima Shanware a4e98c0a4c fix(core): resolve nested plan directory duplication and relative path policies (#25138) 2026-04-21 18:20:57 +00:00
Spencer c260550146 feat(telemetry): add flag for enabling traces specifically (#25343) 2026-04-21 18:07:32 +00:00
Danyel Cabello 7f8f3309a6 Allow dots on GEMINI_API_KEY (#25497) 2026-04-21 11:43:39 -07:00
Muhammad Ahsan Farooq ebebbbfc20 Fix/allow for session persistence (#25176) 2026-04-21 11:20:07 -07:00
Gordon Hui 27344833cb feat(vertex): add settings for Vertex AI request routing (#25513) 2026-04-21 17:48:30 +00:00
cynthialong0-0 aee2cde1a3 feat(test): refactor the memory usage test to use metrics from CLI process instead of test runner (#25708) 2026-04-21 17:06:22 +00:00
Mundur 2c14954010 Fix: Disallow overriding IDE stdio via workspace .env (RCE) (#25022)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-04-21 10:31:10 -07:00
186 changed files with 6299 additions and 2315 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"experimental": {
"extensionReloading": true,
"modelSteering": true,
"memoryManager": true
"autoMemory": true
},
"general": {
"devtools": true
+1
View File
@@ -28,6 +28,7 @@ runs:
- name: 'Run Tests'
env:
GEMINI_API_KEY: '${{ inputs.gemini_api_key }}'
GEMINI_CLI_TRUST_WORKSPACE: true
working-directory: '${{ inputs.working-directory }}'
run: |-
echo "::group::Build"
@@ -98,6 +98,7 @@ runs:
working-directory: '${{ inputs.working-directory }}'
env:
GEMINI_API_KEY: '${{ inputs.gemini_api_key }}'
GEMINI_CLI_TRUST_WORKSPACE: true
INTEGRATION_TEST_USE_INSTALLED_GEMINI: 'true'
# We must diable CI mode here because it interferes with interactive tests.
# See https://github.com/google-gemini/gemini-cli/issues/10517
+3
View File
@@ -167,6 +167,7 @@ jobs:
- name: 'Run E2E tests'
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
KEEP_OUTPUT: 'true'
VERBOSE: 'true'
BUILD_SANDBOX_FLAGS: '--cache-from type=gha --cache-to type=gha,mode=max'
@@ -212,6 +213,7 @@ jobs:
if: "${{runner.os != 'Windows'}}"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
KEEP_OUTPUT: 'true'
SANDBOX: 'sandbox:none'
VERBOSE: 'true'
@@ -288,6 +290,7 @@ jobs:
- name: 'Run E2E tests'
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
KEEP_OUTPUT: 'true'
SANDBOX: 'sandbox:none'
VERBOSE: 'true'
+3
View File
@@ -179,6 +179,7 @@ jobs:
- name: 'Run tests and generate reports'
env:
NO_COLOR: true
GEMINI_CLI_TRUST_WORKSPACE: true
run: |
if [[ "${{ matrix.shard }}" == "cli" ]]; then
npm run test:ci --workspace "@google/gemini-cli"
@@ -267,6 +268,7 @@ jobs:
- name: 'Run tests and generate reports'
env:
NO_COLOR: true
GEMINI_CLI_TRUST_WORKSPACE: true
run: |
if [[ "${{ matrix.shard }}" == "cli" ]]; then
npm run test:ci --workspace "@google/gemini-cli" -- --coverage.enabled=false
@@ -430,6 +432,7 @@ jobs:
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
NO_COLOR: true
GEMINI_CLI_TRUST_WORKSPACE: true
NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256'
UV_THREADPOOL_SIZE: '32'
NODE_ENV: 'test'
+3
View File
@@ -62,6 +62,7 @@ jobs:
- name: 'Run E2E tests'
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
IS_DOCKER: "${{ matrix.sandbox == 'sandbox:docker' }}"
KEEP_OUTPUT: 'true'
RUNS: '${{ github.event.inputs.runs }}'
@@ -105,6 +106,7 @@ jobs:
if: "runner.os != 'Windows'"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
KEEP_OUTPUT: 'true'
RUNS: '${{ github.event.inputs.runs }}'
SANDBOX: 'sandbox:none'
@@ -159,6 +161,7 @@ jobs:
- name: 'Run E2E tests'
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
KEEP_OUTPUT: 'true'
SANDBOX: 'sandbox:none'
VERBOSE: 'true'
+1
View File
@@ -141,6 +141,7 @@ jobs:
if: "github.event_name != 'pull_request'"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_CLI_TRUST_WORKSPACE: true
run: |
echo "Running integration tests with binary..."
if [[ "${{ matrix.os }}" == 'windows-latest' ]]; then
+1
View File
@@ -52,6 +52,7 @@ These commands are available within the interactive REPL.
| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode |
| `--worktree` | `-w` | string | - | Start Gemini in a new git worktree. If no name is provided, one is generated automatically. Requires `experimental.worktrees: true` in settings. |
| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution |
| `--skip-trust` | - | boolean | `false` | Trust the current workspace for this session, skipping the folder trust check. |
| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo`, `plan` |
| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. |
| `--experimental-acp` | - | boolean | - | Start in ACP (Agent Code Pilot) mode. **Experimental feature.** |
+1 -2
View File
@@ -331,7 +331,6 @@ Storage whenever Gemini CLI exits Plan Mode to start the implementation.
#!/usr/bin/env bash
# Extract the plan filename from the tool input JSON
plan_filename=$(jq -r '.tool_input.plan_filename // empty')
plan_filename=$(basename -- "$plan_filename")
# Construct the absolute path using the GEMINI_PLANS_DIR environment variable
plan_path="$GEMINI_PLANS_DIR/$plan_filename"
@@ -360,7 +359,7 @@ To register this `AfterTool` hook, add it to your `settings.json`:
{
"name": "archive-plan",
"type": "command",
"command": "./.gemini/hooks/archive-plan.sh"
"command": "~/.gemini/hooks/archive-plan.sh"
}
]
}
+23 -20
View File
@@ -99,13 +99,16 @@ they appear in the UI.
### Model
| UI Label | Setting | Description | Default |
| ----------------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ----------- |
| Model | `model.name` | The Gemini model to use for conversations. | `undefined` |
| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
| Context Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` |
| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` |
| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
| UI Label | Setting | Description | Default |
| --------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------- |
| Model | `model.name` | The Gemini model to use for conversations. | `undefined` |
| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
| Context Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` |
| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` |
| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
| Best Effort Pro | `model.autoRouting.bestEffortPro` | Always prefer the Pro model unless it is unavailable (e.g., due to timeouts or quota), ignoring other routing hints. | `false` |
| Pro Timeout (Minutes) | `model.autoRouting.proTimeoutMinutes` | If a Pro request takes longer than this many minutes, it will be marked as temporarily unavailable and fallback to Flash. | `5` |
| Pro Timeout Fallback Duration (Minutes) | `model.autoRouting.proTimeoutFallbackDurationMinutes` | How long to route to Flash after Pro times out. | `60` |
### Agents
@@ -161,19 +164,19 @@ they appear in the UI.
### Experimental
| UI Label | Setting | Description | Default |
| ---------------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
| Auto-start LiteRT Server | `experimental.gemmaModelRouter.autoStartServer` | Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled. | `false` |
| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` |
| Auto Memory | `experimental.autoMemory` | Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox. | `false` |
| Use the generalist profile to manage agent contexts. | `experimental.generalistProfile` | Suitable for general coding and software development tasks. | `false` |
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
| UI Label | Setting | Description | Default |
| ---------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
| Auto-start LiteRT Server | `experimental.gemmaModelRouter.autoStartServer` | Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled. | `false` |
| Memory v2 | `experimental.memoryV2` | Disable the built-in save_memory tool and let the main agent persist project context by editing markdown files directly with edit/write_file. Route facts across four tiers: team-shared conventions go to project GEMINI.md files, project-specific personal notes go to the per-project private memory folder (MEMORY.md as index + sibling .md files for detail), and cross-project personal preferences go to the global ~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit — settings, credentials, etc. remain off-limits). Set to false to fall back to the legacy save_memory tool. | `true` |
| Auto Memory | `experimental.autoMemory` | Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox. | `false` |
| Use the generalist profile to manage agent contexts. | `experimental.generalistProfile` | Suitable for general coding and software development tasks. | `false` |
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
### Skills
+18 -11
View File
@@ -35,17 +35,18 @@ The observability system provides:
You control telemetry behavior through the `.gemini/settings.json` file.
Environment variables can override these settings.
| Setting | Environment Variable | Description | Values | Default |
| -------------- | -------------------------------- | --------------------------------------------------- | ----------------- | ----------------------- |
| `enabled` | `GEMINI_TELEMETRY_ENABLED` | Enable or disable telemetry | `true`/`false` | `false` |
| `target` | `GEMINI_TELEMETRY_TARGET` | Where to send telemetry data | `"gcp"`/`"local"` | `"local"` |
| `otlpEndpoint` | `GEMINI_TELEMETRY_OTLP_ENDPOINT` | OTLP collector endpoint | URL string | `http://localhost:4317` |
| `otlpProtocol` | `GEMINI_TELEMETRY_OTLP_PROTOCOL` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` |
| `outfile` | `GEMINI_TELEMETRY_OUTFILE` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - |
| `logPrompts` | `GEMINI_TELEMETRY_LOG_PROMPTS` | Include prompts in telemetry logs | `true`/`false` | `true` |
| `useCollector` | `GEMINI_TELEMETRY_USE_COLLECTOR` | Use external OTLP collector (advanced) | `true`/`false` | `false` |
| `useCliAuth` | `GEMINI_TELEMETRY_USE_CLI_AUTH` | Use CLI credentials for telemetry (GCP target only) | `true`/`false` | `false` |
| - | `GEMINI_CLI_SURFACE` | Optional custom label for traffic reporting | string | - |
| Setting | Environment Variable | Description | Values | Default |
| -------------- | --------------------------------- | --------------------------------------------------- | ----------------- | ----------------------- |
| `enabled` | `GEMINI_TELEMETRY_ENABLED` | Enable or disable telemetry | `true`/`false` | `false` |
| `traces` | `GEMINI_TELEMETRY_TRACES_ENABLED` | Enable detailed attribute tracing | `true`/`false` | `false` |
| `target` | `GEMINI_TELEMETRY_TARGET` | Where to send telemetry data | `"gcp"`/`"local"` | `"local"` |
| `otlpEndpoint` | `GEMINI_TELEMETRY_OTLP_ENDPOINT` | OTLP collector endpoint | URL string | `http://localhost:4317` |
| `otlpProtocol` | `GEMINI_TELEMETRY_OTLP_PROTOCOL` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` |
| `outfile` | `GEMINI_TELEMETRY_OUTFILE` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - |
| `logPrompts` | `GEMINI_TELEMETRY_LOG_PROMPTS` | Include prompts in telemetry logs | `true`/`false` | `true` |
| `useCollector` | `GEMINI_TELEMETRY_USE_COLLECTOR` | Use external OTLP collector (advanced) | `true`/`false` | `false` |
| `useCliAuth` | `GEMINI_TELEMETRY_USE_CLI_AUTH` | Use CLI credentials for telemetry (GCP target only) | `true`/`false` | `false` |
| - | `GEMINI_CLI_SURFACE` | Optional custom label for traffic reporting | string | - |
**Note on boolean environment variables:** For boolean settings like `enabled`,
setting the environment variable to `true` or `1` enables the feature.
@@ -1235,6 +1236,12 @@ These metrics follow standard [OpenTelemetry GenAI semantic conventions].
Traces provide an "under-the-hood" view of agent and backend operations. Use
traces to debug tool interactions and optimize performance.
<!-- prettier-ignore -->
> [!NOTE]
> Detailed trace attributes (like full prompts and tool outputs) are disabled by default
> to minimize overhead. You must explicitly set `telemetry.traces` to `true` (or set
> `GEMINI_TELEMETRY_TRACES_ENABLED=true`) to capture them.
Every trace captures rich metadata via standard span attributes.
<details open>
+24
View File
@@ -100,6 +100,30 @@ protect you. In this mode, the following features are disabled:
Granting trust to a folder unlocks the full functionality of Gemini CLI for that
workspace.
## Headless and automated environments
When running Gemini CLI in a headless environment (for example, a CI/CD
pipeline) where interactive prompts are not possible, the trust dialog cannot be
displayed. If the folder is untrusted and the Folder Trust feature is enabled,
the CLI will throw a `FatalUntrustedWorkspaceError` and exit.
To proceed in these environments, you can bypass the trust check using one of
the following methods:
- **Command-line flag:** Run the CLI with the `--skip-trust` flag.
- **Environment variable:** Set the `GEMINI_CLI_TRUST_WORKSPACE=true`
environment variable.
These methods will trust the current workspace for the duration of the session
without prompting.
## Overriding the trust file location
By default, trust settings are saved to `~/.gemini/trustedFolders.json`. If you
need to store this file in a different location, you can set the
`GEMINI_CLI_TRUSTED_FOLDERS_PATH` environment variable to the desired absolute
file path.
## Managing your trust settings
If you need to change a decision or see all your settings, you have a couple of
+63 -7
View File
@@ -436,6 +436,20 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `"ask"`
- **Values:** `"ask"`, `"always"`, `"never"`
- **`billing.vertexAi.requestType`** (enum):
- **Description:** Sets the X-Vertex-AI-LLM-Request-Type header for Vertex AI
requests.
- **Default:** `undefined`
- **Values:** `"dedicated"`, `"shared"`
- **Requires restart:** Yes
- **`billing.vertexAi.sharedRequestType`** (enum):
- **Description:** Sets the X-Vertex-AI-LLM-Shared-Request-Type header for
Vertex AI requests.
- **Default:** `undefined`
- **Values:** `"priority"`, `"flex"`
- **Requires restart:** Yes
#### `model`
- **`model.name`** (string):
@@ -469,6 +483,20 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Skip the next speaker check.
- **Default:** `true`
- **`model.autoRouting.bestEffortPro`** (boolean):
- **Description:** Always prefer the Pro model unless it is unavailable (e.g.,
due to timeouts or quota), ignoring other routing hints.
- **Default:** `false`
- **`model.autoRouting.proTimeoutMinutes`** (number):
- **Description:** If a Pro request takes longer than this many minutes, it
will be marked as temporarily unavailable and fallback to Flash.
- **Default:** `5`
- **`model.autoRouting.proTimeoutFallbackDurationMinutes`** (number):
- **Description:** How long to route to Flash after Pro times out.
- **Default:** `60`
#### `modelConfigs`
- **`modelConfigs.aliases`** (object):
@@ -1359,6 +1387,12 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **Requires restart:** Yes
- **`context.fileFiltering.enableFileWatcher`** (boolean):
- **Description:** Enable file watcher updates for @ file suggestions
(experimental).
- **Default:** `false`
- **Requires restart:** Yes
- **`context.fileFiltering.enableRecursiveFileSearch`** (boolean):
- **Description:** Enable recursive file search functionality when completing
@ references in the prompt.
@@ -1668,8 +1702,10 @@ their corresponding top-level category object in your `settings.json` file.
- **Requires restart:** Yes
- **`experimental.jitContext`** (boolean):
- **Description:** Enable Just-In-Time (JIT) context loading.
- **Default:** `false`
- **Description:** Enable Just-In-Time (JIT) context loading. Defaults to
true; set to false to opt out and load all GEMINI.md files into the system
instruction up-front.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.useOSC52Paste`** (boolean):
@@ -1734,11 +1770,17 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `"gemma3-1b-gpu-custom"`
- **Requires restart:** Yes
- **`experimental.memoryManager`** (boolean):
- **Description:** Replace the built-in save_memory tool with a memory manager
subagent that supports adding, removing, de-duplicating, and organizing
memories.
- **Default:** `false`
- **`experimental.memoryV2`** (boolean):
- **Description:** Disable the built-in save_memory tool and let the main
agent persist project context by editing markdown files directly with
edit/write_file. Route facts across four tiers: team-shared conventions go
to project GEMINI.md files, project-specific personal notes go to the
per-project private memory folder (MEMORY.md as index + sibling .md files
for detail), and cross-project personal preferences go to the global
~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit
— settings, credentials, etc. remain off-limits). Set to false to fall back
to the legacy save_memory tool.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.autoMemory`** (boolean):
@@ -1998,6 +2040,8 @@ see [Telemetry](../cli/telemetry.md).
- **Properties:**
- **`enabled`** (boolean): Whether or not telemetry is enabled.
- **`traces`** (boolean): Whether detailed traces with large attributes (like
tool outputs and file reads) are captured. Defaults to `false`.
- **`target`** (string): The destination for collected telemetry. Supported
values are `local` and `gcp`.
- **`otlpEndpoint`** (string): The endpoint for the OTLP Exporter.
@@ -2126,6 +2170,14 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
- Overrides the hardcoded default
- Example: `export GEMINI_MODEL="gemini-3-flash-preview"` (Windows PowerShell:
`$env:GEMINI_MODEL="gemini-3-flash-preview"`)
- **`GEMINI_CLI_TRUST_WORKSPACE`**:
- If set to `"true"`, trusts the current workspace for the duration of the
session, bypassing the folder trust check.
- Useful for headless environments (for example, CI/CD pipelines).
- **`GEMINI_CLI_TRUSTED_FOLDERS_PATH`**:
- Overrides the default location for the `trustedFolders.json` file.
- Useful if you want to store this configuration in a custom location instead
of the default `~/.gemini/`.
- **`GEMINI_CLI_IDE_PID`**:
- Manually specifies the PID of the IDE process to use for integration. This
is useful when running Gemini CLI in a standalone terminal while still
@@ -2198,6 +2250,10 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
- Set to `true` or `1` to enable telemetry. Any other value is treated as
disabling it.
- Overrides the `telemetry.enabled` setting.
- **`GEMINI_TELEMETRY_TRACES_ENABLED`**:
- Set to `true` or `1` to enable detailed tracing with large attributes. Any
other value is treated as disabling it.
- Overrides the `telemetry.traces` setting.
- **`GEMINI_TELEMETRY_TARGET`**:
- Sets the telemetry target (`local` or `gcp`).
- Overrides the `telemetry.target` setting.
+45 -1
View File
@@ -305,7 +305,7 @@ describe('plan_mode', () => {
settings,
},
prompt:
'Enter plan mode and plan to create a new module called foo. The plan should be saved as foo-plan.md. Then, exit plan mode.',
'I agree with your strategy. Please enter plan mode and draft the plan to create a new module called foo. The plan should be saved as foo-plan.md. Then, exit plan mode.',
assert: async (rig, result) => {
const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode');
expect(
@@ -376,4 +376,48 @@ describe('plan_mode', () => {
assertModelHasOutput(result);
},
});
evalTest('USUALLY_PASSES', {
name: 'should handle nested plan directories correctly',
suiteName: 'plan_mode',
suiteType: 'behavioral',
approvalMode: ApprovalMode.PLAN,
params: {
settings,
},
prompt:
'Please create a new architectural plan in a nested folder called "architecture/frontend-v2.md" within the plans directory. The plan should contain the text "# Frontend V2 Plan". Then, exit plan mode',
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
const toolLogs = rig.readToolLogs();
const writeCalls = toolLogs.filter((log) =>
['write_file', 'replace'].includes(log.toolRequest.name),
);
const wroteToNestedPath = writeCalls.some((log) => {
try {
const args = JSON.parse(log.toolRequest.args);
if (!args.file_path) return false;
// In plan mode, paths can be passed as relative (architecture/frontend-v2.md)
// or they might be resolved as absolute by the tool depending on the exact mock state.
// We strictly ensure it ends exactly with the expected nested path and doesn't contain extra nesting.
const normalizedPath = args.file_path.replace(/\\/g, '/');
return (
normalizedPath === 'architecture/frontend-v2.md' ||
normalizedPath.endsWith('/plans/architecture/frontend-v2.md')
);
} catch {
return false;
}
});
expect(
wroteToNestedPath,
'Expected model to successfully target the nested plan file path',
).toBe(true);
assertModelHasOutput(result);
},
});
});
+291 -21
View File
@@ -283,7 +283,7 @@ describe('save_memory', () => {
name: proactiveMemoryFromLongSession,
params: {
settings: {
experimental: { memoryManager: true },
experimental: { memoryV2: true },
},
},
messages: [
@@ -341,29 +341,75 @@ describe('save_memory', () => {
prompt:
'Please save any persistent preferences or facts about me from our conversation to memory.',
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall(
'invoke_agent',
undefined,
(args) => /save_memory/i.test(args) && /vitest/i.test(args),
);
// Under experimental.memoryV2, the agent persists memories by
// editing markdown files directly with write_file or replace — not via
// a save_memory subagent. The user said "I always prefer Vitest over
// Jest for testing in all my projects" — that matches the new
// cross-project cue phrase ("across all my projects"), so under the
// 4-tier model the correct destination is the global personal memory
// file (~/.gemini/GEMINI.md). It must NOT land in a committed project
// GEMINI.md (that tier is for team conventions) or the per-project
// private memory folder (that tier is for project-specific personal
// notes). The chat history mixes this durable preference with
// transient debugging chatter, so the eval also verifies the agent
// picks out the persistent fact among the noise.
await rig.waitForToolCall('write_file').catch(() => {});
const writeCalls = rig
.readToolLogs()
.filter((log) =>
['write_file', 'replace'].includes(log.toolRequest.name),
);
const wroteVitestToGlobal = writeCalls.some((log) => {
const args = log.toolRequest.args;
return (
/\.gemini\/GEMINI\.md/i.test(args) &&
!/tmp\/[^/]+\/memory/i.test(args) &&
/vitest/i.test(args)
);
});
expect(
wasToolCalled,
'Expected invoke_agent to be called with save_memory agent and the Vitest preference from the conversation history',
wroteVitestToGlobal,
'Expected the cross-project Vitest preference to be written to the global personal memory file (~/.gemini/GEMINI.md) via write_file or replace',
).toBe(true);
const leakedToCommittedProject = writeCalls.some((log) => {
const args = log.toolRequest.args;
return (
/GEMINI\.md/i.test(args) &&
!/\.gemini\//i.test(args) &&
/vitest/i.test(args)
);
});
expect(
leakedToCommittedProject,
'Cross-project Vitest preference must NOT be mirrored into a committed project ./GEMINI.md (that tier is for team-shared conventions only)',
).toBe(false);
const leakedToPrivateProject = writeCalls.some((log) => {
const args = log.toolRequest.args;
return (
/\.gemini\/tmp\/[^/]+\/memory\//i.test(args) && /vitest/i.test(args)
);
});
expect(
leakedToPrivateProject,
'Cross-project Vitest preference must NOT be mirrored into the private project memory folder (that tier is for project-specific personal notes only)',
).toBe(false);
assertModelHasOutput(result);
},
});
const memoryManagerRoutingPreferences =
'Agent routes global and project preferences to memory';
const memoryV2RoutesTeamConventionsToProjectGemini =
'Agent routes team-shared project conventions to ./GEMINI.md';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: memoryManagerRoutingPreferences,
name: memoryV2RoutesTeamConventionsToProjectGemini,
params: {
settings: {
experimental: { memoryManager: true },
experimental: { memoryV2: true },
},
},
messages: [
@@ -372,7 +418,7 @@ describe('save_memory', () => {
type: 'user',
content: [
{
text: 'I always use dark mode in all my editors and terminals.',
text: 'For this project, the team always runs tests with `npm run test` — please remember that as our project convention.',
},
],
timestamp: '2026-01-01T00:00:00Z',
@@ -380,7 +426,9 @@ describe('save_memory', () => {
{
id: 'msg-2',
type: 'gemini',
content: [{ text: 'Got it, I will keep that in mind!' }],
content: [
{ text: 'Got it, I will keep `npm run test` in mind for tests.' },
],
timestamp: '2026-01-01T00:00:05Z',
},
{
@@ -404,16 +452,238 @@ describe('save_memory', () => {
],
prompt: 'Please save the preferences I mentioned earlier to memory.',
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall(
'invoke_agent',
undefined,
(args) => /save_memory/i.test(args),
);
// Under experimental.memoryV2, the prompt enforces an explicit
// one-tier-per-fact rule: team-shared project conventions (the team's
// test command, project-wide indentation rules) belong in the
// committed project-root ./GEMINI.md and must NOT be mirrored or
// cross-referenced into the private project memory folder
// (~/.gemini/tmp/<hash>/memory/). The global ~/.gemini/GEMINI.md must
// never be touched in this mode either.
await rig.waitForToolCall('write_file').catch(() => {});
const writeCalls = rig
.readToolLogs()
.filter((log) =>
['write_file', 'replace'].includes(log.toolRequest.name),
);
const wroteToProjectRoot = (factPattern: RegExp) =>
writeCalls.some((log) => {
const args = log.toolRequest.args;
return (
/GEMINI\.md/i.test(args) &&
!/\.gemini\//i.test(args) &&
factPattern.test(args)
);
});
expect(
wasToolCalled,
'Expected invoke_agent to be called with save_memory agent',
wroteToProjectRoot(/npm run test/i),
'Expected the team test-command convention to be written to the project-root ./GEMINI.md',
).toBe(true);
expect(
wroteToProjectRoot(/2[- ]space/i),
'Expected the project-wide "2-space indentation" convention to be written to the project-root ./GEMINI.md',
).toBe(true);
const leakedToPrivateMemory = writeCalls.some((log) => {
const args = log.toolRequest.args;
return (
/\.gemini\/tmp\/[^/]+\/memory\//i.test(args) &&
(/npm run test/i.test(args) || /2[- ]space/i.test(args))
);
});
expect(
leakedToPrivateMemory,
'Team-shared project conventions must NOT be mirrored into the private project memory folder (~/.gemini/tmp/<hash>/memory/) — each fact lives in exactly one tier.',
).toBe(false);
const leakedToGlobal = writeCalls.some((log) => {
const args = log.toolRequest.args;
return (
/\.gemini\/GEMINI\.md/i.test(args) &&
!/tmp\/[^/]+\/memory/i.test(args)
);
});
expect(
leakedToGlobal,
'Project preferences must NOT be written to the global ~/.gemini/GEMINI.md',
).toBe(false);
assertModelHasOutput(result);
},
});
const memoryV2RoutesUserProject =
'Agent routes personal-to-user project notes to user-project memory';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: memoryV2RoutesUserProject,
params: {
settings: {
experimental: { memoryV2: true },
},
},
prompt: `Please remember my personal local dev setup for THIS project's Postgres database. This is private to my machine — do NOT commit it to the repo.
Connection details:
- Host: localhost
- Port: 6543 (non-standard, I run multiple Postgres instances)
- Database: myproj_dev
- User: sandy_local
- Password: read from the SANDY_PG_LOCAL_PASS env var in my shell
How I start it locally:
1. Run \`brew services start postgresql@15\` to bring the server up.
2. Run \`./scripts/seed-local-db.sh\` from the repo root to load my personal seed data.
3. Verify with \`psql -h localhost -p 6543 -U sandy_local myproj_dev -c '\\dt'\`.
Quirks to remember:
- The migrations runner sometimes hangs on my machine if I forget step 1; kill it with Ctrl+C and rerun.
- I keep an extra \`scratch\` schema for ad-hoc experiments — never reference it from project code.`,
assert: async (rig, result) => {
// Under experimental.memoryV2 with the Private Project Memory bullet
// surfaced in the prompt, a fact that is project-specific AND
// personal-to-the-user (must not be committed) should land in the
// private project memory folder under ~/.gemini/tmp/<hash>/memory/. The
// detailed note should be written to a sibling markdown file, with
// MEMORY.md updated as the index. It must NOT go to committed
// ./GEMINI.md or the global ~/.gemini/GEMINI.md.
await rig.waitForToolCall('write_file').catch(() => {});
const writeCalls = rig
.readToolLogs()
.filter((log) =>
['write_file', 'replace'].includes(log.toolRequest.name),
);
const wroteUserProjectDetail = writeCalls.some((log) => {
const args = log.toolRequest.args;
return (
/\.gemini\/tmp\/[^/]+\/memory\/(?!MEMORY\.md)[^"]+\.md/i.test(args) &&
/6543/.test(args)
);
});
expect(
wroteUserProjectDetail,
'Expected the personal-to-user project note to be written to a private project memory detail file (~/.gemini/tmp/<hash>/memory/*.md)',
).toBe(true);
const wroteUserProjectIndex = writeCalls.some((log) => {
const args = log.toolRequest.args;
return /\.gemini\/tmp\/[^/]+\/memory\/MEMORY\.md/i.test(args);
});
expect(
wroteUserProjectIndex,
'Expected the personal-to-user project note to update the private project memory index (~/.gemini/tmp/<hash>/memory/MEMORY.md)',
).toBe(true);
// Defensive: should NOT have written this private note to the
// committed project GEMINI.md or the global GEMINI.md.
const leakedToCommittedProject = writeCalls.some((log) => {
const args = log.toolRequest.args;
return (
/\/GEMINI\.md/i.test(args) &&
!/\.gemini\//i.test(args) &&
/6543/.test(args)
);
});
expect(
leakedToCommittedProject,
'Personal-to-user note must NOT be written to the committed project GEMINI.md',
).toBe(false);
const leakedToGlobal = writeCalls.some((log) => {
const args = log.toolRequest.args;
return (
/\.gemini\/GEMINI\.md/i.test(args) &&
!/tmp\/[^/]+\/memory/i.test(args) &&
/6543/.test(args)
);
});
expect(
leakedToGlobal,
'Personal-to-user project note must NOT be written to the global ~/.gemini/GEMINI.md',
).toBe(false);
assertModelHasOutput(result);
},
});
const memoryV2RoutesCrossProjectToGlobal =
'Agent routes cross-project personal preferences to ~/.gemini/GEMINI.md';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: memoryV2RoutesCrossProjectToGlobal,
params: {
settings: {
experimental: { memoryV2: true },
},
},
prompt:
'Please remember this about me in general: across all my projects I always prefer Prettier with single quotes and trailing commas, and I always prefer tabs over spaces for indentation. These are my personal coding-style defaults that follow me into every workspace.',
assert: async (rig, result) => {
// Under experimental.memoryV2 with the Global Personal Memory
// tier surfaced in the prompt, a fact that explicitly applies to the
// user "across all my projects" / "in every workspace" must land in
// the global ~/.gemini/GEMINI.md (the cross-project tier). It must
// NOT be mirrored into a committed project-root ./GEMINI.md (that
// tier is for team-shared conventions) or into the per-project
// private memory folder (that tier is for project-specific personal
// notes). Each fact lives in exactly one tier across all four tiers.
await rig.waitForToolCall('write_file').catch(() => {});
const writeCalls = rig
.readToolLogs()
.filter((log) =>
['write_file', 'replace'].includes(log.toolRequest.name),
);
const wroteToGlobal = (factPattern: RegExp) =>
writeCalls.some((log) => {
const args = log.toolRequest.args;
return (
/\.gemini\/GEMINI\.md/i.test(args) &&
!/tmp\/[^/]+\/memory/i.test(args) &&
factPattern.test(args)
);
});
expect(
wroteToGlobal(/Prettier/i),
'Expected the cross-project Prettier preference to be written to the global personal memory file (~/.gemini/GEMINI.md)',
).toBe(true);
expect(
wroteToGlobal(/tabs/i),
'Expected the cross-project "tabs over spaces" preference to be written to the global personal memory file (~/.gemini/GEMINI.md)',
).toBe(true);
const leakedToCommittedProject = writeCalls.some((log) => {
const args = log.toolRequest.args;
return (
/GEMINI\.md/i.test(args) &&
!/\.gemini\//i.test(args) &&
(/Prettier/i.test(args) || /tabs/i.test(args))
);
});
expect(
leakedToCommittedProject,
'Cross-project personal preferences must NOT be mirrored into a committed project ./GEMINI.md (that tier is for team-shared conventions only)',
).toBe(false);
const leakedToPrivateProject = writeCalls.some((log) => {
const args = log.toolRequest.args;
return (
/\.gemini\/tmp\/[^/]+\/memory\//i.test(args) &&
(/Prettier/i.test(args) || /tabs/i.test(args))
);
});
expect(
leakedToPrivateProject,
'Cross-project personal preferences must NOT be mirrored into the private project memory folder (that tier is for project-specific personal notes only)',
).toBe(false);
assertModelHasOutput(result);
},
});
+1
View File
@@ -70,6 +70,7 @@ describe('ACP telemetry', () => {
GEMINI_API_KEY: 'fake-key',
GEMINI_CLI_HOME: rig.homeDir!,
GEMINI_TELEMETRY_ENABLED: 'true',
GEMINI_TELEMETRY_TRACES_ENABLED: 'true',
GEMINI_TELEMETRY_TARGET: 'local',
GEMINI_TELEMETRY_OUTFILE: telemetryPath,
},
@@ -8,7 +8,16 @@ import { expect, describe, it, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import { join } from 'node:path';
describe('Interactive Mode', () => {
// Skip on macOS: every interactive test in this file is chronically flaky
// because the captured pty buffer contains the CLI's startup escape
// sequences (`q4;?m...true color warning`) instead of the streamed output,
// causing `expectText(...)` to time out. Reproducible across unrelated
// runs on `main` (24740161950, 24739323404) and on consecutive merge-queue
// gates for #25753 (24743605639, 24747624513) — different tests in the
// same describe fail on different runs. Not specific to any model.
const skipOnDarwin = process.platform === 'darwin';
describe.skipIf(skipOnDarwin)('Interactive Mode', () => {
let rig: TestRig;
beforeEach(() => {
+3 -1
View File
@@ -134,7 +134,9 @@ describe('file-system', () => {
).toBeTruthy();
const newFileContent = rig.readFile(fileName);
expect(newFileContent).toBe('hello');
// Trim to tolerate models that idiomatically append a trailing newline.
// This test is about path-with-spaces handling, not whitespace fidelity.
expect(newFileContent.trim()).toBe('hello');
});
it('should perform a read-then-write sequence', async () => {
+9 -2
View File
@@ -81,7 +81,10 @@ describe('Plan Mode', () => {
await rig.run({
approvalMode: 'plan',
args: 'Create a file called plan.md in the plans directory.',
args:
'Create a file called plan.md in the plans directory with the ' +
'content "# Plan". Treat this as a Directive and write the file ' +
'immediately without proposing strategy or asking for confirmation.',
});
const toolLogs = rig.readToolLogs();
@@ -194,7 +197,11 @@ describe('Plan Mode', () => {
await rig.run({
approvalMode: 'plan',
args: 'Create a file called plan-no-session.md in the plans directory.',
args:
'Create a file called plan-no-session.md in the plans directory ' +
'with the content "# Plan". Treat this as a Directive and write ' +
'the file immediately without proposing strategy or asking for ' +
'confirmation.',
});
const toolLogs = rig.readToolLogs();
+36 -36
View File
@@ -1,55 +1,55 @@
{
"version": 1,
"updatedAt": "2026-04-10T15:36:04.547Z",
"updatedAt": "2026-04-20T18:04:59.671Z",
"scenarios": {
"multi-turn-conversation": {
"heapUsedBytes": 120082704,
"heapTotalBytes": 177586176,
"rssBytes": 269172736,
"externalBytes": 4304053,
"timestamp": "2026-04-10T15:35:17.603Z"
"heapUsedMB": 68.8,
"heapTotalMB": 91.2,
"rssMB": 215.4,
"externalMB": 93.8,
"timestamp": "2026-04-20T18:02:40.101Z"
},
"multi-function-call-repo-search": {
"heapUsedBytes": 104644984,
"heapTotalBytes": 111575040,
"rssBytes": 204079104,
"externalBytes": 4304053,
"timestamp": "2026-04-10T15:35:22.480Z"
"heapUsedMB": 73.5,
"heapTotalMB": 93.1,
"rssMB": 223.6,
"externalMB": 97.7,
"timestamp": "2026-04-20T18:02:42.032Z"
},
"idle-session-startup": {
"heapUsedBytes": 119813672,
"heapTotalBytes": 177061888,
"rssBytes": 267943936,
"externalBytes": 4304053,
"timestamp": "2026-04-10T15:35:08.035Z"
"heapUsedMB": 69.8,
"heapTotalMB": 92.4,
"rssMB": 217.4,
"externalMB": 93.8,
"timestamp": "2026-04-20T18:02:36.294Z"
},
"simple-prompt-response": {
"heapUsedBytes": 119722064,
"heapTotalBytes": 177324032,
"rssBytes": 268812288,
"externalBytes": 4304053,
"timestamp": "2026-04-10T15:35:12.770Z"
"heapUsedMB": 69.5,
"heapTotalMB": 92.4,
"rssMB": 216.1,
"externalMB": 93.8,
"timestamp": "2026-04-20T18:02:38.198Z"
},
"resume-large-chat-with-messages": {
"heapUsedBytes": 106545568,
"heapTotalBytes": 111509504,
"rssBytes": 202596352,
"externalBytes": 4306101,
"timestamp": "2026-04-10T15:36:04.547Z"
"heapUsedMB": 887.1,
"heapTotalMB": 954.3,
"rssMB": 1109.6,
"externalMB": 103.2,
"timestamp": "2026-04-20T18:04:59.671Z"
},
"resume-large-chat": {
"heapUsedBytes": 106513760,
"heapTotalBytes": 111509504,
"rssBytes": 202596352,
"externalBytes": 4306101,
"timestamp": "2026-04-10T15:35:59.528Z"
"heapUsedMB": 885.6,
"heapTotalMB": 955.6,
"rssMB": 1107.8,
"externalMB": 110.5,
"timestamp": "2026-04-20T18:04:06.526Z"
},
"large-chat": {
"heapUsedBytes": 106471568,
"heapTotalBytes": 111509504,
"rssBytes": 202596352,
"externalBytes": 4306101,
"timestamp": "2026-04-10T15:35:53.180Z"
"heapUsedMB": 158.5,
"heapTotalMB": 193,
"rssMB": 787.9,
"externalMB": 104,
"timestamp": "2026-04-20T18:03:12.486Z"
}
}
}
+30 -8
View File
@@ -16,15 +16,21 @@ import {
mkdirSync,
rmSync,
} from 'node:fs';
import { randomUUID } from 'node:crypto';
import { randomUUID, createHash } from 'node:crypto';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BASELINES_PATH = join(__dirname, 'baselines.json');
const UPDATE_BASELINES = process.env['UPDATE_MEMORY_BASELINES'] === 'true';
function getProjectHash(projectRoot: string): string {
return createHash('sha256').update(projectRoot).digest('hex');
}
const TOLERANCE_PERCENT = 10;
// Fake API key for tests using fake responses
const TEST_ENV = { GEMINI_API_KEY: 'fake-memory-test-key' };
const TEST_ENV = {
GEMINI_API_KEY: 'fake-memory-test-key',
GEMINI_MEMORY_MONITOR_INTERVAL: '100',
};
describe('Memory Usage Tests', () => {
let harness: MemoryTestHarness;
@@ -56,6 +62,7 @@ describe('Memory Usage Tests', () => {
});
const result = await harness.runScenario(
rig,
'idle-session-startup',
async (recordSnapshot) => {
await rig.run({
@@ -85,6 +92,7 @@ describe('Memory Usage Tests', () => {
});
const result = await harness.runScenario(
rig,
'simple-prompt-response',
async (recordSnapshot) => {
await rig.run({
@@ -122,6 +130,7 @@ describe('Memory Usage Tests', () => {
];
const result = await harness.runScenario(
rig,
'multi-turn-conversation',
async (recordSnapshot) => {
// Run through all turns as a piped sequence
@@ -144,6 +153,9 @@ describe('Memory Usage Tests', () => {
);
} else {
harness.assertWithinBaseline(result);
harness.assertMemoryReturnsToBaseline(result.snapshots, 20);
const { leaked, message } = harness.analyzeSnapshots(result.snapshots);
if (leaked) console.warn(`${message}`);
}
});
@@ -168,6 +180,7 @@ describe('Memory Usage Tests', () => {
);
const result = await harness.runScenario(
rig,
'multi-function-call-repo-search',
async (recordSnapshot) => {
await rig.run({
@@ -189,6 +202,7 @@ describe('Memory Usage Tests', () => {
);
} else {
harness.assertWithinBaseline(result);
harness.assertMemoryReturnsToBaseline(result.snapshots, 20);
}
});
@@ -228,6 +242,7 @@ describe('Memory Usage Tests', () => {
});
const result = await harness.runScenario(
rig,
'large-chat',
async (recordSnapshot) => {
await rig.run({
@@ -257,19 +272,21 @@ describe('Memory Usage Tests', () => {
});
const result = await harness.runScenario(
rig,
'resume-large-chat',
async (recordSnapshot) => {
// Ensure the history file is linked
const targetChatsDir = join(
rig.testDir!,
rig.homeDir!,
'.gemini',
'tmp',
'test-project-hash',
getProjectHash(rig.testDir!),
'chats',
);
mkdirSync(targetChatsDir, { recursive: true });
const targetHistoryPath = join(
targetChatsDir,
'large-chat-session.json',
'session-large-chat.json',
);
if (existsSync(targetHistoryPath)) rmSync(targetHistoryPath);
copyFileSync(sharedHistoryPath, targetHistoryPath);
@@ -302,19 +319,21 @@ describe('Memory Usage Tests', () => {
});
const result = await harness.runScenario(
rig,
'resume-large-chat-with-messages',
async (recordSnapshot) => {
// Ensure the history file is linked
const targetChatsDir = join(
rig.testDir!,
rig.homeDir!,
'.gemini',
'tmp',
'test-project-hash',
getProjectHash(rig.testDir!),
'chats',
);
mkdirSync(targetChatsDir, { recursive: true });
const targetHistoryPath = join(
targetChatsDir,
'large-chat-session.json',
'session-large-chat.json',
);
if (existsSync(targetHistoryPath)) rmSync(targetHistoryPath);
copyFileSync(sharedHistoryPath, targetHistoryPath);
@@ -457,6 +476,9 @@ async function generateSharedLargeChatData(tempDir: string) {
// Generate responses for resumed chat
const resumeResponsesStream = createWriteStream(resumeResponsesPath);
for (let i = 0; i < 5; i++) {
// Doubling up on non-streaming responses to satisfy classifier and complexity checks
resumeResponsesStream.write(JSON.stringify(complexityResponse) + '\n');
resumeResponsesStream.write(JSON.stringify(summaryResponse) + '\n');
resumeResponsesStream.write(JSON.stringify(complexityResponse) + '\n');
resumeResponsesStream.write(
JSON.stringify({
+38 -59
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"workspaces": [
"packages/*"
],
@@ -4593,56 +4593,6 @@
}
}
},
"node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
"version": "8.47.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz",
"integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.47.0",
"@typescript-eslint/visitor-keys": "8.47.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/types": {
"version": "8.47.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz",
"integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.47.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz",
"integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.47.0",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
@@ -17742,7 +17692,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "^7.16.0",
@@ -17871,7 +17821,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
@@ -18019,7 +17969,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -18054,6 +18004,7 @@
"ajv": "^8.17.1",
"ajv-formats": "^3.0.0",
"chardet": "^2.1.0",
"chokidar": "^5.0.0",
"diff": "^8.0.3",
"dotenv": "^17.2.4",
"dotenv-expand": "^12.0.3",
@@ -18203,6 +18154,21 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"packages/core/node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"packages/core/node_modules/dotenv": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
@@ -18271,6 +18237,19 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"packages/core/node_modules/readdirp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"packages/core/node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -18300,7 +18279,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -18315,7 +18294,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18346,7 +18325,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18378,7 +18357,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.40.0-nightly.20260414.g5b1f7375a"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.41.0-nightly.20260423.gaa05b4583"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
@@ -98,6 +98,7 @@ export function createMockConfig(
getMcpServers: vi.fn().mockReturnValue({}),
}),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
getGitService: vi.fn(),
validatePathAccess: vi.fn().mockReturnValue(undefined),
getShellExecutionConfig: vi.fn().mockReturnValue({
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -27,7 +27,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.40.0-nightly.20260414.g5b1f7375a"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.41.0-nightly.20260423.gaa05b4583"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
+34
View File
@@ -41,6 +41,8 @@ import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { ApprovalMode } from '@google/gemini-cli-core/src/policy/types.js';
const startMemoryServiceMock = vi.hoisted(() => vi.fn());
vi.mock('../config/config.js', () => ({
loadCliConfig: vi.fn(),
}));
@@ -101,6 +103,7 @@ vi.mock(
const actual = await importOriginal();
return {
...actual,
startMemoryService: startMemoryServiceMock,
updatePolicy: vi.fn(),
createPolicyUpdater: vi.fn(),
ReadManyFilesTool: vi.fn(),
@@ -148,6 +151,8 @@ describe('GeminiAgent', () => {
let agent: GeminiAgent;
beforeEach(() => {
vi.clearAllMocks();
startMemoryServiceMock.mockResolvedValue(undefined);
mockConfig = {
refreshAuth: vi.fn(),
initialize: vi.fn(),
@@ -155,6 +160,7 @@ describe('GeminiAgent', () => {
getFileSystemService: vi.fn(),
setFileSystemService: vi.fn(),
getContentGeneratorConfig: vi.fn(),
isAutoMemoryEnabled: vi.fn().mockReturnValue(false),
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
getModel: vi.fn().mockReturnValue('gemini-pro'),
getGeminiClient: vi.fn().mockReturnValue({
@@ -354,6 +360,34 @@ describe('GeminiAgent', () => {
vi.useRealTimers();
});
it('should start auto memory for new ACP sessions when enabled', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
mockConfig.isAutoMemoryEnabled = vi.fn().mockReturnValue(true);
await agent.newSession({
cwd: '/tmp',
mcpServers: [],
});
expect(startMemoryServiceMock).toHaveBeenCalledWith(mockConfig);
});
it('should not start auto memory for new ACP sessions when disabled', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
mockConfig.isAutoMemoryEnabled = vi.fn().mockReturnValue(false);
await agent.newSession({
cwd: '/tmp',
mcpServers: [],
});
expect(startMemoryServiceMock).not.toHaveBeenCalled();
});
it('should return modes without plan mode when plan is disabled', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
+3
View File
@@ -76,6 +76,7 @@ import { randomUUID } from 'node:crypto';
import { loadCliConfig, type CliArgs } from '../config/config.js';
import { runExitCleanup } from '../utils/cleanup.js';
import { SessionSelector } from '../utils/sessionUtils.js';
import { startAutoMemoryIfEnabled } from '../utils/autoMemory.js';
import { CommandHandler } from './commandHandler.js';
@@ -324,6 +325,7 @@ export class GeminiAgent {
await config.initialize();
startupProfiler.flush(config);
startAutoMemoryIfEnabled(config);
const geminiClient = config.getGeminiClient();
const chat = await geminiClient.startChat();
@@ -465,6 +467,7 @@ export class GeminiAgent {
// which starts the MCP servers and other heavy resources.
await config.initialize();
startupProfiler.flush(config);
startAutoMemoryIfEnabled(config);
return config;
}
+1
View File
@@ -100,6 +100,7 @@ describe('GeminiAgent Session Resume', () => {
unsubscribe: vi.fn(),
},
getApprovalMode: vi.fn().mockReturnValue('default'),
isAutoMemoryEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
getModel: vi.fn().mockReturnValue('gemini-pro'),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
+34 -15
View File
@@ -24,6 +24,7 @@ import {
FileDiscoveryService,
resolveTelemetrySettings,
FatalConfigError,
getErrorMessage,
getPty,
debugLogger,
loadServerHierarchicalMemory,
@@ -60,6 +61,7 @@ import {
import { loadSandboxConfig } from './sandboxConfig.js';
import { resolvePath } from '../utils/resolvePath.js';
import { isRecord } from '../utils/settingsUtils.js';
import { RESUME_LATEST } from '../utils/sessionUtils.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
@@ -106,6 +108,7 @@ export interface CliArgs {
startupMessages?: string[];
rawOutput: boolean | undefined;
acceptRawOutputRisk: boolean | undefined;
skipTrust: boolean | undefined;
isCommand: boolean | undefined;
}
@@ -291,6 +294,11 @@ export async function parseArguments(
description:
'Execute the provided prompt and continue in interactive mode',
})
.option('skip-trust', {
type: 'boolean',
description: 'Trust the current workspace for this session.',
default: false,
})
.option('worktree', {
alias: 'w',
type: 'string',
@@ -459,9 +467,16 @@ export async function parseArguments(
yargsInstance.wrap(yargsInstance.terminalWidth());
let result;
try {
result = await yargsInstance.parse();
const parsed = await yargsInstance.parse();
if (!isRecord(parsed)) {
throw new Error('Failed to parse arguments');
}
result = parsed;
if (result['skip-trust']) {
process.env['GEMINI_CLI_TRUST_WORKSPACE'] = 'true';
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const msg = getErrorMessage(e);
debugLogger.error(msg);
yargsInstance.showHelp();
await runExitCleanup();
@@ -475,11 +490,13 @@ export async function parseArguments(
}
// Normalize query args: handle both quoted "@path file" and unquoted @path file
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const queryArg = (result as { query?: string | string[] | undefined }).query;
const q: string | undefined = Array.isArray(queryArg)
? queryArg.join(' ')
: queryArg;
const queryArg = result['query'];
let q: string | undefined;
if (Array.isArray(queryArg)) {
q = queryArg.join(' ');
} else if (typeof queryArg === 'string') {
q = queryArg;
}
// -p/--prompt forces non-interactive mode; positional args default to interactive in TTY
if (q && !result['prompt']) {
@@ -494,8 +511,8 @@ export async function parseArguments(
}
// Keep CliArgs.query as a string for downstream typing
(result as Record<string, unknown>)['query'] = q || undefined;
(result as Record<string, unknown>)['startupMessages'] = startupMessages;
result['query'] = q || undefined;
result['startupMessages'] = startupMessages;
// The import format is now only controlled by settings.memoryImportFormat
// We no longer accept it as a CLI argument
@@ -547,7 +564,7 @@ export async function loadCliConfig(
? false
: (settings.security?.folderTrust?.enabled ?? false);
const trustedFolder =
isWorkspaceTrusted(settings, cwd, undefined, {
isWorkspaceTrusted(settings, cwd, {
prompt: argv.prompt,
query: argv.query,
})?.isTrusted ?? false;
@@ -593,7 +610,7 @@ export async function loadCliConfig(
return resolveToRealPath(trimmedPath) !== realCwd;
} catch (e) {
debugLogger.debug(
`[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${e instanceof Error ? e.message : String(e)})`,
`[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${getErrorMessage(e)})`,
);
return false;
}
@@ -617,7 +634,7 @@ export async function loadCliConfig(
.getExtensions()
.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
const experimentalJitContext = settings.experimental.jitContext;
const experimentalJitContext = settings.experimental.jitContext ?? true;
let extensionRegistryURI =
process.env['GEMINI_CLI_EXTENSION_REGISTRY_URI'] ??
@@ -991,8 +1008,8 @@ export async function loadCliConfig(
enableEventDrivenScheduler: true,
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
experimentalJitContext: settings.experimental?.jitContext,
experimentalMemoryManager: settings.experimental?.memoryManager,
experimentalJitContext,
experimentalMemoryV2: settings.experimental?.memoryV2,
experimentalAutoMemory: settings.experimental?.autoMemory,
contextManagement,
modelSteering: settings.experimental?.modelSteering,
@@ -1027,11 +1044,13 @@ export async function loadCliConfig(
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
},
gemmaModelRouter: settings.experimental?.gemmaModelRouter,
autoRouting: settings.model?.autoRouting,
adk: settings.experimental?.adk,
fakeResponses: argv.fakeResponses,
recordResponses: argv.recordResponses,
retryFetchErrors: settings.general?.retryFetchErrors,
billing: settings.billing,
vertexAiRouting: settings.billing?.vertexAi,
maxAttempts: settings.general?.maxAttempts,
ptyInfo: ptyInfo?.name,
disableLLMCorrection: settings.tools?.disableLLMCorrection,
@@ -1098,7 +1117,7 @@ async function resolveWorktreeSettings(
worktreeBaseSha = stdout.trim();
} catch (e: unknown) {
debugLogger.debug(
`Failed to resolve worktree base SHA at ${worktreePath}: ${e instanceof Error ? e.message : String(e)}`,
`Failed to resolve worktree base SHA at ${worktreePath}: ${getErrorMessage(e)}`,
);
}
+5 -1
View File
@@ -26,6 +26,7 @@ import {
loadAgentsFromDirectory,
loadSkillsFromDir,
getRealPath,
normalizePath,
} from '@google/gemini-cli-core';
import {
loadSettings,
@@ -1420,6 +1421,7 @@ name = "yolo-checker"
'.gemini',
'trustedFolders.json',
);
vi.stubEnv('GEMINI_CLI_TRUSTED_FOLDERS_PATH', trustedFoldersPath);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: undefined,
@@ -1438,7 +1440,9 @@ name = "yolo-checker"
const trustedFolders = JSON.parse(
fs.readFileSync(trustedFoldersPath, 'utf-8'),
);
expect(trustedFolders[tempWorkspaceDir]).toBe('TRUST_FOLDER');
expect(trustedFolders[normalizePath(tempWorkspaceDir)]).toBe(
'TRUST_FOLDER',
);
});
describe.each([true, false])(
+12 -6
View File
@@ -1912,6 +1912,9 @@ describe('Settings Loading and Merging', () => {
const geminiEnvPath = path.resolve(
path.join(MOCK_WORKSPACE_DIR, GEMINI_DIR, '.env'),
);
const workspaceEnvPath = path.resolve(
path.join(MOCK_WORKSPACE_DIR, '.env'),
);
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
isTrusted: isWorkspaceTrustedValue,
@@ -1919,9 +1922,11 @@ describe('Settings Loading and Merging', () => {
});
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => {
const normalizedP = path.resolve(p.toString());
return [path.resolve(USER_SETTINGS_PATH), geminiEnvPath].includes(
normalizedP,
);
return [
path.resolve(USER_SETTINGS_PATH),
geminiEnvPath,
workspaceEnvPath,
].includes(normalizedP);
});
const userSettingsContent: Settings = {
ui: {
@@ -1941,7 +1946,7 @@ describe('Settings Loading and Merging', () => {
const normalizedP = path.resolve(p.toString());
if (normalizedP === path.resolve(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
if (normalizedP === geminiEnvPath)
if (normalizedP === geminiEnvPath || normalizedP === workspaceEnvPath)
return 'TESTTEST=1234\nGEMINI_API_KEY=test-key';
return '{}';
},
@@ -1970,7 +1975,7 @@ describe('Settings Loading and Merging', () => {
expect(process.env['TESTTEST']).not.toEqual('1234');
});
it('does load env files from untrusted spaces when NOT sandboxed', () => {
it('does NOT load non-whitelisted env files from untrusted spaces even when NOT sandboxed', () => {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = {
security: { folderTrust: { enabled: true } },
@@ -1978,7 +1983,8 @@ describe('Settings Loading and Merging', () => {
} as Settings;
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
expect(process.env['TESTTEST']).toEqual('1234');
expect(process.env['TESTTEST']).not.toEqual('1234');
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
});
it('does not load env files when trust is undefined and sandboxed', () => {
+21 -12
View File
@@ -78,7 +78,12 @@ export function getMergeStrategyForPath(
export const USER_SETTINGS_PATH = Storage.getGlobalSettingsPath();
export const USER_SETTINGS_DIR = path.dirname(USER_SETTINGS_PATH);
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
export const DEFAULT_EXCLUDED_ENV_VARS = [
'DEBUG',
'DEBUG_MODE',
'GEMINI_CLI_IDE_SERVER_STDIO_COMMAND',
'GEMINI_CLI_IDE_SERVER_STDIO_ARGS',
];
const AUTH_ENV_VAR_WHITELIST = [
'GEMINI_API_KEY',
@@ -494,13 +499,15 @@ export class LoadedSettings {
}
}
function findEnvFile(startDir: string): string | null {
function findEnvFile(startDir: string, isTrusted: boolean): string | null {
let currentDir = path.resolve(startDir);
while (true) {
// prefer gemini-specific .env under GEMINI_DIR
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
if (fs.existsSync(geminiEnvPath)) {
return geminiEnvPath;
if (isTrusted) {
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
if (fs.existsSync(geminiEnvPath)) {
return geminiEnvPath;
}
}
const envPath = path.join(currentDir, '.env');
if (fs.existsSync(envPath)) {
@@ -509,9 +516,11 @@ function findEnvFile(startDir: string): string | null {
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir || !parentDir) {
// check .env under home as fallback, again preferring gemini-specific .env
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
if (fs.existsSync(homeGeminiEnvPath)) {
return homeGeminiEnvPath;
if (isTrusted) {
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
if (fs.existsSync(homeGeminiEnvPath)) {
return homeGeminiEnvPath;
}
}
const homeEnvPath = path.join(homedir(), '.env');
if (fs.existsSync(homeEnvPath)) {
@@ -554,10 +563,10 @@ export function loadEnvironment(
workspaceDir: string,
isWorkspaceTrustedFn = isWorkspaceTrusted,
): void {
const envFilePath = findEnvFile(workspaceDir);
const trustResult = isWorkspaceTrustedFn(settings, workspaceDir);
const isTrusted = trustResult.isTrusted ?? false;
const envFilePath = findEnvFile(workspaceDir, isTrusted);
// Check settings OR check process.argv directly since this might be called
// before arguments are fully parsed. This is a best-effort sniffing approach
// that happens early in the CLI lifecycle. It is designed to detect the
@@ -592,8 +601,8 @@ export function loadEnvironment(
for (const key in parsedEnv) {
if (Object.hasOwn(parsedEnv, key)) {
let value = parsedEnv[key];
// If the workspace is untrusted but we are sandboxed, only allow whitelisted variables.
if (!isTrusted && isSandboxed) {
// If the workspace is untrusted, only allow whitelisted variables.
if (!isTrusted) {
if (!AUTH_ENV_VAR_WHITELIST.includes(key)) {
continue;
}
@@ -138,6 +138,10 @@ describe('SettingsSchema', () => {
getSettingsSchema().context.properties.fileFiltering.properties
?.enableRecursiveFileSearch,
).toBeDefined();
expect(
getSettingsSchema().context.properties.fileFiltering.properties
?.enableFileWatcher,
).toBeDefined();
expect(
getSettingsSchema().context.properties.fileFiltering.properties
?.customIgnoreFilePaths,
@@ -313,6 +317,22 @@ describe('SettingsSchema', () => {
).toBe(false);
});
it('should have Vertex AI routing settings in schema', () => {
const vertexAi =
getSettingsSchema().billing.properties.vertexAi.properties;
expect(vertexAi.requestType).toBeDefined();
expect(vertexAi.requestType.type).toBe('enum');
expect(
vertexAi.requestType.options?.map((option) => option.value),
).toEqual(['dedicated', 'shared']);
expect(vertexAi.sharedRequestType).toBeDefined();
expect(vertexAi.sharedRequestType.type).toBe('enum');
expect(
vertexAi.sharedRequestType.options?.map((option) => option.value),
).toEqual(['priority', 'flex']);
});
it('should have folderTrustFeature setting in schema', () => {
expect(
getSettingsSchema().security.properties.folderTrust.properties.enabled,
+103 -6
View File
@@ -21,6 +21,7 @@ import {
type AgentOverride,
type CustomTheme,
type SandboxConfig,
type VertexAiRoutingConfig,
} from '@google/gemini-cli-core';
import type { SessionRetentionSettings } from './settings.js';
import { DEFAULT_MIN_RETENTION } from '../utils/sessionCleanup.js';
@@ -990,6 +991,45 @@ const SETTINGS_SCHEMA = {
{ value: 'never', label: 'Never use credits' },
],
},
vertexAi: {
type: 'object',
label: 'Vertex AI',
category: 'Advanced',
requiresRestart: true,
default: undefined as VertexAiRoutingConfig | undefined,
description: 'Vertex AI request routing settings.',
showInDialog: false,
properties: {
requestType: {
type: 'enum',
label: 'Vertex AI Request Type',
category: 'Advanced',
requiresRestart: true,
default: undefined as VertexAiRoutingConfig['requestType'],
description:
'Sets the X-Vertex-AI-LLM-Request-Type header for Vertex AI requests.',
showInDialog: false,
options: [
{ value: 'dedicated', label: 'Dedicated' },
{ value: 'shared', label: 'Shared' },
],
},
sharedRequestType: {
type: 'enum',
label: 'Vertex AI Shared Request Type',
category: 'Advanced',
requiresRestart: true,
default: undefined as VertexAiRoutingConfig['sharedRequestType'],
description:
'Sets the X-Vertex-AI-LLM-Shared-Request-Type header for Vertex AI requests.',
showInDialog: false,
options: [
{ value: 'priority', label: 'Priority' },
{ value: 'flex', label: 'Flex' },
],
},
},
},
},
},
@@ -1072,6 +1112,46 @@ const SETTINGS_SCHEMA = {
description: 'Skip the next speaker check.',
showInDialog: true,
},
autoRouting: {
type: 'object',
label: 'Auto Routing',
category: 'Model',
requiresRestart: false,
default: {},
description: 'Settings for automatic model routing.',
showInDialog: false,
properties: {
bestEffortPro: {
type: 'boolean',
label: 'Best Effort Pro',
category: 'Model',
requiresRestart: false,
default: false,
description:
'Always prefer the Pro model unless it is unavailable (e.g., due to timeouts or quota), ignoring other routing hints.',
showInDialog: true,
},
proTimeoutMinutes: {
type: 'number',
label: 'Pro Timeout (Minutes)',
category: 'Model',
requiresRestart: false,
default: 5,
description:
'If a Pro request takes longer than this many minutes, it will be marked as temporarily unavailable and fallback to Flash.',
showInDialog: true,
},
proTimeoutFallbackDurationMinutes: {
type: 'number',
label: 'Pro Timeout Fallback Duration (Minutes)',
category: 'Model',
requiresRestart: false,
default: 60,
description: 'How long to route to Flash after Pro times out.',
showInDialog: true,
},
},
},
},
},
@@ -1431,6 +1511,17 @@ const SETTINGS_SCHEMA = {
description: 'Respect .geminiignore files when searching.',
showInDialog: true,
},
enableFileWatcher: {
type: 'boolean',
label: 'Enable File Watcher',
category: 'Context',
requiresRestart: true,
default: false,
description: oneLine`
Enable file watcher updates for @ file suggestions (experimental).
`,
showInDialog: false,
},
enableRecursiveFileSearch: {
type: 'boolean',
label: 'Enable Recursive File Search',
@@ -2089,8 +2180,9 @@ const SETTINGS_SCHEMA = {
label: 'JIT Context Loading',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Enable Just-In-Time (JIT) context loading.',
default: true,
description:
'Enable Just-In-Time (JIT) context loading. Defaults to true; set to false to opt out and load all GEMINI.md files into the system instruction up-front.',
showInDialog: false,
},
useOSC52Paste: {
@@ -2223,14 +2315,14 @@ const SETTINGS_SCHEMA = {
},
},
},
memoryManager: {
memoryV2: {
type: 'boolean',
label: 'Memory Manager Agent',
label: 'Memory v2',
category: 'Experimental',
requiresRestart: true,
default: false,
default: true,
description:
'Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.',
'Disable the built-in save_memory tool and let the main agent persist project context by editing markdown files directly with edit/write_file. Route facts across four tiers: team-shared conventions go to project GEMINI.md files, project-specific personal notes go to the per-project private memory folder (MEMORY.md as index + sibling .md files for detail), and cross-project personal preferences go to the global ~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit — settings, credentials, etc. remain off-limits). Set to false to fall back to the legacy save_memory tool.',
showInDialog: true,
},
autoMemory: {
@@ -3020,6 +3112,11 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
description: 'Protocol for OTLP exporters.',
enum: ['grpc', 'http'],
},
traces: {
type: 'boolean',
description:
'Whether detailed traces with large attributes are captured.',
},
logPrompts: {
type: 'boolean',
description: 'Whether prompts are logged in telemetry payloads.',
+49 -41
View File
@@ -11,7 +11,7 @@ import * as os from 'node:os';
import {
FatalConfigError,
ideContextStore,
coreEvents,
normalizePath,
} from '@google/gemini-cli-core';
import {
loadTrustedFolders,
@@ -32,9 +32,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
...actual,
homedir: () => '/mock/home/user',
isHeadlessMode: vi.fn(() => false),
coreEvents: {
emitFeedback: vi.fn(),
},
coreEvents: Object.assign(
Object.create(Object.getPrototypeOf(actual.coreEvents)),
actual.coreEvents,
{
emitFeedback: vi.fn(),
},
),
FatalConfigError: actual.FatalConfigError,
};
});
@@ -53,6 +58,7 @@ describe('Trusted Folders', () => {
// Reset the internal state
resetTrustedFoldersForTesting();
vi.clearAllMocks();
delete process.env['GEMINI_CLI_TRUST_WORKSPACE'];
});
afterEach(() => {
@@ -70,8 +76,14 @@ describe('Trusted Folders', () => {
// Start two concurrent calls
// These will race to acquire the lock on the real file system
const p1 = loadedFolders.setValue('/path1', TrustLevel.TRUST_FOLDER);
const p2 = loadedFolders.setValue('/path2', TrustLevel.TRUST_FOLDER);
const p1 = loadedFolders.setValue(
path.resolve('/path1'),
TrustLevel.TRUST_FOLDER,
);
const p2 = loadedFolders.setValue(
path.resolve('/path2'),
TrustLevel.TRUST_FOLDER,
);
await Promise.all([p1, p2]);
@@ -80,8 +92,8 @@ describe('Trusted Folders', () => {
const config = JSON.parse(content);
expect(config).toEqual({
'/path1': TrustLevel.TRUST_FOLDER,
'/path2': TrustLevel.TRUST_FOLDER,
[normalizePath('/path1')]: TrustLevel.TRUST_FOLDER,
[normalizePath('/path2')]: TrustLevel.TRUST_FOLDER,
});
});
});
@@ -95,13 +107,16 @@ describe('Trusted Folders', () => {
it('should load rules from the configuration file', () => {
const config = {
'/user/folder': TrustLevel.TRUST_FOLDER,
[normalizePath('/user/folder')]: TrustLevel.TRUST_FOLDER,
};
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
const { rules, errors } = loadTrustedFolders();
expect(rules).toEqual([
{ path: '/user/folder', trustLevel: TrustLevel.TRUST_FOLDER },
{
path: normalizePath('/user/folder'),
trustLevel: TrustLevel.TRUST_FOLDER,
},
]);
expect(errors).toEqual([]);
});
@@ -143,14 +158,14 @@ describe('Trusted Folders', () => {
const content = `
{
// This is a comment
"/path": "TRUST_FOLDER"
"${normalizePath('/path').replaceAll('\\', '\\\\')}": "TRUST_FOLDER"
}
`;
fs.writeFileSync(trustedFoldersPath, content, 'utf-8');
const { rules, errors } = loadTrustedFolders();
expect(rules).toEqual([
{ path: '/path', trustLevel: TrustLevel.TRUST_FOLDER },
{ path: normalizePath('/path'), trustLevel: TrustLevel.TRUST_FOLDER },
]);
expect(errors).toEqual([]);
});
@@ -216,15 +231,18 @@ describe('Trusted Folders', () => {
fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8');
const loadedFolders = loadTrustedFolders();
await loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER);
await loadedFolders.setValue(
normalizePath('/new/path'),
TrustLevel.TRUST_FOLDER,
);
expect(loadedFolders.user.config['/new/path']).toBe(
expect(loadedFolders.user.config[normalizePath('/new/path')]).toBe(
TrustLevel.TRUST_FOLDER,
);
const content = fs.readFileSync(trustedFoldersPath, 'utf-8');
const config = JSON.parse(content);
expect(config['/new/path']).toBe(TrustLevel.TRUST_FOLDER);
expect(config[normalizePath('/new/path')]).toBe(TrustLevel.TRUST_FOLDER);
});
it('should throw FatalConfigError if there were load errors', async () => {
@@ -237,28 +255,6 @@ describe('Trusted Folders', () => {
loadedFolders.setValue('/some/path', TrustLevel.TRUST_FOLDER),
).rejects.toThrow(FatalConfigError);
});
it('should report corrupted config via coreEvents.emitFeedback and still succeed', async () => {
// Initialize with valid JSON
fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8');
const loadedFolders = loadTrustedFolders();
// Corrupt the file after initial load
fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8');
await loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER);
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
expect.stringContaining('may be corrupted'),
expect.any(Error),
);
// Should have overwritten the corrupted file with new valid config
const content = fs.readFileSync(trustedFoldersPath, 'utf-8');
const config = JSON.parse(content);
expect(config).toEqual({ '/new/path': TrustLevel.TRUST_FOLDER });
});
});
describe('isWorkspaceTrusted Integration', () => {
@@ -427,16 +423,28 @@ describe('Trusted Folders', () => {
},
};
it('should return true when isHeadlessMode is true, ignoring config', async () => {
it('should NOT return true when isHeadlessMode is true, ignoring config', async () => {
const geminiCore = await import('@google/gemini-cli-core');
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true);
expect(isWorkspaceTrusted(mockSettings)).toEqual({
isTrusted: true,
isTrusted: undefined,
source: undefined,
});
});
it('should return true when GEMINI_CLI_TRUST_WORKSPACE is true', async () => {
process.env['GEMINI_CLI_TRUST_WORKSPACE'] = 'true';
try {
expect(isWorkspaceTrusted(mockSettings)).toEqual({
isTrusted: true,
source: 'env',
});
} finally {
delete process.env['GEMINI_CLI_TRUST_WORKSPACE'];
}
});
it('should fall back to config when isHeadlessMode is false', async () => {
const geminiCore = await import('@google/gemini-cli-core');
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(false);
@@ -449,12 +457,12 @@ describe('Trusted Folders', () => {
);
});
it('should return true for isPathTrusted when isHeadlessMode is true', async () => {
it('should return undefined for isPathTrusted when isHeadlessMode is true', async () => {
const geminiCore = await import('@google/gemini-cli-core');
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true);
const folders = loadTrustedFolders();
expect(folders.isPathTrusted('/any-untrusted-path')).toBe(true);
expect(folders.isPathTrusted('/any-untrusted-path')).toBe(undefined);
});
});
+31 -365
View File
@@ -4,330 +4,29 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
import { lock } from 'proper-lockfile';
import {
FatalConfigError,
getErrorMessage,
isWithinRoot,
ideContextStore,
GEMINI_DIR,
homedir,
isHeadlessMode,
coreEvents,
type HeadlessModeOptions,
checkPathTrust,
isHeadlessMode,
loadTrustedFolders as loadCoreTrustedFolders,
type LoadedTrustedFolders,
} from '@google/gemini-cli-core';
import type { Settings } from './settings.js';
import stripJsonComments from 'strip-json-comments';
const { promises: fsPromises } = fs;
export {
TrustLevel,
isTrustLevel,
resetTrustedFoldersForTesting,
saveTrustedFolders,
} from '@google/gemini-cli-core';
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
export function getUserSettingsDir(): string {
return path.join(homedir(), GEMINI_DIR);
}
export function getTrustedFoldersPath(): string {
if (process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']) {
return process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'];
}
return path.join(getUserSettingsDir(), TRUSTED_FOLDERS_FILENAME);
}
export enum TrustLevel {
TRUST_FOLDER = 'TRUST_FOLDER',
TRUST_PARENT = 'TRUST_PARENT',
DO_NOT_TRUST = 'DO_NOT_TRUST',
}
export function isTrustLevel(
value: string | number | boolean | object | null | undefined,
): value is TrustLevel {
return (
typeof value === 'string' &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
Object.values(TrustLevel).includes(value as TrustLevel)
);
}
export interface TrustRule {
path: string;
trustLevel: TrustLevel;
}
export interface TrustedFoldersError {
message: string;
path: string;
}
export interface TrustedFoldersFile {
config: Record<string, TrustLevel>;
path: string;
}
export interface TrustResult {
isTrusted: boolean | undefined;
source: 'ide' | 'file' | undefined;
}
const realPathCache = new Map<string, string>();
/**
* Parses the trusted folders JSON content, stripping comments.
*/
function parseTrustedFoldersJson(content: string): unknown {
return JSON.parse(stripJsonComments(content));
}
/**
* FOR TESTING PURPOSES ONLY.
* Clears the real path cache.
*/
export function clearRealPathCacheForTesting(): void {
realPathCache.clear();
}
function getRealPath(location: string): string {
let realPath = realPathCache.get(location);
if (realPath !== undefined) {
return realPath;
}
try {
realPath = fs.existsSync(location) ? fs.realpathSync(location) : location;
} catch {
realPath = location;
}
realPathCache.set(location, realPath);
return realPath;
}
export class LoadedTrustedFolders {
constructor(
readonly user: TrustedFoldersFile,
readonly errors: TrustedFoldersError[],
) {}
get rules(): TrustRule[] {
return Object.entries(this.user.config).map(([path, trustLevel]) => ({
path,
trustLevel,
}));
}
/**
* Returns true or false if the path should be "trusted". This function
* should only be invoked when the folder trust setting is active.
*
* @param location path
* @returns
*/
isPathTrusted(
location: string,
config?: Record<string, TrustLevel>,
headlessOptions?: HeadlessModeOptions,
): boolean | undefined {
if (isHeadlessMode(headlessOptions)) {
return true;
}
const configToUse = config ?? this.user.config;
// Resolve location to its realpath for canonical comparison
const realLocation = getRealPath(location);
let longestMatchLen = -1;
let longestMatchTrust: TrustLevel | undefined = undefined;
for (const [rulePath, trustLevel] of Object.entries(configToUse)) {
const effectivePath =
trustLevel === TrustLevel.TRUST_PARENT
? path.dirname(rulePath)
: rulePath;
// Resolve effectivePath to its realpath for canonical comparison
const realEffectivePath = getRealPath(effectivePath);
if (isWithinRoot(realLocation, realEffectivePath)) {
if (rulePath.length > longestMatchLen) {
longestMatchLen = rulePath.length;
longestMatchTrust = trustLevel;
}
}
}
if (longestMatchTrust === TrustLevel.DO_NOT_TRUST) return false;
if (
longestMatchTrust === TrustLevel.TRUST_FOLDER ||
longestMatchTrust === TrustLevel.TRUST_PARENT
)
return true;
return undefined;
}
async setValue(folderPath: string, trustLevel: TrustLevel): Promise<void> {
if (this.errors.length > 0) {
const errorMessages = this.errors.map(
(error) => `Error in ${error.path}: ${error.message}`,
);
throw new FatalConfigError(
`Cannot update trusted folders because the configuration file is invalid:\n${errorMessages.join('\n')}\nPlease fix the file manually before trying to update it.`,
);
}
const dirPath = path.dirname(this.user.path);
if (!fs.existsSync(dirPath)) {
await fsPromises.mkdir(dirPath, { recursive: true });
}
// lockfile requires the file to exist
if (!fs.existsSync(this.user.path)) {
await fsPromises.writeFile(this.user.path, JSON.stringify({}, null, 2), {
mode: 0o600,
});
}
const release = await lock(this.user.path, {
retries: {
retries: 10,
minTimeout: 100,
},
});
try {
// Re-read the file to handle concurrent updates
const content = await fsPromises.readFile(this.user.path, 'utf-8');
let config: Record<string, TrustLevel>;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
config = parseTrustedFoldersJson(content) as Record<string, TrustLevel>;
} catch (error) {
coreEvents.emitFeedback(
'error',
`Failed to parse trusted folders file at ${this.user.path}. The file may be corrupted.`,
error,
);
config = {};
}
const originalTrustLevel = config[folderPath];
config[folderPath] = trustLevel;
this.user.config[folderPath] = trustLevel;
try {
saveTrustedFolders({ ...this.user, config });
} catch (e) {
// Revert the in-memory change if the save failed.
if (originalTrustLevel === undefined) {
delete this.user.config[folderPath];
} else {
this.user.config[folderPath] = originalTrustLevel;
}
throw e;
}
} finally {
await release();
}
}
}
let loadedTrustedFolders: LoadedTrustedFolders | undefined;
/**
* FOR TESTING PURPOSES ONLY.
* Resets the in-memory cache of the trusted folders configuration.
*/
export function resetTrustedFoldersForTesting(): void {
loadedTrustedFolders = undefined;
clearRealPathCacheForTesting();
}
export function loadTrustedFolders(): LoadedTrustedFolders {
if (loadedTrustedFolders) {
return loadedTrustedFolders;
}
const errors: TrustedFoldersError[] = [];
const userConfig: Record<string, TrustLevel> = {};
const userPath = getTrustedFoldersPath();
try {
if (fs.existsSync(userPath)) {
const content = fs.readFileSync(userPath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const parsed = parseTrustedFoldersJson(content) as Record<string, string>;
if (
typeof parsed !== 'object' ||
parsed === null ||
Array.isArray(parsed)
) {
errors.push({
message: 'Trusted folders file is not a valid JSON object.',
path: userPath,
});
} else {
for (const [path, trustLevel] of Object.entries(parsed)) {
if (isTrustLevel(trustLevel)) {
userConfig[path] = trustLevel;
} else {
const possibleValues = Object.values(TrustLevel).join(', ');
errors.push({
message: `Invalid trust level "${trustLevel}" for path "${path}". Possible values are: ${possibleValues}.`,
path: userPath,
});
}
}
}
}
} catch (error) {
errors.push({
message: getErrorMessage(error),
path: userPath,
});
}
loadedTrustedFolders = new LoadedTrustedFolders(
{ path: userPath, config: userConfig },
errors,
);
return loadedTrustedFolders;
}
export function saveTrustedFolders(
trustedFoldersFile: TrustedFoldersFile,
): void {
// Ensure the directory exists
const dirPath = path.dirname(trustedFoldersFile.path);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
const content = JSON.stringify(trustedFoldersFile.config, null, 2);
const tempPath = `${trustedFoldersFile.path}.tmp.${crypto.randomUUID()}`;
try {
fs.writeFileSync(tempPath, content, {
encoding: 'utf-8',
mode: 0o600,
});
fs.renameSync(tempPath, trustedFoldersFile.path);
} catch (error) {
// Clean up temp file if it was created but rename failed
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// Ignore cleanup errors
}
}
throw error;
}
}
export type {
TrustRule,
TrustedFoldersError,
TrustedFoldersFile,
TrustResult,
LoadedTrustedFolders,
} from '@google/gemini-cli-core';
/** Is folder trust feature enabled per the current applied settings */
export function isFolderTrustEnabled(settings: Settings): boolean {
@@ -335,57 +34,24 @@ export function isFolderTrustEnabled(settings: Settings): boolean {
return folderTrustSetting;
}
function getWorkspaceTrustFromLocalConfig(
workspaceDir: string,
trustConfig?: Record<string, TrustLevel>,
headlessOptions?: HeadlessModeOptions,
): TrustResult {
const folders = loadTrustedFolders();
const configToUse = trustConfig ?? folders.user.config;
if (folders.errors.length > 0) {
const errorMessages = folders.errors.map(
(error) => `Error in ${error.path}: ${error.message}`,
);
throw new FatalConfigError(
`${errorMessages.join('\n')}\nPlease fix the configuration file and try again.`,
);
}
const isTrusted = folders.isPathTrusted(
workspaceDir,
configToUse,
headlessOptions,
);
return {
isTrusted,
source: isTrusted !== undefined ? 'file' : undefined,
};
export function loadTrustedFolders(): LoadedTrustedFolders {
return loadCoreTrustedFolders();
}
/**
* Returns true or false if the workspace is considered "trusted".
*/
export function isWorkspaceTrusted(
settings: Settings,
workspaceDir: string = process.cwd(),
trustConfig?: Record<string, TrustLevel>,
headlessOptions?: HeadlessModeOptions,
): TrustResult {
if (isHeadlessMode(headlessOptions)) {
return { isTrusted: true, source: undefined };
}
if (!isFolderTrustEnabled(settings)) {
return { isTrusted: true, source: undefined };
}
const ideTrust = ideContextStore.get()?.workspaceState?.isTrusted;
if (ideTrust !== undefined) {
return { isTrusted: ideTrust, source: 'ide' };
}
// Fall back to the local user configuration
return getWorkspaceTrustFromLocalConfig(
workspaceDir,
trustConfig,
headlessOptions,
);
): {
isTrusted: boolean | undefined;
source: 'ide' | 'file' | 'env' | undefined;
} {
return checkPathTrust({
path: workspaceDir,
isFolderTrustEnabled: isFolderTrustEnabled(settings),
isHeadless: isHeadlessMode(headlessOptions),
});
}
+3
View File
@@ -280,6 +280,7 @@ describe('gemini.tsx main function', () => {
vi.stubEnv('GEMINI_SANDBOX', '');
vi.stubEnv('SANDBOX', '');
vi.stubEnv('SHPOOL_SESSION_NAME', '');
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true');
initialUnhandledRejectionListeners =
process.listeners('unhandledRejection');
@@ -555,6 +556,7 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
skipTrust: undefined,
});
await act(async () => {
@@ -613,6 +615,7 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
skipTrust: undefined,
});
await act(async () => {
+7 -1
View File
@@ -532,7 +532,7 @@ export async function main() {
const { setupInitialActivityLogger } = await import(
'./utils/devtoolsService.js'
);
await setupInitialActivityLogger(config);
setupInitialActivityLogger(config);
}
// Register config for telemetry shutdown
@@ -661,6 +661,12 @@ export async function main() {
cliStartupHandle?.end();
if (!config.isInteractive()) {
for (const warning of startupWarnings) {
writeToStderr(warning.message + '\n');
}
}
// Render UI, passing necessary config values. Check that there is no command line question.
if (config.isInteractive()) {
// Earlier initialization phases (like TerminalCapabilityManager resolving
+1
View File
@@ -181,6 +181,7 @@ describe('gemini.tsx main function cleanup', () => {
beforeEach(() => {
vi.clearAllMocks();
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true');
});
afterEach(() => {
+1 -1
View File
@@ -80,7 +80,7 @@ export async function runNonInteractive(
const { setupInitialActivityLogger } = await import(
'./utils/devtoolsService.js'
);
await setupInitialActivityLogger(config);
setupInitialActivityLogger(config);
}
const { stdout: workingStdout } = createWorkingStdio();
@@ -80,7 +80,7 @@ export async function runNonInteractive({
const { setupInitialActivityLogger } = await import(
'./utils/devtoolsService.js'
);
await setupInitialActivityLogger(config);
setupInitialActivityLogger(config);
}
const { stdout: workingStdout } = createWorkingStdio();
+5 -1
View File
@@ -38,7 +38,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
})),
isMemoryManagerEnabled: vi.fn(() => false),
isMemoryV2Enabled: vi.fn(() => false),
isAutoMemoryEnabled: vi.fn(() => false),
getListExtensions: vi.fn(() => false),
getExtensions: vi.fn(() => []),
@@ -89,6 +89,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getAccessibility: vi.fn().mockReturnValue({}),
getTelemetryEnabled: vi.fn().mockReturnValue(false),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
getTelemetryOtlpEndpoint: vi.fn().mockReturnValue(''),
getTelemetryOtlpProtocol: vi.fn().mockReturnValue('grpc'),
getTelemetryTarget: vi.fn().mockReturnValue(''),
@@ -123,6 +124,9 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getCompressionThreshold: vi.fn().mockResolvedValue(undefined),
getUserCaching: vi.fn().mockResolvedValue(false),
getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false),
getBestEffortProEnabled: vi.fn().mockResolvedValue(false),
getProTimeoutMinutes: vi.fn().mockResolvedValue(5),
getProTimeoutFallbackDurationMinutes: vi.fn().mockResolvedValue(60),
getClassifierThreshold: vi.fn().mockResolvedValue(undefined),
getBannerTextNoCapacityIssues: vi.fn().mockResolvedValue(''),
getBannerTextCapacityIssues: vi.fn().mockResolvedValue(''),
+2 -7
View File
@@ -92,7 +92,6 @@ import {
ApiKeyUpdatedEvent,
LegacyAgentProtocol,
type InjectionSource,
startMemoryService,
} from '@google/gemini-cli-core';
import { validateAuthMethod } from '../config/auth.js';
import process from 'node:process';
@@ -125,6 +124,7 @@ import { type BackgroundTask } from './hooks/useExecutionLifecycle.js';
import { useVim } from './hooks/vim.js';
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
import { type InitializationResult } from '../core/initializer.js';
import { startAutoMemoryIfEnabled } from '../utils/autoMemory.js';
import { useFocus } from './hooks/useFocus.js';
import { useKeypress, type Key } from './hooks/useKeypress.js';
import { KeypressPriority } from './contexts/KeypressContext.js';
@@ -486,12 +486,7 @@ export const AppContainer = (props: AppContainerProps) => {
setConfigInitialized(true);
startupProfiler.flush(config);
// Fire-and-forget Auto Memory service (skill extraction from past sessions)
if (config.isAutoMemoryEnabled()) {
startMemoryService(config).catch((e) => {
debugLogger.error('Failed to start memory service:', e);
});
}
startAutoMemoryIfEnabled(config);
const sessionStartSource = resumedSessionData
? SessionStartSource.Resume
@@ -10,8 +10,7 @@
<rect x="27" y="0" width="324" height="17" fill="#141414" />
<text x="27" y="2" fill="#ffffff" textLength="324" lengthAdjust="spacingAndGlyphs">Can you edit InputPrompt.tsx for me?</text>
<rect x="351" y="0" width="549" height="17" fill="#141414" />
<rect x="0" y="17" width="900" height="17" fill="#141414" />
<text x="0" y="19" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
<text x="0" y="19" fill="#141414" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<text x="0" y="53" fill="#333333" textLength="891" lengthAdjust="spacingAndGlyphs">╭─────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#ffffaf" textLength="63" lengthAdjust="spacingAndGlyphs" font-weight="bold">? Edit </text>

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

@@ -2,7 +2,7 @@
exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation box in the frame of the entire terminal 1`] = `
" > Can you edit InputPrompt.tsx for me?
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
╭─────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ? Edit packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto… │
+1 -1
View File
@@ -52,7 +52,7 @@ export function ApiAuthDialog({
height: 4,
},
inputFilter: (text) =>
text.replace(/[^a-zA-Z0-9_-]/g, '').replace(/[\r\n]/g, ''),
text.replace(/[^a-zA-Z0-9_.-]/g, '').replace(/[\r\n]/g, ''),
singleLine: true,
});
+2 -1
View File
@@ -17,7 +17,8 @@ import { randomUUID } from 'node:crypto';
export const clearCommand: SlashCommand = {
name: 'clear',
description: 'Clear the screen and conversation history',
altNames: ['new'],
description: 'Clear the screen and start a new session',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context, _args) => {
@@ -159,6 +159,7 @@ Implement a comprehensive authentication system with multiple providers.
isTrustedFolder: () => true,
getPreferredEditor: () => undefined,
getSessionId: () => 'test-session-id',
getProjectRoot: () => mockTargetDir,
storage: {
getPlansDir: () => mockPlansDir,
},
@@ -466,6 +467,7 @@ Implement a comprehensive authentication system with multiple providers.
getIdeMode: () => false,
isTrustedFolder: () => true,
getSessionId: () => 'test-session-id',
getProjectRoot: () => mockTargetDir,
storage: {
getPlansDir: () => mockPlansDir,
},
@@ -85,6 +85,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
const pathError = await validatePlanPath(
planPath,
config.storage.getPlansDir(),
config.getProjectRoot(),
);
if (ignore) return;
if (pathError) {
@@ -56,11 +56,8 @@ import * as clipboardUtils from '../utils/clipboardUtils.js';
import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import stripAnsi from 'strip-ansi';
import chalk from 'chalk';
import { StreamingState } from '../types.js';
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
import type { UIState } from '../contexts/UIStateContext.js';
import { isLowColorDepth } from '../utils/terminalUtils.js';
import { cpLen } from '../utils/textUtils.js';
import { defaultKeyMatchers, Command } from '../key/keyMatchers.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
@@ -78,9 +75,6 @@ vi.mock('../hooks/useReverseSearchCompletion.js');
vi.mock('clipboardy');
vi.mock('../utils/clipboardUtils.js');
vi.mock('../hooks/useKittyKeyboardProtocol.js');
vi.mock('../utils/terminalUtils.js', () => ({
isLowColorDepth: vi.fn(() => false),
}));
// Mock ink BEFORE importing components that use it to intercept terminalCursorPosition
vi.mock('ink', async (importOriginal) => {
@@ -1914,172 +1908,6 @@ describe('InputPrompt', () => {
unmount();
});
describe('Background Color Styles', () => {
beforeEach(() => {
vi.mocked(isLowColorDepth).mockReturnValue(false);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should render with background color by default', async () => {
const { stdout, unmount } = await renderWithProviders(
<TestInputPrompt {...props} />,
);
await waitFor(() => {
const frame = stdout.lastFrameRaw();
expect(frame).toContain('▀');
expect(frame).toContain('▄');
});
unmount();
});
it.each([
{ color: 'black', name: 'black' },
{ color: '#000000', name: '#000000' },
{ color: '#000', name: '#000' },
{ color: 'white', name: 'white' },
{ color: '#ffffff', name: '#ffffff' },
{ color: '#fff', name: '#fff' },
])(
'should render with safe grey background but NO side borders in 8-bit mode when background is $name',
async ({ color }) => {
vi.mocked(isLowColorDepth).mockReturnValue(true);
const { stdout, unmount } = await renderWithProviders(
<TestInputPrompt {...props} />,
{
uiState: {
terminalBackgroundColor: color,
} as Partial<UIState>,
},
);
const isWhite =
color === 'white' || color === '#ffffff' || color === '#fff';
const expectedBgColor = isWhite ? '#eeeeee' : '#1c1c1c';
await waitFor(() => {
const frame = stdout.lastFrameRaw();
// Use chalk to get the expected background color escape sequence
const bgCheck = chalk.bgHex(expectedBgColor)(' ');
const bgCode = bgCheck.substring(0, bgCheck.indexOf(' '));
// Background color code should be present
expect(frame).toContain(bgCode);
// Background characters should be rendered
expect(frame).toContain('▀');
expect(frame).toContain('▄');
// Side borders should STILL be removed
expect(frame).not.toContain('│');
});
unmount();
},
);
it('should NOT render with background color but SHOULD render horizontal lines when color depth is < 24 and background is NOT black', async () => {
vi.mocked(isLowColorDepth).mockReturnValue(true);
const { stdout, unmount } = await renderWithProviders(
<TestInputPrompt {...props} />,
{
uiState: {
terminalBackgroundColor: '#333333',
} as Partial<UIState>,
},
);
await waitFor(() => {
const frame = stdout.lastFrameRaw();
expect(frame).not.toContain('▀');
expect(frame).not.toContain('▄');
// It SHOULD have horizontal fallback lines
expect(frame).toContain('─');
// It SHOULD NOT have vertical side borders (standard Box borders have │)
expect(frame).not.toContain('│');
});
unmount();
});
it('should handle 4-bit color mode (16 colors) as low color depth', async () => {
vi.mocked(isLowColorDepth).mockReturnValue(true);
const { stdout, unmount } = await renderWithProviders(
<TestInputPrompt {...props} />,
{
uiState: {
terminalBackgroundColor: 'black',
} as Partial<UIState>,
},
);
await waitFor(() => {
const frame = stdout.lastFrameRaw();
expect(frame).toContain('▀');
expect(frame).not.toContain('│');
});
unmount();
});
it('should render horizontal lines (but NO background) in 8-bit mode when background is blue', async () => {
vi.mocked(isLowColorDepth).mockReturnValue(true);
const { stdout, unmount } = await renderWithProviders(
<TestInputPrompt {...props} />,
{
uiState: {
terminalBackgroundColor: 'blue',
} as Partial<UIState>,
},
);
await waitFor(() => {
const frame = stdout.lastFrameRaw();
// Should NOT have background characters
expect(frame).not.toContain('▀');
expect(frame).not.toContain('▄');
// Should HAVE horizontal lines from the fallback Box borders
// Box style "round" uses these for top/bottom
expect(frame).toContain('─');
// Should NOT have vertical side borders
expect(frame).not.toContain('│');
});
unmount();
});
it('should render with plain borders when useBackgroundColor is false', async () => {
props.config.getUseBackgroundColor = () => false;
const { stdout, unmount } = await renderWithProviders(
<TestInputPrompt {...props} />,
);
await waitFor(() => {
const frame = stdout.lastFrameRaw();
expect(frame).not.toContain('▀');
expect(frame).not.toContain('▄');
// Check for Box borders (round style uses unicode box chars)
expect(frame).toMatch(/[─│┐└┘┌]/);
});
unmount();
});
});
describe('cursor-based completion trigger', () => {
it.each([
{
@@ -4007,9 +3835,9 @@ describe('InputPrompt', () => {
expect(stdout.lastFrame()).toContain('hello world');
});
// With plain borders: 1(border) + 1(padding) + 2(prompt) = 4 offset (x=4, col=5)
// With plain borders offset
await act(async () => {
stdin.write(`\x1b[<0;5;2M`); // Click at col 5, row 2
stdin.write(`\x1b[<0;4;2M`); // Click at col 4, row 2
});
await waitFor(() => {
+8 -32
View File
@@ -73,8 +73,6 @@ import {
import { parseSlashCommand } from '../../utils/commands.js';
import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { getSafeLowColorBackground } from '../themes/color-utils.js';
import { isLowColorDepth } from '../utils/terminalUtils.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useInputState } from '../contexts/InputContext.js';
@@ -1645,21 +1643,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
);
const useBackgroundColor = config.getUseBackgroundColor();
const isLowColor = isLowColorDepth();
const terminalBg = theme.background.primary || 'black';
// We should fallback to lines if the background color is disabled OR if it is
// enabled but we are in a low color depth terminal where we don't have a safe
// background color to use.
const useLineFallback = useMemo(() => {
if (!useBackgroundColor) {
return true;
}
if (isLowColor) {
return !getSafeLowColorBackground(terminalBg);
}
return false;
}, [useBackgroundColor, isLowColor, terminalBg]);
const prevCursorRef = useRef(buffer.visualCursor);
const prevTextRef = useRef(buffer.text);
@@ -1698,8 +1681,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}, [buffer.visualCursor, buffer.text, focus]);
const listBackgroundColor =
useLineFallback || !useBackgroundColor ? undefined : theme.background.input;
const listBackgroundColor = !useBackgroundColor
? undefined
: theme.background.input;
const useLineFallback = !!process.env['NO_COLOR'];
useEffect(() => {
if (onSuggestionsVisibilityChange) {
@@ -1762,7 +1748,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return (
<>
{suggestionsPosition === 'above' && suggestionsNode}
{useLineFallback ? (
{useLineFallback || !useBackgroundColor ? (
<Box
borderStyle="round"
borderTop={true}
@@ -1781,17 +1767,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
backgroundOpacity={1}
useBackgroundColor={useBackgroundColor}
>
<Box
flexGrow={1}
flexDirection="row"
paddingX={1}
borderColor={borderColor}
borderStyle={useLineFallback ? 'round' : undefined}
borderTop={false}
borderBottom={false}
borderLeft={!useBackgroundColor}
borderRight={!useBackgroundColor}
>
<Box flexGrow={1} flexDirection="row" paddingX={1}>
<Text
color={statusColor ?? theme.text.accent}
aria-label={statusText || undefined}
@@ -1880,7 +1856,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
</Box>
</Box>
</HalfLinePaddedBox>
{useLineFallback ? (
{useLineFallback || !useBackgroundColor ? (
<Box
borderStyle="round"
borderTop={false}
@@ -219,7 +219,7 @@ describe('Hint Visibility', () => {
<ThemeDialog {...baseProps} settings={settings} />,
{
settings,
uiState: { terminalBackgroundColor: '#FFFFFF' },
uiState: { terminalBackgroundColor: '#123456' },
},
);
+16 -7
View File
@@ -287,11 +287,15 @@ export function ThemeDialog({
const itemWithExtras = item as typeof item & {
themeWarning?: string;
themeMatch?: string;
themeNameDisplay?: string;
themeTypeDisplay?: string;
};
if (item.themeNameDisplay && item.themeTypeDisplay) {
const match = item.themeNameDisplay.match(/^(.*) \((.*)\)$/);
let themeNamePart: React.ReactNode = item.themeNameDisplay;
if (itemWithExtras.themeNameDisplay) {
const match =
itemWithExtras.themeNameDisplay.match(/^(.*) \((.*)\)$/);
let themeNamePart: React.ReactNode =
itemWithExtras.themeNameDisplay;
if (match) {
themeNamePart = (
<>
@@ -303,10 +307,15 @@ export function ThemeDialog({
return (
<Text color={titleColor} wrap="truncate" key={item.key}>
{themeNamePart}{' '}
<Text color={theme.text.secondary}>
{item.themeTypeDisplay}
</Text>
{themeNamePart}
{itemWithExtras.themeTypeDisplay ? (
<>
{' '}
<Text color={theme.text.secondary}>
{itemWithExtras.themeTypeDisplay}
</Text>
</>
) : null}
{itemWithExtras.themeMatch && (
<Text color={theme.status.success}>
{itemWithExtras.themeMatch}
@@ -52,6 +52,7 @@ describe('ToolConfirmationQueue', () => {
getModel: () => 'gemini-pro',
getDebugMode: () => false,
getTargetDir: () => '/mock/target/dir',
getProjectRoot: () => '/mock/project/root',
getFileSystemService: () => ({
readFile: vi.fn().mockResolvedValue('Plan content'),
}),
@@ -139,9 +139,9 @@ Tips for getting started:
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Hello Gemini
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> Hello Gemini
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
✦ Hello User!
"
`;
@@ -5,15 +5,11 @@
<rect width="920" height="88" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="36" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="36" y="34" width="9" height="17" fill="#ffffff" />
<text x="36" y="36" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">s</text>
<text x="45" y="36" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">econd line</text>
<text x="891" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="27" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
<rect x="27" y="34" width="9" height="17" fill="#ffffff" />
<text x="27" y="36" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">s</text>
<text x="36" y="36" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">econd line</text>
<text x="0" y="53" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -5,14 +5,10 @@
<rect width="920" height="88" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="36" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
<rect x="126" y="17" width="9" height="17" fill="#ffffff" />
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="36" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs">second line</text>
<text x="891" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="27" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
<rect x="117" y="17" width="9" height="17" fill="#ffffff" />
<text x="27" y="36" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs">second line</text>
<text x="0" y="53" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -5,19 +5,13 @@
<rect width="920" height="105" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="36" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="36" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">sec</text>
<rect x="63" y="34" width="9" height="17" fill="#ffffff" />
<text x="63" y="36" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">o</text>
<text x="72" y="36" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs">nd line</text>
<text x="891" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="53" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">third line</text>
<text x="891" y="53" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="27" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
<text x="27" y="36" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">sec</text>
<rect x="54" y="34" width="9" height="17" fill="#ffffff" />
<text x="54" y="36" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">o</text>
<text x="63" y="36" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs">nd line</text>
<text x="27" y="53" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">third line</text>
<text x="0" y="70" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -5,16 +5,10 @@
<rect width="920" height="105" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="36" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="36" y="34" width="9" height="17" fill="#ffffff" />
<text x="891" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="53" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">third line</text>
<text x="891" y="53" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="27" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
<rect x="27" y="34" width="9" height="17" fill="#ffffff" />
<text x="27" y="53" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">third line</text>
<text x="0" y="70" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -5,12 +5,10 @@
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="36" y="19" fill="#ffffff" textLength="9" lengthAdjust="spacingAndGlyphs">👍</text>
<rect x="45" y="17" width="9" height="17" fill="#ffffff" />
<text x="45" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">A</text>
<text x="882" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="27" y="19" fill="#ffffff" textLength="9" lengthAdjust="spacingAndGlyphs">👍</text>
<rect x="36" y="17" width="9" height="17" fill="#ffffff" />
<text x="36" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">A</text>
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -5,12 +5,10 @@
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="36" y="17" width="9" height="17" fill="#ffffff" />
<text x="36" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">h</text>
<text x="45" y="19" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">ello</text>
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="17" width="9" height="17" fill="#ffffff" />
<text x="27" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">h</text>
<text x="36" y="19" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">ello</text>
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -5,10 +5,8 @@
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="36" y="19" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs">hello 👍</text>
<text x="882" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="27" y="19" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs">hello 👍</text>
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -5,11 +5,9 @@
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="36" y="19" fill="#ffffff" textLength="9" lengthAdjust="spacingAndGlyphs">👍</text>
<rect x="45" y="17" width="9" height="17" fill="#ffffff" />
<text x="882" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="27" y="19" fill="#ffffff" textLength="9" lengthAdjust="spacingAndGlyphs">👍</text>
<rect x="36" y="17" width="9" height="17" fill="#ffffff" />
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -5,11 +5,9 @@
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="36" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">hello</text>
<rect x="81" y="17" width="9" height="17" fill="#ffffff" />
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="27" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">hello</text>
<rect x="72" y="17" width="9" height="17" fill="#ffffff" />
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -5,13 +5,11 @@
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="36" y="19" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">hello </text>
<rect x="90" y="17" width="9" height="17" fill="#ffffff" />
<text x="90" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">👍</text>
<text x="99" y="19" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs"> world</text>
<text x="882" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="27" y="19" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">hello </text>
<rect x="81" y="17" width="9" height="17" fill="#ffffff" />
<text x="81" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">👍</text>
<text x="90" y="19" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs"> world</text>
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -5,13 +5,11 @@
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="36" y="19" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">hel</text>
<rect x="63" y="17" width="9" height="17" fill="#ffffff" />
<text x="63" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">l</text>
<text x="72" y="19" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs">o world</text>
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="27" y="19" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">hel</text>
<rect x="54" y="17" width="9" height="17" fill="#ffffff" />
<text x="54" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">l</text>
<text x="63" y="19" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs">o world</text>
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -5,14 +5,12 @@
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="36" y="19" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">run </text>
<text x="72" y="19" fill="#d7afff" textLength="45" lengthAdjust="spacingAndGlyphs">@path</text>
<rect x="117" y="17" width="9" height="17" fill="#d7afff" />
<text x="117" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">/</text>
<text x="126" y="19" fill="#d7afff" textLength="63" lengthAdjust="spacingAndGlyphs">to/file</text>
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="27" y="19" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">run </text>
<text x="63" y="19" fill="#d7afff" textLength="45" lengthAdjust="spacingAndGlyphs">@path</text>
<rect x="108" y="17" width="9" height="17" fill="#d7afff" />
<text x="108" y="19" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">/</text>
<text x="117" y="19" fill="#d7afff" textLength="63" lengthAdjust="spacingAndGlyphs">to/file</text>
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -5,12 +5,10 @@
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="36" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">hello</text>
<rect x="81" y="17" width="9" height="17" fill="#ffffff" />
<text x="90" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">world</text>
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="27" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">hello</text>
<rect x="72" y="17" width="9" height="17" fill="#ffffff" />
<text x="81" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">world</text>
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -5,11 +5,9 @@
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="36" y="17" width="9" height="17" fill="#ffffff" />
<text x="45" y="19" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs"> Type your message or @path/to/file</text>
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="17" width="9" height="17" fill="#ffffff" />
<text x="36" y="19" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs"> Type your message or @path/to/file</text>
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -5,16 +5,10 @@
<rect width="920" height="105" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="36" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">hello</text>
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="53" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">world</text>
<rect x="81" y="51" width="9" height="17" fill="#ffffff" />
<text x="891" y="53" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="27" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">hello</text>
<text x="27" y="53" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">world</text>
<rect x="72" y="51" width="9" height="17" fill="#ffffff" />
<text x="0" y="70" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -4,15 +4,13 @@
</style>
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<rect x="0" y="0" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="2" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<text x="0" y="2" fill="#1f1f1f" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
<rect x="0" y="17" width="9" height="17" fill="#1f1f1f" />
<rect x="9" y="17" width="18" height="17" fill="#1f1f1f" />
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="17" width="324" height="17" fill="#1f1f1f" />
<text x="27" y="19" fill="#afafaf" textLength="324" lengthAdjust="spacingAndGlyphs"> Type your message or @path/to/file</text>
<rect x="351" y="17" width="549" height="17" fill="#1f1f1f" />
<rect x="0" y="34" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="36" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
<text x="0" y="36" fill="#1f1f1f" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -2,105 +2,105 @@
exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'at the beginning of a line' in a multiline block 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> first line
second line
> first line
second line
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'at the end of a line' in a multiline block 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> first line
second line
> first line
second line
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'in the middle of a line' in a multiline block 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> first line
second line
third line
> first line
second line
third line
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor on a blank line in a multiline block 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> first line
│ │
third line
> first line
third line
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'after multi-byte unicode characters' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> 👍A
> 👍A
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the beginning of the line' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> hello
> hello
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of a line with unicode cha…' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> hello 👍
> hello 👍
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of a short line with unico…' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> 👍
> 👍
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of the line' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> hello
> hello
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'for multi-byte unicode characters' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> hello 👍 world
> hello 👍 world
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'mid-word' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> hello world
> hello world
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on a highlighted token' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> run @path/to/file
> run @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on a space between words' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> hello world
> hello world
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on an empty line' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> Type your message or @path/to/file
> Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > History Navigation and Completion Suppression > should not render suggestions during history navigation 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> second message
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-collapsed-match 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
(r:) Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll →
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
...
@@ -108,9 +108,9 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-expanded-match 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
(r:) Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll ←
lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll
llllllllllllllllllllllllllllllllllllllllllllllllll
@@ -118,87 +118,87 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-collapsed-match 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
(r:) commit
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
git commit -m "feat: add search" in src/app
"
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-expanded-match 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
(r:) commit
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
git commit -m "feat: add search" in src/app
"
`;
exports[`InputPrompt > image path transformation snapshots > should snapshot collapsed image path 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> [Image ...reenshot2x.png]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`InputPrompt > image path transformation snapshots > should snapshot expanded image path when cursor is on it 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> @/path/to/screenshots/screenshot2x.png
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> [Pasted Text: 10 lines]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 2`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> [Pasted Text: 10 lines]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 3`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> [Pasted Text: 10 lines]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`InputPrompt > multiline rendering > should correctly render multiline input including blank lines 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> hello
│ │
world
> hello
world
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"
`;
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
! Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
* Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
@@ -6,16 +6,14 @@
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">ScrollableList </text>
<text x="0" y="19" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">AppHeader(full) </text>
<rect x="0" y="34" width="900" height="17" fill="#141414" />
<text x="0" y="36" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<text x="0" y="36" fill="#141414" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
<rect x="0" y="51" width="9" height="17" fill="#141414" />
<rect x="9" y="51" width="18" height="17" fill="#141414" />
<text x="9" y="53" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="51" width="135" height="17" fill="#141414" />
<text x="27" y="53" fill="#ffffff" textLength="135" lengthAdjust="spacingAndGlyphs">Plan a solution</text>
<rect x="162" y="51" width="738" height="17" fill="#141414" />
<rect x="0" y="68" width="900" height="17" fill="#141414" />
<text x="0" y="70" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
<text x="0" y="70" fill="#141414" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<text x="0" y="87" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs" font-style="italic"> Thinking... </text>
<text x="9" y="104" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="121" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -94,9 +94,9 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unc
exports[`MainContent > renders a ToolConfirmationQueue without an extra line when preceded by hidden tools 1`] = `
"AppHeader(full)
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Apply plan
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> Apply plan
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
╭──────────────────────────────────────────────────────────────────────────────╮
│ Ready to start implementation? │
@@ -123,17 +123,17 @@ exports[`MainContent > renders a split tool group without a gap between static a
exports[`MainContent > renders a spurious line when a tool group has only hidden tools and borderBottom true 1`] = `
"AppHeader(full)
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Apply plan
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> Apply plan
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`MainContent > renders a subagent with a complete box including bottom border 1`] = `
"AppHeader(full)
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Investigate
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> Investigate
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
╭──────────────────────────────────────────────────────────────────────────╮
│ ≡ Running Agent... (ctrl+o to collapse) │
@@ -149,9 +149,9 @@ exports[`MainContent > renders a subagent with a complete box including bottom b
exports[`MainContent > renders mixed history items (user + gemini) with single line padding between them 1`] = `
"ScrollableList
AppHeader(full)
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> User message
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> User message
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
✦ Gemini response
Gemini response
Gemini response
@@ -195,9 +195,9 @@ AppHeader(full)
exports[`MainContent > renders multiple thinking messages sequentially correctly 1`] = `
"ScrollableList
AppHeader(full)
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Plan a solution
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> Plan a solution
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
Thinking...
│ Initial analysis
@@ -217,9 +217,9 @@ AppHeader(full)
exports[`MainContent > renders multiple thinking messages sequentially correctly 2`] = `
"ScrollableList
AppHeader(full)
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Plan a solution
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> Plan a solution
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
Thinking...
│ Initial analysis
@@ -16,7 +16,7 @@ exports[`Initial Theme Selection > should default to a dark theme when terminal
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. Tokyo Night Dark │ │ │
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
│ 12. ANSI Light (Incompatible) └─────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
@@ -32,7 +32,7 @@ exports[`Initial Theme Selection > should default to a light theme when terminal
│ ▲ ┌─────────────────────────────────────────────────┐ │
│ 1. ANSI Light │ │ │
│ 2. Ayu Light │ 1 # function │ │
│ ● 3. Default Light │ 2 def fibonacci(n): │ │
│ ● 3. Default Light (Matches terminal) │ 2 def fibonacci(n): │ │
│ 4. GitHub Light │ 3 a, b = 0, 1 │ │
│ 5. GitHub Light Colorblind Light (Mat… │ 4 for _ in range(n): │ │
│ 6. Google Code Light │ 5 a, b = b, a + b │ │
@@ -66,7 +66,7 @@ exports[`Initial Theme Selection > should use the theme from settings even if te
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. Tokyo Night Dark │ │ │
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
│ 12. ANSI Light (Incompatible) └─────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
@@ -105,7 +105,7 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. Tokyo Night Dark │ │ │
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
│ 12. ANSI Light (Incompatible) └─────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
@@ -130,7 +130,7 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. Tokyo Night Dark │ │ │
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
│ 12. ANSI Light (Incompatible) └─────────────────────────────────────────────────┘ │
│ ▼ │
│ ╭─────────────────────────────────────────────────╮ │
│ │ DEVELOPER TOOLS (Not visible to users) │ │
@@ -1,30 +1,30 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`UserMessage > renders multiline user message 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> Line 1
Line 2
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`UserMessage > renders normal user message with correct prefix 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> Hello Gemini
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`UserMessage > renders slash command message 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> /help
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`UserMessage > transforms image paths in user message 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> Check out this image: [Image my-image.png]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
@@ -3,12 +3,11 @@
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../../test-utils/render.js';
import { HalfLinePaddedBox } from './HalfLinePaddedBox.js';
import { Text, useIsScreenReaderEnabled } from 'ink';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { isITerm2 } from '../../utils/terminalUtils.js';
import { supportsTrueColor } from '@google/gemini-cli-core';
vi.mock('ink', async () => {
const actual = await vi.importActual('ink');
@@ -18,15 +17,24 @@ vi.mock('ink', async () => {
};
});
vi.mock('@google/gemini-cli-core', async () => {
const actual = await vi.importActual('@google/gemini-cli-core');
return {
...actual,
supportsTrueColor: vi.fn(() => true),
};
});
describe('<HalfLinePaddedBox />', () => {
const mockUseIsScreenReaderEnabled = vi.mocked(useIsScreenReaderEnabled);
const mockSupportsTrueColor = vi.mocked(supportsTrueColor);
afterEach(() => {
vi.restoreAllMocks();
});
it('renders standard background and blocks when not iTerm2', async () => {
vi.mocked(isITerm2).mockReturnValue(false);
it('renders standard background and blocks when true color is supported', async () => {
mockSupportsTrueColor.mockReturnValue(true);
const { lastFrame, unmount } = await renderWithProviders(
<HalfLinePaddedBox backgroundBaseColor="blue" backgroundOpacity={0.5}>
@@ -40,8 +48,8 @@ describe('<HalfLinePaddedBox />', () => {
unmount();
});
it('renders iTerm2-specific blocks when iTerm2 is detected', async () => {
vi.mocked(isITerm2).mockReturnValue(true);
it('renders alternative blocks when true color is not supported', async () => {
mockSupportsTrueColor.mockReturnValue(false);
const { lastFrame, unmount } = await renderWithProviders(
<HalfLinePaddedBox backgroundBaseColor="blue" backgroundOpacity={0.5}>
@@ -9,12 +9,8 @@ import { useMemo } from 'react';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { useUIState } from '../../contexts/UIStateContext.js';
import { theme } from '../../semantic-colors.js';
import {
interpolateColor,
resolveColor,
getSafeLowColorBackground,
} from '../../themes/color-utils.js';
import { isLowColorDepth, isITerm2 } from '../../utils/terminalUtils.js';
import { interpolateColor, resolveColor } from '../../themes/color-utils.js';
import { supportsTrueColor } from '@google/gemini-cli-core';
export interface HalfLinePaddedBoxProps {
/**
@@ -56,14 +52,7 @@ const HalfLinePaddedBoxInternal: React.FC<HalfLinePaddedBoxProps> = ({
const { terminalWidth } = useUIState();
const terminalBg = theme.background.primary || 'black';
const isLowColor = isLowColorDepth();
const backgroundColor = useMemo(() => {
// Interpolated background colors often look bad in 256-color terminals
if (isLowColor) {
return getSafeLowColorBackground(terminalBg);
}
const resolvedBase =
resolveColor(backgroundBaseColor) || backgroundBaseColor;
const resolvedTerminalBg = resolveColor(terminalBg) || terminalBg;
@@ -73,37 +62,18 @@ const HalfLinePaddedBoxInternal: React.FC<HalfLinePaddedBoxProps> = ({
resolvedBase,
backgroundOpacity,
);
}, [backgroundBaseColor, backgroundOpacity, terminalBg, isLowColor]);
}, [backgroundBaseColor, backgroundOpacity, terminalBg]);
if (!backgroundColor) {
return <>{children}</>;
}
const isITerm = isITerm2();
const noTrueColor = !supportsTrueColor();
if (isITerm) {
if (noTrueColor) {
return (
<Box
width={terminalWidth}
flexDirection="column"
alignItems="stretch"
minHeight={1}
flexShrink={0}
>
<Box width={terminalWidth} flexDirection="row">
<Text color={backgroundColor}>{'▄'.repeat(terminalWidth)}</Text>
</Box>
<Box
width={terminalWidth}
flexDirection="column"
alignItems="stretch"
backgroundColor={backgroundColor}
>
{children}
</Box>
<Box width={terminalWidth} flexDirection="row">
<Text color={backgroundColor}>{'▀'.repeat(terminalWidth)}</Text>
</Box>
<Box width={terminalWidth} backgroundColor={backgroundColor} paddingY={1}>
{children}
</Box>
);
}
@@ -115,18 +85,20 @@ const HalfLinePaddedBoxInternal: React.FC<HalfLinePaddedBoxProps> = ({
alignItems="stretch"
minHeight={1}
flexShrink={0}
backgroundColor={backgroundColor}
>
<Box width={terminalWidth} flexDirection="row">
<Text backgroundColor={backgroundColor} color={terminalBg}>
{'▀'.repeat(terminalWidth)}
</Text>
<Text color={backgroundColor}>{'▄'.repeat(terminalWidth)}</Text>
</Box>
<Box
width={terminalWidth}
flexDirection="column"
alignItems="stretch"
backgroundColor={backgroundColor}
>
{children}
</Box>
{children}
<Box width={terminalWidth} flexDirection="row">
<Text color={terminalBg} backgroundColor={backgroundColor}>
{'▄'.repeat(terminalWidth)}
</Text>
<Text color={backgroundColor}>{'▀'.repeat(terminalWidth)}</Text>
</Box>
</Box>
);
@@ -1,9 +1,9 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<HalfLinePaddedBox /> > renders iTerm2-specific blocks when iTerm2 is detected 1`] = `
"▄▄▄▄▄▄▄▄▄▄
exports[`<HalfLinePaddedBox /> > renders alternative blocks when true color is not supported 1`] = `
"
Content
▀▀▀▀▀▀▀▀▀▀
"
`;
@@ -17,9 +17,9 @@ exports[`<HalfLinePaddedBox /> > renders nothing when useBackgroundColor is fals
"
`;
exports[`<HalfLinePaddedBox /> > renders standard background and blocks when not iTerm2 1`] = `
"▀▀▀▀▀▀▀▀▀▀
exports[`<HalfLinePaddedBox /> > renders standard background and blocks when true color is supported 1`] = `
"▄▄▄▄▄▄▄▄▄▄
Content
▄▄▄▄▄▄▄▄▄▄
▀▀▀▀▀▀▀▀▀▀
"
`;
@@ -553,6 +553,38 @@ describe('useAtCompletion', () => {
]);
});
it('should pass enableFileWatcher flag into FileSearchFactory options', async () => {
const structure: FileSystemStructure = {
src: {
'index.ts': '',
},
};
testRootDir = await createTmpDir(structure);
const createSpy = vi.spyOn(FileSearchFactory, 'create');
const configWithWatcher = {
getFileFilteringOptions: vi.fn(() => ({
respectGitIgnore: true,
respectGeminiIgnore: true,
enableFileWatcher: true,
})),
getEnableRecursiveFileSearch: () => true,
getFileFilteringEnableFuzzySearch: () => true,
} as unknown as Config;
const { result } = await renderHook(() =>
useTestHarnessForAtCompletion(true, '', configWithWatcher, testRootDir),
);
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
expect(createSpy).toHaveBeenCalled();
const firstCallArg = createSpy.mock.calls[0]?.[0];
expect(firstCallArg?.enableFileWatcher).toBe(true);
});
it('should reset and re-initialize when the cwd changes', async () => {
const structure1: FileSystemStructure = { 'file1.txt': '' };
const rootDir1 = await createTmpDir(structure1);
+31 -5
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useReducer, useRef } from 'react';
import { useCallback, useEffect, useReducer, useRef } from 'react';
import { setTimeout as setTimeoutPromise } from 'node:timers/promises';
import * as path from 'node:path';
import {
@@ -224,15 +224,28 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
setIsLoadingSuggestions(state.isLoading);
}, [state.isLoading, setIsLoadingSuggestions]);
const resetFileSearchState = () => {
const disposeFileSearchers = useCallback(async () => {
const searchers = [...fileSearchMap.current.values()];
fileSearchMap.current.clear();
initEpoch.current += 1;
const closePromises: Array<Promise<void>> = [];
for (const searcher of searchers) {
if (searcher.close) {
closePromises.push(searcher.close());
}
}
await Promise.all(closePromises);
}, []);
const resetFileSearchState = useCallback(() => {
void disposeFileSearchers();
dispatch({ type: 'RESET' });
};
}, [disposeFileSearchers]);
useEffect(() => {
resetFileSearchState();
}, [cwd, config]);
}, [cwd, config, resetFileSearchState]);
useEffect(() => {
const workspaceContext = config?.getWorkspaceContext?.();
@@ -242,7 +255,18 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
workspaceContext.onDirectoriesChanged(resetFileSearchState);
return unsubscribe;
}, [config]);
}, [config, resetFileSearchState]);
useEffect(
() => () => {
void disposeFileSearchers();
searchAbortController.current?.abort();
if (slowSearchTimer.current) {
clearTimeout(slowSearchTimer.current);
}
},
[disposeFileSearchers],
);
// Reacts to user input (`pattern`) ONLY.
useEffect(() => {
@@ -295,6 +319,8 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
),
cache: true,
cacheTtl: 30,
enableFileWatcher:
config?.getFileFilteringOptions()?.enableFileWatcher ?? false,
enableRecursiveFileSearch:
config?.getEnableRecursiveFileSearch() ?? true,
enableFuzzySearch:
@@ -16,7 +16,7 @@ import {
afterEach,
type Mock,
} from 'vitest';
import { NoopSandboxManager } from '@google/gemini-cli-core';
import { NoopSandboxManager, escapeShellArg } from '@google/gemini-cli-core';
const mockIsBinary = vi.hoisted(() => vi.fn());
const mockShellExecutionService = vi.hoisted(() => vi.fn());
@@ -76,7 +76,21 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
isBinary: mockIsBinary,
};
});
vi.mock('node:fs');
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const mockFs = {
...actual,
existsSync: vi.fn(),
mkdtempSync: vi.fn(),
unlinkSync: vi.fn(),
readFileSync: vi.fn(),
rmSync: vi.fn(),
};
return {
...mockFs,
default: mockFs,
};
});
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
const mocked = {
@@ -154,6 +168,7 @@ describe('useExecutionLifecycle', () => {
);
mockIsBinary.mockReturnValue(false);
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(fs.mkdtempSync).mockReturnValue('/tmp/gemini-shell-abcdef');
mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => {
mockShellOutputCallback = callback;
@@ -239,8 +254,9 @@ describe('useExecutionLifecycle', () => {
}),
],
});
const tmpFile = path.join(os.tmpdir(), 'shell_pwd_abcdef.tmp');
const wrappedCommand = `{ ls -l; }; __code=$?; pwd > "${tmpFile}"; exit $__code`;
const tmpFile = path.join('/tmp/gemini-shell-abcdef', 'pwd.tmp');
const escapedTmpFile = escapeShellArg(tmpFile, 'bash');
const wrappedCommand = `{\nls -l\n}\n__code=$?; pwd > ${escapedTmpFile}; exit $__code`;
expect(mockShellExecutionService).toHaveBeenCalledWith(
wrappedCommand,
'/test/dir',
@@ -349,11 +365,9 @@ describe('useExecutionLifecycle', () => {
);
});
// Verify it's using the non-pty shell
const wrappedCommand = `{ stream; }; __code=$?; pwd > "${path.join(
os.tmpdir(),
'shell_pwd_abcdef.tmp',
)}"; exit $__code`;
const tmpFile = path.join('/tmp/gemini-shell-abcdef', 'pwd.tmp');
const escapedTmpFile = escapeShellArg(tmpFile, 'bash');
const wrappedCommand = `{\nstream\n}\n__code=$?; pwd > ${escapedTmpFile}; exit $__code`;
expect(mockShellExecutionService).toHaveBeenCalledWith(
wrappedCommand,
'/test/dir',
@@ -644,7 +658,7 @@ describe('useExecutionLifecycle', () => {
type: 'error',
text: 'An unexpected error occurred: Synchronous spawn error',
});
const tmpFile = path.join(os.tmpdir(), 'shell_pwd_abcdef.tmp');
const tmpFile = path.join('/tmp/gemini-shell-abcdef', 'pwd.tmp');
// Verify that the temporary file was cleaned up
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
expect(setShellInputFocusedMock).toHaveBeenCalledWith(false);
@@ -652,7 +666,7 @@ describe('useExecutionLifecycle', () => {
describe('Directory Change Warning', () => {
it('should show a warning if the working directory changes', async () => {
const tmpFile = path.join(os.tmpdir(), 'shell_pwd_abcdef.tmp');
const tmpFile = path.join('/tmp/gemini-shell-abcdef', 'pwd.tmp');
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('/test/dir/new'); // A different directory
@@ -20,12 +20,12 @@ import {
ShellExecutionService,
ExecutionLifecycleService,
CoreToolCallStatus,
escapeShellArg,
} from '@google/gemini-cli-core';
import { type PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { SHELL_COMMAND_NAME } from '../constants.js';
import { formatBytes } from '../utils/formatters.js';
import crypto from 'node:crypto';
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
@@ -362,18 +362,6 @@ export const useExecutionLifecycle = (
let commandToExecute = rawQuery;
let pwdFilePath: string | undefined;
// On non-windows, wrap the command to capture the final working directory.
if (!isWindows) {
let command = rawQuery.trim();
const pwdFileName = `shell_pwd_${crypto.randomBytes(6).toString('hex')}.tmp`;
pwdFilePath = path.join(os.tmpdir(), pwdFileName);
// Ensure command ends with a separator before adding our own.
if (!command.endsWith(';') && !command.endsWith('&')) {
command += ';';
}
commandToExecute = `{ ${command} }; __code=$?; pwd > "${pwdFilePath}"; exit $__code`;
}
const executeCommand = async () => {
let cumulativeStdout: string | AnsiOutput = '';
let isBinaryStream = false;
@@ -403,9 +391,23 @@ export const useExecutionLifecycle = (
};
abortSignal.addEventListener('abort', abortHandler, { once: true });
onDebugMessage(`Executing in ${targetDir}: ${commandToExecute}`);
try {
// On non-windows, wrap the command to capture the final working directory.
if (!isWindows) {
let command = rawQuery.trim();
if (command.endsWith('\\')) {
command += ' ';
}
const tmpDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-shell-'),
);
pwdFilePath = path.join(tmpDir, 'pwd.tmp');
const escapedPwdFilePath = escapeShellArg(pwdFilePath, 'bash');
commandToExecute = `{\n${command}\n}\n__code=$?; pwd > ${escapedPwdFilePath}; exit $__code`;
}
onDebugMessage(`Executing in ${targetDir}: ${commandToExecute}`);
const activeTheme = themeManager.getActiveTheme();
const shellExecutionConfig = {
...config.getShellExecutionConfig(),
@@ -630,8 +632,18 @@ export const useExecutionLifecycle = (
);
} finally {
abortSignal.removeEventListener('abort', abortHandler);
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
fs.unlinkSync(pwdFilePath);
if (pwdFilePath) {
const tmpDir = path.dirname(pwdFilePath);
try {
if (fs.existsSync(pwdFilePath)) {
fs.unlinkSync(pwdFilePath);
}
if (fs.existsSync(tmpDir)) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
} catch {
// Ignore cleanup errors
}
}
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
@@ -513,6 +513,70 @@ describe('useSlashCompletion', () => {
unmountResume();
});
it('should NOT suggest the auto-list command when typing a non-matching partial after /chat', async () => {
const slashCommands = [
createTestCommand({
name: 'chat',
description: 'Manage chat history',
subCommands: [
createTestCommand({ name: 'list', description: 'List chats' }),
],
}),
];
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/chat x', // 'x' does not match 'list'
slashCommands,
mockCommandContext,
),
);
await resolveMatch();
await waitFor(() => {
// It should NOT have the 'auto' section 'list' suggestion
const autoSuggestion = result.current.suggestions.find(
(s) => s.sectionTitle === 'auto',
);
expect(autoSuggestion).toBeUndefined();
});
unmount();
});
it('should STILL suggest the auto-list command when typing a matching partial after /chat', async () => {
const slashCommands = [
createTestCommand({
name: 'chat',
description: 'Manage chat history',
subCommands: [
createTestCommand({ name: 'list', description: 'List chats' }),
],
}),
];
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/chat l', // 'l' matches 'list'
slashCommands,
mockCommandContext,
),
);
await resolveMatch();
await waitFor(() => {
const autoSuggestion = result.current.suggestions.find(
(s) => s.sectionTitle === 'auto',
);
expect(autoSuggestion).toBeDefined();
expect(autoSuggestion?.label).toBe('list');
});
unmount();
});
it('should sort exact altName matches to the top', async () => {
const slashCommands = [
createTestCommand({
+17 -11
View File
@@ -338,17 +338,23 @@ function useCommandSuggestions(
if (isTopLevelChatOrResumeContext) {
const canonicalParentName = leafCommand.name;
const autoSectionSuggestion: Suggestion = {
label: 'list',
value: 'list',
insertValue: canonicalParentName,
description: 'Browse auto-saved chats',
commandKind: CommandKind.BUILT_IN,
sectionTitle: 'auto',
submitValue: `/${canonicalParentName}`,
};
setSuggestions([autoSectionSuggestion, ...finalSuggestions]);
return;
const autoLabel = 'list';
if (
partial === '' ||
autoLabel.toLowerCase().startsWith(partial.toLowerCase())
) {
const autoSectionSuggestion: Suggestion = {
label: autoLabel,
value: autoLabel,
insertValue: canonicalParentName,
description: 'Browse auto-saved chats',
commandKind: CommandKind.BUILT_IN,
sectionTitle: 'auto',
submitValue: `/${canonicalParentName}`,
};
setSuggestions([autoSectionSuggestion, ...finalSuggestions]);
return;
}
}
setSuggestions(finalSuggestions);
+21
View File
@@ -0,0 +1,21 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
debugLogger,
startMemoryService,
type Config,
} from '@google/gemini-cli-core';
export function startAutoMemoryIfEnabled(config: Config): void {
if (!config.isAutoMemoryEnabled()) {
return;
}
startMemoryService(config).catch((e) => {
debugLogger.error('Failed to start memory service:', e);
});
}
+8 -55
View File
@@ -123,69 +123,22 @@ describe('devtoolsService', () => {
});
describe('setupInitialActivityLogger', () => {
it('stays in buffer mode when no existing server found', async () => {
it('stays in buffer mode (no probe attempted)', () => {
const config = createMockConfig();
const promise = setupInitialActivityLogger(config);
// Probe fires immediately — no server running
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
MockWebSocket.instances[0].simulateError();
await promise;
setupInitialActivityLogger(config);
expect(mockInitActivityLogger).toHaveBeenCalledWith(config, {
mode: 'buffer',
});
expect(mockAddNetworkTransport).not.toHaveBeenCalled();
// No WebSocket probe on startup
expect(MockWebSocket.instances.length).toBe(0);
});
it('attaches transport when existing server found at startup', async () => {
const config = createMockConfig();
const promise = setupInitialActivityLogger(config);
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
MockWebSocket.instances[0].simulateOpen();
await promise;
expect(mockInitActivityLogger).toHaveBeenCalledWith(config, {
mode: 'buffer',
});
expect(mockAddNetworkTransport).toHaveBeenCalledWith(
config,
'127.0.0.1',
25417,
expect.any(Function),
);
expect(
mockActivityLoggerInstance.enableNetworkLogging,
).toHaveBeenCalled();
});
it('F12 short-circuits when startup already connected', async () => {
const config = createMockConfig();
// Startup: probe succeeds
const setupPromise = setupInitialActivityLogger(config);
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
MockWebSocket.instances[0].simulateOpen();
await setupPromise;
mockAddNetworkTransport.mockClear();
mockActivityLoggerInstance.enableNetworkLogging.mockClear();
// F12: should return URL immediately
const url = await startDevToolsServer(config);
expect(url).toBe('http://localhost:25417');
expect(mockAddNetworkTransport).not.toHaveBeenCalled();
expect(mockDevToolsInstance.start).not.toHaveBeenCalled();
});
it('initializes in file mode when target env var is set', async () => {
it('initializes in file mode when target env var is set', () => {
process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'] = '/tmp/test.jsonl';
const config = createMockConfig();
await setupInitialActivityLogger(config);
setupInitialActivityLogger(config);
expect(mockInitActivityLogger).toHaveBeenCalledWith(config, {
mode: 'file',
@@ -195,10 +148,10 @@ describe('devtoolsService', () => {
expect(MockWebSocket.instances.length).toBe(0);
});
it('does nothing in file mode when config.storage is missing', async () => {
it('does nothing in file mode when config.storage is missing', () => {
process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'] = '/tmp/test.jsonl';
const config = createMockConfig({ storage: undefined });
await setupInitialActivityLogger(config);
setupInitialActivityLogger(config);
expect(mockInitActivityLogger).not.toHaveBeenCalled();
expect(MockWebSocket.instances.length).toBe(0);
+3 -25
View File
@@ -116,39 +116,17 @@ async function handlePromotion(config: Config) {
/**
* Initializes the activity logger.
* Interception starts immediately in buffering mode.
* If an existing DevTools server is found, attaches transport eagerly.
* Transport is only attached when the user presses F12.
*/
export async function setupInitialActivityLogger(config: Config) {
export function setupInitialActivityLogger(config: Config) {
const target = process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'];
if (target) {
if (!config.storage) return;
initActivityLogger(config, { mode: 'file', filePath: target });
} else {
// Start in buffering mode (no transport attached yet)
// Start in buffering mode transport attached later on F12
initActivityLogger(config, { mode: 'buffer' });
// Eagerly probe for an existing DevTools server
try {
const existing = await probeDevTools(
DEFAULT_DEVTOOLS_HOST,
DEFAULT_DEVTOOLS_PORT,
);
if (existing) {
const onReconnectFailed = () => handlePromotion(config);
addNetworkTransport(
config,
DEFAULT_DEVTOOLS_HOST,
DEFAULT_DEVTOOLS_PORT,
onReconnectFailed,
);
ActivityLogger.getInstance().enableNetworkLogging();
connectedUrl = `http://localhost:${DEFAULT_DEVTOOLS_PORT}`;
debugLogger.log(`DevTools (existing) at startup: ${connectedUrl}`);
}
} catch {
// Probe failed silently — stay in buffer mode
}
}
}
@@ -34,6 +34,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
...actual,
homedir: () => os.homedir(),
getCompatibilityWarnings: vi.fn().mockReturnValue([]),
isHeadlessMode: vi.fn().mockReturnValue(false),
WarningPriority: {
Low: 'low',
High: 'high',
@@ -143,6 +144,51 @@ describe('getUserStartupWarnings', () => {
});
});
describe('folder trust check', () => {
it('should throw FatalUntrustedWorkspaceError when untrusted in headless mode', async () => {
const { isHeadlessMode, FatalUntrustedWorkspaceError } = await import(
'@google/gemini-cli-core'
);
vi.mocked(isFolderTrustEnabled).mockReturnValue(true);
vi.mocked(isWorkspaceTrusted).mockImplementation(() => {
throw new FatalUntrustedWorkspaceError(
'Gemini CLI is not running in a trusted directory',
);
});
vi.mocked(isHeadlessMode).mockReturnValue(true);
await expect(
getUserStartupWarnings({}, testRootDir),
).rejects.toThrowError(FatalUntrustedWorkspaceError);
});
it('should not return a warning when trusted in headless mode', async () => {
const { isHeadlessMode } = await import('@google/gemini-cli-core');
vi.mocked(isFolderTrustEnabled).mockReturnValue(true);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
vi.mocked(isHeadlessMode).mockReturnValue(true);
const warnings = await getUserStartupWarnings({}, testRootDir);
expect(warnings.find((w) => w.id === 'folder-trust')).toBeUndefined();
});
it('should not return a warning when untrusted in interactive mode', async () => {
const { isHeadlessMode } = await import('@google/gemini-cli-core');
vi.mocked(isFolderTrustEnabled).mockReturnValue(true);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: false,
source: undefined,
});
vi.mocked(isHeadlessMode).mockReturnValue(false);
const warnings = await getUserStartupWarnings({}, testRootDir);
expect(warnings.find((w) => w.id === 'folder-trust')).toBeUndefined();
});
});
describe('compatibility warnings', () => {
it('should include compatibility warnings by default', async () => {
const compWarning = {
@@ -12,6 +12,8 @@ import {
getCompatibilityWarnings,
WarningPriority,
type StartupWarning,
isHeadlessMode,
FatalUntrustedWorkspaceError,
} from '@google/gemini-cli-core';
import type { Settings } from '../config/settingsSchema.js';
import {
@@ -79,10 +81,34 @@ const rootDirectoryCheck: WarningCheck = {
},
};
const folderTrustCheck: WarningCheck = {
id: 'folder-trust',
priority: WarningPriority.High,
check: async (workspaceRoot: string, settings: Settings) => {
if (!isFolderTrustEnabled(settings)) {
return null;
}
const { isTrusted } = isWorkspaceTrusted(settings, workspaceRoot);
if (isTrusted === true) {
return null;
}
if (isHeadlessMode()) {
throw new FatalUntrustedWorkspaceError(
'Gemini CLI is not running in a trusted directory. To proceed, either use `--skip-trust`, set the `GEMINI_CLI_TRUST_WORKSPACE=true` environment variable, or trust this directory in interactive mode.',
);
}
return null;
},
};
// All warning checks
const WARNING_CHECKS: readonly WarningCheck[] = [
homeDirectoryCheck,
rootDirectoryCheck,
folderTrustCheck,
];
export async function getUserStartupWarnings(
+8
View File
@@ -37,6 +37,14 @@ process.env.FORCE_GENERIC_KEYBINDING_HINTS = 'true';
// Force generic terminal declaration to ensure stable snapshots across different host environments.
process.env.TERM_PROGRAM = 'generic';
// Force true color support for terminal capability checks to ensure stable snapshots across different terminals.
process.env.COLORTERM = 'truecolor';
// Mock stdout color depth to ensure true color capability is detected consistently across local and headless CI runners.
if (process.stdout) {
process.stdout.getColorDepth = () => 24;
}
import './src/test-utils/customMatchers.js';
let consoleErrorSpy: vi.SpyInstance;
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -20,8 +20,7 @@
"typecheck": "tsc --noEmit"
},
"files": [
"dist",
"vendor"
"dist"
],
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -56,6 +55,7 @@
"ajv": "^8.17.1",
"ajv-formats": "^3.0.0",
"chardet": "^2.1.0",
"chokidar": "^5.0.0",
"diff": "^8.0.3",
"dotenv": "^17.2.4",
"dotenv-expand": "^12.0.3",
+47
View File
@@ -538,5 +538,52 @@ describe('a2aUtils', () => {
expect(output).toContain('Artifact (Data):');
expect(output).not.toContain('Answer from history');
});
it('should return message log as activity items', () => {
const reassembler = new A2AResultReassembler();
reassembler.update({
kind: 'status-update',
taskId: 't1',
contextId: 'ctx1',
status: {
state: 'working',
message: {
kind: 'message',
role: 'agent',
parts: [{ kind: 'text', text: 'Message 1' }],
} as Message,
},
} as unknown as SendMessageResult);
reassembler.update({
kind: 'status-update',
taskId: 't1',
contextId: 'ctx1',
status: {
state: 'working',
message: {
kind: 'message',
role: 'agent',
parts: [{ kind: 'text', text: 'Message 2' }],
} as Message,
},
} as unknown as SendMessageResult);
const items = reassembler.toActivityItems();
expect(items).toHaveLength(2);
expect(items[0]).toEqual({
id: 'msg-0',
type: 'thought',
content: 'Message 1',
status: 'completed',
});
expect(items[1]).toEqual({
id: 'msg-1',
type: 'thought',
content: 'Message 2',
status: 'completed',
});
});
});
});
+31 -15
View File
@@ -124,6 +124,7 @@ export class A2AResultReassembler {
private pushMessage(message: Message | undefined) {
if (!message) return;
if (message.role === 'user') return; // Skip user messages reflected by server
const text = extractPartsText(message.parts, '');
if (text && this.messageLog[this.messageLog.length - 1] !== text) {
this.messageLog.push(text);
@@ -135,21 +136,36 @@ export class A2AResultReassembler {
*/
toActivityItems(): SubagentActivityItem[] {
const isAuthRequired = this.messageLog.includes(AUTH_REQUIRED_MSG);
return [
isAuthRequired
? {
id: 'auth-required',
type: 'thought',
content: AUTH_REQUIRED_MSG,
status: 'running',
}
: {
id: 'pending',
type: 'thought',
content: 'Working...',
status: 'running',
},
];
const items: SubagentActivityItem[] = [];
if (isAuthRequired) {
items.push({
id: 'auth-required',
type: 'thought',
content: AUTH_REQUIRED_MSG,
status: 'running',
});
}
this.messageLog.forEach((msg, index) => {
items.push({
id: `msg-${index}`,
type: 'thought',
content: msg.trim(),
status: 'completed',
});
});
if (items.length === 0 && !isAuthRequired) {
items.push({
id: 'pending',
type: 'thought',
content: 'Working...',
status: 'running',
});
}
return items;
}
/**
+1
View File
@@ -194,6 +194,7 @@ class DelegateInvocation extends BaseToolInvocation<
{
operation: GeminiCliOperation.AgentCall,
logPrompts: this.context.config.getTelemetryLogPromptsEnabled(),
tracesEnabled: this.context.config.getTelemetryTracesEnabled(),
sessionId: this.context.config.getSessionId(),
attributes: {
[GEN_AI_AGENT_NAME]: this.definition.name,
@@ -1,160 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { MemoryManagerAgent } from './memory-manager-agent.js';
import {
ASK_USER_TOOL_NAME,
EDIT_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
} from '../tools/tool-names.js';
import { Storage } from '../config/storage.js';
import type { Config } from '../config/config.js';
import type { HierarchicalMemory } from '../config/memory.js';
function createMockConfig(memory: string | HierarchicalMemory = ''): Config {
return {
getUserMemory: vi.fn().mockReturnValue(memory),
} as unknown as Config;
}
describe('MemoryManagerAgent', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should have the correct name "save_memory"', () => {
const agent = MemoryManagerAgent(createMockConfig());
expect(agent.name).toBe('save_memory');
});
it('should be a local agent', () => {
const agent = MemoryManagerAgent(createMockConfig());
expect(agent.kind).toBe('local');
});
it('should have a description', () => {
const agent = MemoryManagerAgent(createMockConfig());
expect(agent.description).toBeTruthy();
expect(agent.description).toContain('memory');
});
it('should have a system prompt with memory management instructions', () => {
const agent = MemoryManagerAgent(createMockConfig());
const prompt = agent.promptConfig.systemPrompt;
const globalGeminiDir = Storage.getGlobalGeminiDir();
expect(prompt).toContain(`Global (${globalGeminiDir}`);
expect(prompt).toContain('Project (./');
expect(prompt).toContain('Memory Hierarchy');
expect(prompt).toContain('De-duplicating');
expect(prompt).toContain('Adding');
expect(prompt).toContain('Removing stale entries');
expect(prompt).toContain('Organizing');
expect(prompt).toContain('Routing');
});
it('should have efficiency guidelines in the system prompt', () => {
const agent = MemoryManagerAgent(createMockConfig());
const prompt = agent.promptConfig.systemPrompt;
expect(prompt).toContain('Efficiency & Performance');
expect(prompt).toContain('Use as few turns as possible');
expect(prompt).toContain('Do not perform any exploration');
expect(prompt).toContain('Be strategic with your thinking');
expect(prompt).toContain('Context Awareness');
});
it('should inject hierarchical memory into initial context', () => {
const config = createMockConfig({
global:
'--- Context from: ../../.gemini/GEMINI.md ---\nglobal context\n--- End of Context from: ../../.gemini/GEMINI.md ---',
project:
'--- Context from: .gemini/GEMINI.md ---\nproject context\n--- End of Context from: .gemini/GEMINI.md ---',
});
const agent = MemoryManagerAgent(config);
const query = agent.promptConfig.query;
expect(query).toContain('# Initial Context');
expect(query).toContain('global context');
expect(query).toContain('project context');
});
it('should inject flat string memory into initial context', () => {
const config = createMockConfig('flat memory content');
const agent = MemoryManagerAgent(config);
const query = agent.promptConfig.query;
expect(query).toContain('# Initial Context');
expect(query).toContain('flat memory content');
});
it('should exclude extension memory from initial context', () => {
const config = createMockConfig({
global: 'global context',
extension: 'extension context that should be excluded',
project: 'project context',
});
const agent = MemoryManagerAgent(config);
const query = agent.promptConfig.query;
expect(query).toContain('global context');
expect(query).toContain('project context');
expect(query).not.toContain('extension context');
});
it('should not include initial context when memory is empty', () => {
const agent = MemoryManagerAgent(createMockConfig());
const query = agent.promptConfig.query;
expect(query).not.toContain('# Initial Context');
});
it('should have file-management and search tools', () => {
const agent = MemoryManagerAgent(createMockConfig());
expect(agent.toolConfig).toBeDefined();
expect(agent.toolConfig!.tools).toEqual(
expect.arrayContaining([
READ_FILE_TOOL_NAME,
EDIT_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
LS_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
ASK_USER_TOOL_NAME,
]),
);
});
it('should require a "request" input parameter', () => {
const agent = MemoryManagerAgent(createMockConfig());
const schema = agent.inputConfig.inputSchema as Record<string, unknown>;
expect(schema).toBeDefined();
expect(schema['properties']).toHaveProperty('request');
expect(schema['required']).toContain('request');
});
it('should use a fast model', () => {
const agent = MemoryManagerAgent(createMockConfig());
expect(agent.modelConfig.model).toBe('flash');
});
it('should declare workspaceDirectories containing the global .gemini directory', () => {
const agent = MemoryManagerAgent(createMockConfig());
const globalGeminiDir = Storage.getGlobalGeminiDir();
expect(agent.workspaceDirectories).toBeDefined();
expect(agent.workspaceDirectories).toContain(globalGeminiDir);
});
});
@@ -1,157 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { z } from 'zod';
import type { LocalAgentDefinition } from './types.js';
import {
ASK_USER_TOOL_NAME,
EDIT_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
} from '../tools/tool-names.js';
import { Storage } from '../config/storage.js';
import { flattenMemory } from '../config/memory.js';
import { GEMINI_MODEL_ALIAS_FLASH } from '../config/models.js';
import type { Config } from '../config/config.js';
const MemoryManagerSchema = z.object({
response: z
.string()
.describe('A summary of the memory operations performed.'),
});
/**
* A memory management agent that replaces the built-in save_memory tool.
* It provides richer memory operations: adding, removing, de-duplicating,
* and organizing memories in the global GEMINI.md file.
*
* Users can override this agent by placing a custom save_memory.md
* in ~/.gemini/agents/ or .gemini/agents/.
*/
export const MemoryManagerAgent = (
config: Config,
): LocalAgentDefinition<typeof MemoryManagerSchema> => {
const globalGeminiDir = Storage.getGlobalGeminiDir();
const getInitialContext = (): string => {
const memory = config.getUserMemory();
// Only include global and project memory — extension memory is read-only
// and not relevant to the memory manager.
const content =
typeof memory === 'string'
? memory
: flattenMemory({ global: memory.global, project: memory.project });
if (!content.trim()) return '';
return `\n# Initial Context\n\n${content}\n`;
};
const buildSystemPrompt = (): string =>
`
You are a memory management agent maintaining user memories in GEMINI.md files.
# Memory Hierarchy
## Global (${globalGeminiDir})
- \`${globalGeminiDir}/GEMINI.md\` — Cross-project user preferences, key personal info,
and habits that apply everywhere.
## Project (./)
- \`./GEMINI.md\` — **Table of Contents** for project-specific context:
architecture decisions, conventions, key contacts, and references to
subdirectory GEMINI.md files for detailed context.
- Subdirectory GEMINI.md files (e.g. \`src/GEMINI.md\`, \`docs/GEMINI.md\`) —
detailed, domain-specific context for that part of the project. Reference
these from the root \`./GEMINI.md\`.
## Routing
When adding a memory, route it to the right store:
- **Global**: User preferences, personal info, tool aliases, cross-project habits **global**
- **Project Root**: Project architecture, conventions, workflows, team info **project root**
- **Subdirectory**: Detailed context about a specific module or directory **subdirectory
GEMINI.md**, with a reference added to the project root
- **Ambiguity**: If a memory (like a coding preference or workflow) could be interpreted as either a global habit or a project-specific convention, you **MUST** use \`${ASK_USER_TOOL_NAME}\` to clarify the user's intent. Do NOT make a unilateral decision when ambiguity exists between Global and Project stores.
# Operations
1. **Adding** Route to the correct store and file. Check for duplicates in your provided context first.
2. **Removing stale entries** Delete outdated or unwanted entries. Clean up
dangling references.
3. **De-duplicating** Semantically equivalent entries should be combined. Keep the most informative version.
4. **Organizing** Restructure for clarity. Update references between files.
# Restrictions
- Keep GEMINI.md files lean they are loaded into context every session.
- Keep entries concise.
- Edit surgically preserve existing structure and user-authored content.
- NEVER write or read any files other than GEMINI.md files.
# Efficiency & Performance
- **Use as few turns as possible.** Execute independent reads and writes to different files in parallel by calling multiple tools in a single turn.
- **Do not perform any exploration of the codebase.** Try to use the provided file context and only search additional GEMINI.md files as needed to accomplish your task.
- **Be strategic with your thinking.** carefully decide where to route memories and how to de-duplicate memories, but be decisive with simple memory writes.
- **Minimize file system operations.** You should typically only modify the GEMINI.md files that are already provided in your context. Only read or write to other files if explicitly directed or if you are following a specific reference from an existing memory file.
- **Context Awareness.** If a file's content is already provided in the "Initial Context" section, you do not need to call \`read_file\` for it.
# Insufficient context
If you find that you have insufficient context to read or modify the memories as described,
reply with what you need, and exit. Do not search the codebase for the missing context.
`.trim();
return {
kind: 'local',
name: 'save_memory',
displayName: 'Memory Manager',
description: `Writes and reads memory, preferences or facts across ALL future sessions. Use this for recurring instructions like coding styles or tool aliases.`,
inputConfig: {
inputSchema: {
type: 'object',
properties: {
request: {
type: 'string',
description:
'The memory operation to perform. Examples: "Remember that I prefer tabs over spaces", "Clean up stale memories", "De-duplicate my memories", "Organize my memories".',
},
},
required: ['request'],
},
},
outputConfig: {
outputName: 'result',
description: 'A summary of the memory operations performed.',
schema: MemoryManagerSchema,
},
modelConfig: {
model: GEMINI_MODEL_ALIAS_FLASH,
},
workspaceDirectories: [globalGeminiDir],
toolConfig: {
tools: [
READ_FILE_TOOL_NAME,
EDIT_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
LS_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
ASK_USER_TOOL_NAME,
],
},
get promptConfig() {
return {
systemPrompt: buildSystemPrompt(),
query: `${getInitialContext()}\${request}`,
};
},
runConfig: {
maxTimeMinutes: 5,
maxTurns: 10,
},
};
};
-9
View File
@@ -15,7 +15,6 @@ import { CodebaseInvestigatorAgent } from './codebase-investigator.js';
import { CliHelpAgent } from './cli-help-agent.js';
import { GeneralistAgent } from './generalist-agent.js';
import { BrowserAgentDefinition } from './browser/browserAgentDefinition.js';
import { MemoryManagerAgent } from './memory-manager-agent.js';
import { AgentTool } from './agent-tool.js';
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
import type { AuthenticationHandler } from '@a2a-js/sdk/client';
@@ -293,14 +292,6 @@ export class AgentRegistry {
this.registerLocalAgent(BrowserAgentDefinition(this.config));
}
}
// Register the memory manager agent as a replacement for the save_memory tool.
// The agent declares its own workspaceDirectories (e.g. ~/.gemini) which are
// scoped to its execution via runWithScopedWorkspaceContext in LocalAgentExecutor,
// keeping the main agent's workspace context clean.
if (this.config.isMemoryManagerEnabled()) {
this.registerLocalAgent(MemoryManagerAgent(this.config));
}
}
private async refreshAgents(
@@ -8,13 +8,15 @@ export type ModelId = string;
type TerminalUnavailabilityReason = 'quota' | 'capacity';
export type TurnUnavailabilityReason = 'retry_once_per_turn';
export type TemporaryUnavailabilityReason = 'timeout';
export type UnavailabilityReason =
| TerminalUnavailabilityReason
| TurnUnavailabilityReason
| TemporaryUnavailabilityReason
| 'unknown';
export type ModelHealthStatus = 'terminal' | 'sticky_retry';
export type ModelHealthStatus = 'terminal' | 'sticky_retry' | 'temporary';
type HealthState =
| { status: 'terminal'; reason: TerminalUnavailabilityReason }
@@ -22,6 +24,11 @@ type HealthState =
status: 'sticky_retry';
reason: TurnUnavailabilityReason;
consumed: boolean;
}
| {
status: 'temporary';
reason: TemporaryUnavailabilityReason;
untilMs: number;
};
export interface ModelAvailabilitySnapshot {
@@ -48,6 +55,18 @@ export class ModelAvailabilityService {
});
}
markTemporarilyUnavailable(
model: ModelId,
reason: TemporaryUnavailabilityReason,
durationMs: number,
) {
this.setState(model, {
status: 'temporary',
reason,
untilMs: Date.now() + durationMs,
});
}
markHealthy(model: ModelId) {
this.clearState(model);
}
@@ -95,6 +114,15 @@ export class ModelAvailabilityService {
return { available: false, reason: state.reason };
}
if (state.status === 'temporary') {
if (Date.now() < state.untilMs) {
return { available: false, reason: state.reason };
} else {
this.clearState(model);
return { available: true };
}
}
return { available: true };
}

Some files were not shown because too many files have changed in this diff Show More