Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 413098e03a | |||
| 66abea7a0e | |||
| dc82a395ac |
@@ -2,7 +2,7 @@
|
||||
"experimental": {
|
||||
"extensionReloading": true,
|
||||
"modelSteering": true,
|
||||
"autoMemory": true
|
||||
"memoryManager": true
|
||||
},
|
||||
"general": {
|
||||
"devtools": true
|
||||
|
||||
@@ -28,7 +28,6 @@ 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,7 +98,6 @@ 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
|
||||
|
||||
@@ -167,7 +167,6 @@ 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'
|
||||
@@ -213,7 +212,6 @@ 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'
|
||||
@@ -290,7 +288,6 @@ 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'
|
||||
|
||||
@@ -179,7 +179,6 @@ 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"
|
||||
@@ -268,7 +267,6 @@ 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
|
||||
@@ -432,7 +430,6 @@ 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'
|
||||
|
||||
@@ -62,7 +62,6 @@ 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 }}'
|
||||
@@ -106,7 +105,6 @@ 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'
|
||||
@@ -161,7 +159,6 @@ 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'
|
||||
|
||||
@@ -141,7 +141,6 @@ 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
|
||||
|
||||
@@ -52,7 +52,6 @@ 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.** |
|
||||
|
||||
@@ -99,16 +99,13 @@ 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` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
|
||||
### Agents
|
||||
|
||||
@@ -164,19 +161,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 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` |
|
||||
| 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` |
|
||||
|
||||
### Skills
|
||||
|
||||
|
||||
@@ -100,30 +100,6 @@ 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
|
||||
|
||||
@@ -483,20 +483,6 @@ 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):
|
||||
@@ -1466,6 +1452,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
performance.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`tools.shell.rcFile`** (string):
|
||||
- **Description:** The path to a bash file (e.g., .bashrc) to source before
|
||||
executing shell commands.
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`tools.core`** (array):
|
||||
- **Description:** Restrict the set of built-in tools with an allowlist. Match
|
||||
semantics mirror tools.allowed; see the built-in tools documentation for
|
||||
@@ -1702,10 +1693,8 @@ 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. Defaults to
|
||||
true; set to false to opt out and load all GEMINI.md files into the system
|
||||
instruction up-front.
|
||||
- **Default:** `true`
|
||||
- **Description:** Enable Just-In-Time (JIT) context loading.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.useOSC52Paste`** (boolean):
|
||||
@@ -1770,17 +1759,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `"gemma3-1b-gpu-custom"`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`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`
|
||||
- **`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`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.autoMemory`** (boolean):
|
||||
@@ -2170,14 +2153,6 @@ 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
|
||||
|
||||
@@ -283,7 +283,7 @@ describe('save_memory', () => {
|
||||
name: proactiveMemoryFromLongSession,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: true },
|
||||
experimental: { memoryManager: true },
|
||||
},
|
||||
},
|
||||
messages: [
|
||||
@@ -341,75 +341,29 @@ describe('save_memory', () => {
|
||||
prompt:
|
||||
'Please save any persistent preferences or facts about me from our conversation to memory.',
|
||||
assert: async (rig, result) => {
|
||||
// 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)
|
||||
);
|
||||
});
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
'invoke_agent',
|
||||
undefined,
|
||||
(args) => /save_memory/i.test(args) && /vitest/i.test(args),
|
||||
);
|
||||
expect(
|
||||
wroteVitestToGlobal,
|
||||
'Expected the cross-project Vitest preference to be written to the global personal memory file (~/.gemini/GEMINI.md) via write_file or replace',
|
||||
wasToolCalled,
|
||||
'Expected invoke_agent to be called with save_memory agent and the Vitest preference from the conversation history',
|
||||
).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 memoryV2RoutesTeamConventionsToProjectGemini =
|
||||
'Agent routes team-shared project conventions to ./GEMINI.md';
|
||||
const memoryManagerRoutingPreferences =
|
||||
'Agent routes global and project preferences to memory';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: memoryV2RoutesTeamConventionsToProjectGemini,
|
||||
name: memoryManagerRoutingPreferences,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: true },
|
||||
experimental: { memoryManager: true },
|
||||
},
|
||||
},
|
||||
messages: [
|
||||
@@ -418,7 +372,7 @@ describe('save_memory', () => {
|
||||
type: 'user',
|
||||
content: [
|
||||
{
|
||||
text: 'For this project, the team always runs tests with `npm run test` — please remember that as our project convention.',
|
||||
text: 'I always use dark mode in all my editors and terminals.',
|
||||
},
|
||||
],
|
||||
timestamp: '2026-01-01T00:00:00Z',
|
||||
@@ -426,9 +380,7 @@ describe('save_memory', () => {
|
||||
{
|
||||
id: 'msg-2',
|
||||
type: 'gemini',
|
||||
content: [
|
||||
{ text: 'Got it, I will keep `npm run test` in mind for tests.' },
|
||||
],
|
||||
content: [{ text: 'Got it, I will keep that in mind!' }],
|
||||
timestamp: '2026-01-01T00:00:05Z',
|
||||
},
|
||||
{
|
||||
@@ -452,238 +404,16 @@ describe('save_memory', () => {
|
||||
],
|
||||
prompt: 'Please save the preferences I mentioned earlier to memory.',
|
||||
assert: async (rig, result) => {
|
||||
// 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)
|
||||
);
|
||||
});
|
||||
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
'invoke_agent',
|
||||
undefined,
|
||||
(args) => /save_memory/i.test(args),
|
||||
);
|
||||
expect(
|
||||
wroteToProjectRoot(/npm run test/i),
|
||||
'Expected the team test-command convention to be written to the project-root ./GEMINI.md',
|
||||
wasToolCalled,
|
||||
'Expected invoke_agent to be called with save_memory agent',
|
||||
).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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,16 +8,7 @@ import { expect, describe, it, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// 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', () => {
|
||||
describe('Interactive Mode', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -134,9 +134,7 @@ describe('file-system', () => {
|
||||
).toBeTruthy();
|
||||
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
// 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');
|
||||
expect(newFileContent).toBe('hello');
|
||||
});
|
||||
|
||||
it('should perform a read-then-write sequence', async () => {
|
||||
|
||||
@@ -81,10 +81,7 @@ describe('Plan Mode', () => {
|
||||
|
||||
await rig.run({
|
||||
approvalMode: 'plan',
|
||||
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.',
|
||||
args: 'Create a file called plan.md in the plans directory.',
|
||||
});
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
@@ -197,11 +194,7 @@ describe('Plan Mode', () => {
|
||||
|
||||
await rig.run({
|
||||
approvalMode: 'plan',
|
||||
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.',
|
||||
args: 'Create a file called plan-no-session.md in the plans directory.',
|
||||
});
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -4593,6 +4593,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -17692,7 +17742,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -17821,7 +17871,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
@@ -17969,7 +18019,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
@@ -18279,7 +18329,7 @@
|
||||
},
|
||||
"packages/devtools": {
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.16.0"
|
||||
@@ -18294,7 +18344,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18325,7 +18375,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18357,7 +18407,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"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.41.0-nightly.20260423.gaa05b4583"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.40.0-nightly.20260414.g5b1f7375a"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"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.41.0-nightly.20260423.gaa05b4583"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.40.0-nightly.20260414.g5b1f7375a"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
FileDiscoveryService,
|
||||
resolveTelemetrySettings,
|
||||
FatalConfigError,
|
||||
getErrorMessage,
|
||||
getPty,
|
||||
debugLogger,
|
||||
loadServerHierarchicalMemory,
|
||||
@@ -61,7 +60,6 @@ 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';
|
||||
@@ -108,7 +106,6 @@ export interface CliArgs {
|
||||
startupMessages?: string[];
|
||||
rawOutput: boolean | undefined;
|
||||
acceptRawOutputRisk: boolean | undefined;
|
||||
skipTrust: boolean | undefined;
|
||||
isCommand: boolean | undefined;
|
||||
}
|
||||
|
||||
@@ -294,11 +291,6 @@ 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',
|
||||
@@ -467,16 +459,9 @@ export async function parseArguments(
|
||||
yargsInstance.wrap(yargsInstance.terminalWidth());
|
||||
let result;
|
||||
try {
|
||||
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';
|
||||
}
|
||||
result = await yargsInstance.parse();
|
||||
} catch (e) {
|
||||
const msg = getErrorMessage(e);
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
debugLogger.error(msg);
|
||||
yargsInstance.showHelp();
|
||||
await runExitCleanup();
|
||||
@@ -490,13 +475,11 @@ export async function parseArguments(
|
||||
}
|
||||
|
||||
// Normalize query args: handle both quoted "@path file" and unquoted @path file
|
||||
const queryArg = result['query'];
|
||||
let q: string | undefined;
|
||||
if (Array.isArray(queryArg)) {
|
||||
q = queryArg.join(' ');
|
||||
} else if (typeof queryArg === 'string') {
|
||||
q = queryArg;
|
||||
}
|
||||
// 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;
|
||||
|
||||
// -p/--prompt forces non-interactive mode; positional args default to interactive in TTY
|
||||
if (q && !result['prompt']) {
|
||||
@@ -511,8 +494,8 @@ export async function parseArguments(
|
||||
}
|
||||
|
||||
// Keep CliArgs.query as a string for downstream typing
|
||||
result['query'] = q || undefined;
|
||||
result['startupMessages'] = startupMessages;
|
||||
(result as Record<string, unknown>)['query'] = q || undefined;
|
||||
(result as Record<string, unknown>)['startupMessages'] = startupMessages;
|
||||
|
||||
// The import format is now only controlled by settings.memoryImportFormat
|
||||
// We no longer accept it as a CLI argument
|
||||
@@ -564,7 +547,7 @@ export async function loadCliConfig(
|
||||
? false
|
||||
: (settings.security?.folderTrust?.enabled ?? false);
|
||||
const trustedFolder =
|
||||
isWorkspaceTrusted(settings, cwd, {
|
||||
isWorkspaceTrusted(settings, cwd, undefined, {
|
||||
prompt: argv.prompt,
|
||||
query: argv.query,
|
||||
})?.isTrusted ?? false;
|
||||
@@ -610,7 +593,7 @@ export async function loadCliConfig(
|
||||
return resolveToRealPath(trimmedPath) !== realCwd;
|
||||
} catch (e) {
|
||||
debugLogger.debug(
|
||||
`[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${getErrorMessage(e)})`,
|
||||
`[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${e instanceof Error ? e.message : String(e)})`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -634,7 +617,7 @@ export async function loadCliConfig(
|
||||
.getExtensions()
|
||||
.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
|
||||
|
||||
const experimentalJitContext = settings.experimental.jitContext ?? true;
|
||||
const experimentalJitContext = settings.experimental.jitContext;
|
||||
|
||||
let extensionRegistryURI =
|
||||
process.env['GEMINI_CLI_EXTENSION_REGISTRY_URI'] ??
|
||||
@@ -942,6 +925,7 @@ export async function loadCliConfig(
|
||||
toolDiscoveryCommand: settings.tools?.discoveryCommand,
|
||||
toolCallCommand: settings.tools?.callCommand,
|
||||
mcpServerCommand,
|
||||
shellToolRcFile: settings.tools?.shell?.rcFile,
|
||||
mcpServers,
|
||||
mcpEnablementCallbacks,
|
||||
mcpEnabled,
|
||||
@@ -1008,8 +992,8 @@ export async function loadCliConfig(
|
||||
enableEventDrivenScheduler: true,
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
experimentalJitContext,
|
||||
experimentalMemoryV2: settings.experimental?.memoryV2,
|
||||
experimentalJitContext: settings.experimental?.jitContext,
|
||||
experimentalMemoryManager: settings.experimental?.memoryManager,
|
||||
experimentalAutoMemory: settings.experimental?.autoMemory,
|
||||
contextManagement,
|
||||
modelSteering: settings.experimental?.modelSteering,
|
||||
@@ -1044,7 +1028,6 @@ 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,
|
||||
@@ -1117,7 +1100,7 @@ async function resolveWorktreeSettings(
|
||||
worktreeBaseSha = stdout.trim();
|
||||
} catch (e: unknown) {
|
||||
debugLogger.debug(
|
||||
`Failed to resolve worktree base SHA at ${worktreePath}: ${getErrorMessage(e)}`,
|
||||
`Failed to resolve worktree base SHA at ${worktreePath}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
loadAgentsFromDirectory,
|
||||
loadSkillsFromDir,
|
||||
getRealPath,
|
||||
normalizePath,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
loadSettings,
|
||||
@@ -1421,7 +1420,6 @@ name = "yolo-checker"
|
||||
'.gemini',
|
||||
'trustedFolders.json',
|
||||
);
|
||||
vi.stubEnv('GEMINI_CLI_TRUSTED_FOLDERS_PATH', trustedFoldersPath);
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: undefined,
|
||||
@@ -1440,9 +1438,7 @@ name = "yolo-checker"
|
||||
const trustedFolders = JSON.parse(
|
||||
fs.readFileSync(trustedFoldersPath, 'utf-8'),
|
||||
);
|
||||
expect(trustedFolders[normalizePath(tempWorkspaceDir)]).toBe(
|
||||
'TRUST_FOLDER',
|
||||
);
|
||||
expect(trustedFolders[tempWorkspaceDir]).toBe('TRUST_FOLDER');
|
||||
});
|
||||
|
||||
describe.each([true, false])(
|
||||
|
||||
@@ -1912,9 +1912,6 @@ 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,
|
||||
@@ -1922,11 +1919,9 @@ 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,
|
||||
workspaceEnvPath,
|
||||
].includes(normalizedP);
|
||||
return [path.resolve(USER_SETTINGS_PATH), geminiEnvPath].includes(
|
||||
normalizedP,
|
||||
);
|
||||
});
|
||||
const userSettingsContent: Settings = {
|
||||
ui: {
|
||||
@@ -1946,7 +1941,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 || normalizedP === workspaceEnvPath)
|
||||
if (normalizedP === geminiEnvPath)
|
||||
return 'TESTTEST=1234\nGEMINI_API_KEY=test-key';
|
||||
return '{}';
|
||||
},
|
||||
@@ -1975,7 +1970,7 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
});
|
||||
|
||||
it('does NOT load non-whitelisted env files from untrusted spaces even when NOT sandboxed', () => {
|
||||
it('does load env files from untrusted spaces when NOT sandboxed', () => {
|
||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
||||
const settings = {
|
||||
security: { folderTrust: { enabled: true } },
|
||||
@@ -1983,8 +1978,7 @@ describe('Settings Loading and Merging', () => {
|
||||
} as Settings;
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
||||
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
||||
expect(process.env['TESTTEST']).toEqual('1234');
|
||||
});
|
||||
|
||||
it('does not load env files when trust is undefined and sandboxed', () => {
|
||||
|
||||
@@ -499,15 +499,13 @@ export class LoadedSettings {
|
||||
}
|
||||
}
|
||||
|
||||
function findEnvFile(startDir: string, isTrusted: boolean): string | null {
|
||||
function findEnvFile(startDir: string): string | null {
|
||||
let currentDir = path.resolve(startDir);
|
||||
while (true) {
|
||||
// prefer gemini-specific .env under GEMINI_DIR
|
||||
if (isTrusted) {
|
||||
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(geminiEnvPath)) {
|
||||
return geminiEnvPath;
|
||||
}
|
||||
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(geminiEnvPath)) {
|
||||
return geminiEnvPath;
|
||||
}
|
||||
const envPath = path.join(currentDir, '.env');
|
||||
if (fs.existsSync(envPath)) {
|
||||
@@ -516,11 +514,9 @@ function findEnvFile(startDir: string, isTrusted: boolean): string | null {
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir || !parentDir) {
|
||||
// check .env under home as fallback, again preferring gemini-specific .env
|
||||
if (isTrusted) {
|
||||
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(homeGeminiEnvPath)) {
|
||||
return homeGeminiEnvPath;
|
||||
}
|
||||
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(homeGeminiEnvPath)) {
|
||||
return homeGeminiEnvPath;
|
||||
}
|
||||
const homeEnvPath = path.join(homedir(), '.env');
|
||||
if (fs.existsSync(homeEnvPath)) {
|
||||
@@ -563,10 +559,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);
|
||||
|
||||
const isTrusted = trustResult.isTrusted ?? false;
|
||||
// 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
|
||||
@@ -601,8 +597,8 @@ export function loadEnvironment(
|
||||
for (const key in parsedEnv) {
|
||||
if (Object.hasOwn(parsedEnv, key)) {
|
||||
let value = parsedEnv[key];
|
||||
// If the workspace is untrusted, only allow whitelisted variables.
|
||||
if (!isTrusted) {
|
||||
// If the workspace is untrusted but we are sandboxed, only allow whitelisted variables.
|
||||
if (!isTrusted && isSandboxed) {
|
||||
if (!AUTH_ENV_VAR_WHITELIST.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1112,46 +1112,6 @@ 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1677,6 +1637,16 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable shell output efficiency optimizations for better performance.',
|
||||
showInDialog: false,
|
||||
},
|
||||
rcFile: {
|
||||
type: 'string',
|
||||
label: 'Shell Tool RC File',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
description:
|
||||
'The path to a bash file (e.g., .bashrc) to source before executing shell commands.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2180,9 +2150,8 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'JIT Context Loading',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
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.',
|
||||
default: false,
|
||||
description: 'Enable Just-In-Time (JIT) context loading.',
|
||||
showInDialog: false,
|
||||
},
|
||||
useOSC52Paste: {
|
||||
@@ -2315,14 +2284,14 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
memoryV2: {
|
||||
memoryManager: {
|
||||
type: 'boolean',
|
||||
label: 'Memory v2',
|
||||
label: 'Memory Manager Agent',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
default: false,
|
||||
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.',
|
||||
'Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.',
|
||||
showInDialog: true,
|
||||
},
|
||||
autoMemory: {
|
||||
|
||||
@@ -11,7 +11,7 @@ import * as os from 'node:os';
|
||||
import {
|
||||
FatalConfigError,
|
||||
ideContextStore,
|
||||
normalizePath,
|
||||
coreEvents,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
loadTrustedFolders,
|
||||
@@ -32,14 +32,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
...actual,
|
||||
homedir: () => '/mock/home/user',
|
||||
isHeadlessMode: vi.fn(() => false),
|
||||
coreEvents: Object.assign(
|
||||
Object.create(Object.getPrototypeOf(actual.coreEvents)),
|
||||
actual.coreEvents,
|
||||
{
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
),
|
||||
FatalConfigError: actual.FatalConfigError,
|
||||
coreEvents: {
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -58,7 +53,6 @@ describe('Trusted Folders', () => {
|
||||
// Reset the internal state
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.clearAllMocks();
|
||||
delete process.env['GEMINI_CLI_TRUST_WORKSPACE'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -76,14 +70,8 @@ describe('Trusted Folders', () => {
|
||||
|
||||
// Start two concurrent calls
|
||||
// These will race to acquire the lock on the real file system
|
||||
const p1 = loadedFolders.setValue(
|
||||
path.resolve('/path1'),
|
||||
TrustLevel.TRUST_FOLDER,
|
||||
);
|
||||
const p2 = loadedFolders.setValue(
|
||||
path.resolve('/path2'),
|
||||
TrustLevel.TRUST_FOLDER,
|
||||
);
|
||||
const p1 = loadedFolders.setValue('/path1', TrustLevel.TRUST_FOLDER);
|
||||
const p2 = loadedFolders.setValue('/path2', TrustLevel.TRUST_FOLDER);
|
||||
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
@@ -92,8 +80,8 @@ describe('Trusted Folders', () => {
|
||||
const config = JSON.parse(content);
|
||||
|
||||
expect(config).toEqual({
|
||||
[normalizePath('/path1')]: TrustLevel.TRUST_FOLDER,
|
||||
[normalizePath('/path2')]: TrustLevel.TRUST_FOLDER,
|
||||
'/path1': TrustLevel.TRUST_FOLDER,
|
||||
'/path2': TrustLevel.TRUST_FOLDER,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -107,16 +95,13 @@ describe('Trusted Folders', () => {
|
||||
|
||||
it('should load rules from the configuration file', () => {
|
||||
const config = {
|
||||
[normalizePath('/user/folder')]: TrustLevel.TRUST_FOLDER,
|
||||
'/user/folder': TrustLevel.TRUST_FOLDER,
|
||||
};
|
||||
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
|
||||
|
||||
const { rules, errors } = loadTrustedFolders();
|
||||
expect(rules).toEqual([
|
||||
{
|
||||
path: normalizePath('/user/folder'),
|
||||
trustLevel: TrustLevel.TRUST_FOLDER,
|
||||
},
|
||||
{ path: '/user/folder', trustLevel: TrustLevel.TRUST_FOLDER },
|
||||
]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -158,14 +143,14 @@ describe('Trusted Folders', () => {
|
||||
const content = `
|
||||
{
|
||||
// This is a comment
|
||||
"${normalizePath('/path').replaceAll('\\', '\\\\')}": "TRUST_FOLDER"
|
||||
"/path": "TRUST_FOLDER"
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(trustedFoldersPath, content, 'utf-8');
|
||||
|
||||
const { rules, errors } = loadTrustedFolders();
|
||||
expect(rules).toEqual([
|
||||
{ path: normalizePath('/path'), trustLevel: TrustLevel.TRUST_FOLDER },
|
||||
{ path: '/path', trustLevel: TrustLevel.TRUST_FOLDER },
|
||||
]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -231,18 +216,15 @@ describe('Trusted Folders', () => {
|
||||
fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8');
|
||||
const loadedFolders = loadTrustedFolders();
|
||||
|
||||
await loadedFolders.setValue(
|
||||
normalizePath('/new/path'),
|
||||
TrustLevel.TRUST_FOLDER,
|
||||
);
|
||||
await loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER);
|
||||
|
||||
expect(loadedFolders.user.config[normalizePath('/new/path')]).toBe(
|
||||
expect(loadedFolders.user.config['/new/path']).toBe(
|
||||
TrustLevel.TRUST_FOLDER,
|
||||
);
|
||||
|
||||
const content = fs.readFileSync(trustedFoldersPath, 'utf-8');
|
||||
const config = JSON.parse(content);
|
||||
expect(config[normalizePath('/new/path')]).toBe(TrustLevel.TRUST_FOLDER);
|
||||
expect(config['/new/path']).toBe(TrustLevel.TRUST_FOLDER);
|
||||
});
|
||||
|
||||
it('should throw FatalConfigError if there were load errors', async () => {
|
||||
@@ -255,6 +237,28 @@ 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', () => {
|
||||
@@ -423,28 +427,16 @@ describe('Trusted Folders', () => {
|
||||
},
|
||||
};
|
||||
|
||||
it('should NOT return true when isHeadlessMode is true, ignoring config', async () => {
|
||||
it('should return true when isHeadlessMode is true, ignoring config', async () => {
|
||||
const geminiCore = await import('@google/gemini-cli-core');
|
||||
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true);
|
||||
|
||||
expect(isWorkspaceTrusted(mockSettings)).toEqual({
|
||||
isTrusted: undefined,
|
||||
isTrusted: true,
|
||||
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);
|
||||
@@ -457,12 +449,12 @@ describe('Trusted Folders', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return undefined for isPathTrusted when isHeadlessMode is true', async () => {
|
||||
it('should return true 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(undefined);
|
||||
expect(folders.isPathTrusted('/any-untrusted-path')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,29 +4,330 @@
|
||||
* 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 {
|
||||
type HeadlessModeOptions,
|
||||
checkPathTrust,
|
||||
FatalConfigError,
|
||||
getErrorMessage,
|
||||
isWithinRoot,
|
||||
ideContextStore,
|
||||
GEMINI_DIR,
|
||||
homedir,
|
||||
isHeadlessMode,
|
||||
loadTrustedFolders as loadCoreTrustedFolders,
|
||||
type LoadedTrustedFolders,
|
||||
coreEvents,
|
||||
type HeadlessModeOptions,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Settings } from './settings.js';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
|
||||
export {
|
||||
TrustLevel,
|
||||
isTrustLevel,
|
||||
resetTrustedFoldersForTesting,
|
||||
saveTrustedFolders,
|
||||
} from '@google/gemini-cli-core';
|
||||
const { promises: fsPromises } = fs;
|
||||
|
||||
export type {
|
||||
TrustRule,
|
||||
TrustedFoldersError,
|
||||
TrustedFoldersFile,
|
||||
TrustResult,
|
||||
LoadedTrustedFolders,
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Is folder trust feature enabled per the current applied settings */
|
||||
export function isFolderTrustEnabled(settings: Settings): boolean {
|
||||
@@ -34,24 +335,57 @@ export function isFolderTrustEnabled(settings: Settings): boolean {
|
||||
return folderTrustSetting;
|
||||
}
|
||||
|
||||
export function loadTrustedFolders(): LoadedTrustedFolders {
|
||||
return loadCoreTrustedFolders();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
): {
|
||||
isTrusted: boolean | undefined;
|
||||
source: 'ide' | 'file' | 'env' | undefined;
|
||||
} {
|
||||
return checkPathTrust({
|
||||
path: workspaceDir,
|
||||
isFolderTrustEnabled: isFolderTrustEnabled(settings),
|
||||
isHeadless: isHeadlessMode(headlessOptions),
|
||||
});
|
||||
): 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -280,7 +280,6 @@ 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');
|
||||
@@ -556,7 +555,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
skipTrust: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -615,7 +613,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
skipTrust: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -532,7 +532,7 @@ export async function main() {
|
||||
const { setupInitialActivityLogger } = await import(
|
||||
'./utils/devtoolsService.js'
|
||||
);
|
||||
setupInitialActivityLogger(config);
|
||||
await setupInitialActivityLogger(config);
|
||||
}
|
||||
|
||||
// Register config for telemetry shutdown
|
||||
@@ -661,12 +661,6 @@ 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
|
||||
|
||||
@@ -181,7 +181,6 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
|
||||
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -80,7 +80,7 @@ export async function runNonInteractive(
|
||||
const { setupInitialActivityLogger } = await import(
|
||||
'./utils/devtoolsService.js'
|
||||
);
|
||||
setupInitialActivityLogger(config);
|
||||
await setupInitialActivityLogger(config);
|
||||
}
|
||||
|
||||
const { stdout: workingStdout } = createWorkingStdio();
|
||||
|
||||
@@ -80,7 +80,7 @@ export async function runNonInteractive({
|
||||
const { setupInitialActivityLogger } = await import(
|
||||
'./utils/devtoolsService.js'
|
||||
);
|
||||
setupInitialActivityLogger(config);
|
||||
await setupInitialActivityLogger(config);
|
||||
}
|
||||
|
||||
const { stdout: workingStdout } = createWorkingStdio();
|
||||
|
||||
@@ -38,7 +38,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
|
||||
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
isMemoryV2Enabled: vi.fn(() => false),
|
||||
isMemoryManagerEnabled: vi.fn(() => false),
|
||||
isAutoMemoryEnabled: vi.fn(() => false),
|
||||
getListExtensions: vi.fn(() => false),
|
||||
getExtensions: vi.fn(() => []),
|
||||
@@ -124,9 +124,6 @@ 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(''),
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
<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" />
|
||||
<text x="0" y="19" fill="#141414" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
|
||||
<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="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… │
|
||||
|
||||
@@ -16,7 +16,7 @@ import { MessageType } from '../types.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export const clearCommand: SlashCommand = {
|
||||
name: 'clear',
|
||||
name: 'clear (new)',
|
||||
altNames: ['new'],
|
||||
description: 'Clear the screen and start a new session',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
|
||||
@@ -56,8 +56,11 @@ 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';
|
||||
@@ -75,6 +78,9 @@ 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) => {
|
||||
@@ -1908,6 +1914,172 @@ 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([
|
||||
{
|
||||
@@ -3835,9 +4007,9 @@ describe('InputPrompt', () => {
|
||||
expect(stdout.lastFrame()).toContain('hello world');
|
||||
});
|
||||
|
||||
// With plain borders offset
|
||||
// With plain borders: 1(border) + 1(padding) + 2(prompt) = 4 offset (x=4, col=5)
|
||||
await act(async () => {
|
||||
stdin.write(`\x1b[<0;4;2M`); // Click at col 4, row 2
|
||||
stdin.write(`\x1b[<0;5;2M`); // Click at col 5, row 2
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -73,6 +73,8 @@ 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';
|
||||
@@ -1643,6 +1645,21 @@ 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);
|
||||
@@ -1681,11 +1698,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
}, [buffer.visualCursor, buffer.text, focus]);
|
||||
|
||||
const listBackgroundColor = !useBackgroundColor
|
||||
? undefined
|
||||
: theme.background.input;
|
||||
|
||||
const useLineFallback = !!process.env['NO_COLOR'];
|
||||
const listBackgroundColor =
|
||||
useLineFallback || !useBackgroundColor ? undefined : theme.background.input;
|
||||
|
||||
useEffect(() => {
|
||||
if (onSuggestionsVisibilityChange) {
|
||||
@@ -1748,7 +1762,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return (
|
||||
<>
|
||||
{suggestionsPosition === 'above' && suggestionsNode}
|
||||
{useLineFallback || !useBackgroundColor ? (
|
||||
{useLineFallback ? (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderTop={true}
|
||||
@@ -1767,7 +1781,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
backgroundOpacity={1}
|
||||
useBackgroundColor={useBackgroundColor}
|
||||
>
|
||||
<Box flexGrow={1} flexDirection="row" paddingX={1}>
|
||||
<Box
|
||||
flexGrow={1}
|
||||
flexDirection="row"
|
||||
paddingX={1}
|
||||
borderColor={borderColor}
|
||||
borderStyle={useLineFallback ? 'round' : undefined}
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderLeft={!useBackgroundColor}
|
||||
borderRight={!useBackgroundColor}
|
||||
>
|
||||
<Text
|
||||
color={statusColor ?? theme.text.accent}
|
||||
aria-label={statusText || undefined}
|
||||
@@ -1856,7 +1880,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
</Box>
|
||||
</Box>
|
||||
</HalfLinePaddedBox>
|
||||
{useLineFallback || !useBackgroundColor ? (
|
||||
{useLineFallback ? (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderTop={false}
|
||||
|
||||
@@ -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,11 +5,15 @@
|
||||
<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="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="0" y="53" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.9 KiB |
@@ -5,10 +5,14 @@
|
||||
<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="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="0" y="53" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.8 KiB |
@@ -5,13 +5,19 @@
|
||||
<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="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="0" y="70" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -5,10 +5,16 @@
|
||||
<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="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="0" y="70" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -5,10 +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="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -5,10 +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="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" 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">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="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -5,8 +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="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </text>
|
||||
<text x="27" y="19" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs">hello 👍</text>
|
||||
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -5,9 +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="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -5,9 +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="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -5,11 +5,13 @@
|
||||
<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="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -5,11 +5,13 @@
|
||||
<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="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -5,12 +5,14 @@
|
||||
<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="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.8 KiB |
@@ -5,10 +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="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -5,9 +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="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -5,10 +5,16 @@
|
||||
<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="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">> </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="0" y="70" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -4,13 +4,15 @@
|
||||
</style>
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#1f1f1f" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
|
||||
<rect x="0" y="0" width="900" height="17" fill="#1f1f1f" />
|
||||
<text x="0" y="2" fill="#000000" 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">> </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" />
|
||||
<text x="0" y="36" fill="#1f1f1f" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
|
||||
<rect x="0" y="34" width="900" height="17" fill="#1f1f1f" />
|
||||
<text x="0" y="36" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.7 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,14 +6,16 @@
|
||||
<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>
|
||||
<text x="0" y="36" fill="#141414" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
|
||||
<rect x="0" y="34" width="900" height="17" fill="#141414" />
|
||||
<text x="0" y="36" fill="#000000" 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">> </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" />
|
||||
<text x="0" y="70" fill="#141414" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
|
||||
<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="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.7 KiB After Width: | Height: | Size: 4.8 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
|
||||
|
||||
@@ -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,11 +3,12 @@
|
||||
* 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 { supportsTrueColor } from '@google/gemini-cli-core';
|
||||
import { isITerm2 } from '../../utils/terminalUtils.js';
|
||||
|
||||
vi.mock('ink', async () => {
|
||||
const actual = await vi.importActual('ink');
|
||||
@@ -17,24 +18,15 @@ 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 true color is supported', async () => {
|
||||
mockSupportsTrueColor.mockReturnValue(true);
|
||||
it('renders standard background and blocks when not iTerm2', async () => {
|
||||
vi.mocked(isITerm2).mockReturnValue(false);
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<HalfLinePaddedBox backgroundBaseColor="blue" backgroundOpacity={0.5}>
|
||||
@@ -48,8 +40,8 @@ describe('<HalfLinePaddedBox />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders alternative blocks when true color is not supported', async () => {
|
||||
mockSupportsTrueColor.mockReturnValue(false);
|
||||
it('renders iTerm2-specific blocks when iTerm2 is detected', async () => {
|
||||
vi.mocked(isITerm2).mockReturnValue(true);
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<HalfLinePaddedBox backgroundBaseColor="blue" backgroundOpacity={0.5}>
|
||||
|
||||
@@ -9,8 +9,12 @@ 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 } from '../../themes/color-utils.js';
|
||||
import { supportsTrueColor } from '@google/gemini-cli-core';
|
||||
import {
|
||||
interpolateColor,
|
||||
resolveColor,
|
||||
getSafeLowColorBackground,
|
||||
} from '../../themes/color-utils.js';
|
||||
import { isLowColorDepth, isITerm2 } from '../../utils/terminalUtils.js';
|
||||
|
||||
export interface HalfLinePaddedBoxProps {
|
||||
/**
|
||||
@@ -52,7 +56,14 @@ 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;
|
||||
@@ -62,18 +73,37 @@ const HalfLinePaddedBoxInternal: React.FC<HalfLinePaddedBoxProps> = ({
|
||||
resolvedBase,
|
||||
backgroundOpacity,
|
||||
);
|
||||
}, [backgroundBaseColor, backgroundOpacity, terminalBg]);
|
||||
}, [backgroundBaseColor, backgroundOpacity, terminalBg, isLowColor]);
|
||||
|
||||
if (!backgroundColor) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const noTrueColor = !supportsTrueColor();
|
||||
const isITerm = isITerm2();
|
||||
|
||||
if (noTrueColor) {
|
||||
if (isITerm) {
|
||||
return (
|
||||
<Box width={terminalWidth} backgroundColor={backgroundColor} paddingY={1}>
|
||||
{children}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -85,20 +115,18 @@ const HalfLinePaddedBoxInternal: React.FC<HalfLinePaddedBoxProps> = ({
|
||||
alignItems="stretch"
|
||||
minHeight={1}
|
||||
flexShrink={0}
|
||||
backgroundColor={backgroundColor}
|
||||
>
|
||||
<Box width={terminalWidth} flexDirection="row">
|
||||
<Text color={backgroundColor}>{'▄'.repeat(terminalWidth)}</Text>
|
||||
</Box>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexDirection="column"
|
||||
alignItems="stretch"
|
||||
backgroundColor={backgroundColor}
|
||||
>
|
||||
{children}
|
||||
<Text backgroundColor={backgroundColor} color={terminalBg}>
|
||||
{'▀'.repeat(terminalWidth)}
|
||||
</Text>
|
||||
</Box>
|
||||
{children}
|
||||
<Box width={terminalWidth} flexDirection="row">
|
||||
<Text color={backgroundColor}>{'▀'.repeat(terminalWidth)}</Text>
|
||||
<Text color={terminalBg} backgroundColor={backgroundColor}>
|
||||
{'▄'.repeat(terminalWidth)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<HalfLinePaddedBox /> > renders alternative blocks when true color is not supported 1`] = `
|
||||
"
|
||||
exports[`<HalfLinePaddedBox /> > renders iTerm2-specific blocks when iTerm2 is detected 1`] = `
|
||||
"▄▄▄▄▄▄▄▄▄▄
|
||||
Content
|
||||
|
||||
▀▀▀▀▀▀▀▀▀▀
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -17,9 +17,9 @@ exports[`<HalfLinePaddedBox /> > renders nothing when useBackgroundColor is fals
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<HalfLinePaddedBox /> > renders standard background and blocks when true color is supported 1`] = `
|
||||
"▄▄▄▄▄▄▄▄▄▄
|
||||
exports[`<HalfLinePaddedBox /> > renders standard background and blocks when not iTerm2 1`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀
|
||||
Content
|
||||
▀▀▀▀▀▀▀▀▀▀
|
||||
▄▄▄▄▄▄▄▄▄▄
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -513,70 +513,6 @@ 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({
|
||||
|
||||
@@ -338,23 +338,17 @@ function useCommandSuggestions(
|
||||
|
||||
if (isTopLevelChatOrResumeContext) {
|
||||
const canonicalParentName = leafCommand.name;
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
setSuggestions(finalSuggestions);
|
||||
|
||||
@@ -123,22 +123,69 @@ describe('devtoolsService', () => {
|
||||
});
|
||||
|
||||
describe('setupInitialActivityLogger', () => {
|
||||
it('stays in buffer mode (no probe attempted)', () => {
|
||||
it('stays in buffer mode when no existing server found', async () => {
|
||||
const config = createMockConfig();
|
||||
setupInitialActivityLogger(config);
|
||||
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;
|
||||
|
||||
expect(mockInitActivityLogger).toHaveBeenCalledWith(config, {
|
||||
mode: 'buffer',
|
||||
});
|
||||
expect(mockAddNetworkTransport).not.toHaveBeenCalled();
|
||||
// No WebSocket probe on startup
|
||||
expect(MockWebSocket.instances.length).toBe(0);
|
||||
});
|
||||
|
||||
it('initializes in file mode when target env var is set', () => {
|
||||
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 () => {
|
||||
process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'] = '/tmp/test.jsonl';
|
||||
const config = createMockConfig();
|
||||
setupInitialActivityLogger(config);
|
||||
await setupInitialActivityLogger(config);
|
||||
|
||||
expect(mockInitActivityLogger).toHaveBeenCalledWith(config, {
|
||||
mode: 'file',
|
||||
@@ -148,10 +195,10 @@ describe('devtoolsService', () => {
|
||||
expect(MockWebSocket.instances.length).toBe(0);
|
||||
});
|
||||
|
||||
it('does nothing in file mode when config.storage is missing', () => {
|
||||
it('does nothing in file mode when config.storage is missing', async () => {
|
||||
process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'] = '/tmp/test.jsonl';
|
||||
const config = createMockConfig({ storage: undefined });
|
||||
setupInitialActivityLogger(config);
|
||||
await setupInitialActivityLogger(config);
|
||||
|
||||
expect(mockInitActivityLogger).not.toHaveBeenCalled();
|
||||
expect(MockWebSocket.instances.length).toBe(0);
|
||||
|
||||
@@ -116,17 +116,39 @@ async function handlePromotion(config: Config) {
|
||||
/**
|
||||
* Initializes the activity logger.
|
||||
* Interception starts immediately in buffering mode.
|
||||
* Transport is only attached when the user presses F12.
|
||||
* If an existing DevTools server is found, attaches transport eagerly.
|
||||
*/
|
||||
export function setupInitialActivityLogger(config: Config) {
|
||||
export async 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 — transport attached later on F12
|
||||
// Start in buffering mode (no transport attached yet)
|
||||
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,7 +34,6 @@ 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',
|
||||
@@ -144,51 +143,6 @@ 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,8 +12,6 @@ import {
|
||||
getCompatibilityWarnings,
|
||||
WarningPriority,
|
||||
type StartupWarning,
|
||||
isHeadlessMode,
|
||||
FatalUntrustedWorkspaceError,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Settings } from '../config/settingsSchema.js';
|
||||
import {
|
||||
@@ -81,34 +79,10 @@ 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(
|
||||
|
||||
@@ -37,14 +37,6 @@ 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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -20,7 +20,8 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"vendor"
|
||||
],
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -15,6 +15,7 @@ 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';
|
||||
@@ -292,6 +293,14 @@ 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,15 +8,13 @@ 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' | 'temporary';
|
||||
export type ModelHealthStatus = 'terminal' | 'sticky_retry';
|
||||
|
||||
type HealthState =
|
||||
| { status: 'terminal'; reason: TerminalUnavailabilityReason }
|
||||
@@ -24,11 +22,6 @@ type HealthState =
|
||||
status: 'sticky_retry';
|
||||
reason: TurnUnavailabilityReason;
|
||||
consumed: boolean;
|
||||
}
|
||||
| {
|
||||
status: 'temporary';
|
||||
reason: TemporaryUnavailabilityReason;
|
||||
untilMs: number;
|
||||
};
|
||||
|
||||
export interface ModelAvailabilitySnapshot {
|
||||
@@ -55,18 +48,6 @@ 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);
|
||||
}
|
||||
@@ -114,15 +95,6 @@ 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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -3500,8 +3500,8 @@ describe('Config JIT Initialization', () => {
|
||||
expect(config.getUserMemory()).toBe('Initial Memory');
|
||||
});
|
||||
|
||||
describe('isMemoryV2Enabled', () => {
|
||||
it('should default to true', () => {
|
||||
describe('isMemoryManagerEnabled', () => {
|
||||
it('should default to false', () => {
|
||||
const params: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
targetDir: '/tmp/test',
|
||||
@@ -3511,106 +3511,21 @@ describe('Config JIT Initialization', () => {
|
||||
};
|
||||
|
||||
config = new Config(params);
|
||||
expect(config.isMemoryV2Enabled()).toBe(true);
|
||||
expect(config.isMemoryManagerEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when experimentalMemoryV2 is explicitly false', () => {
|
||||
it('should return true when experimentalMemoryManager is true', () => {
|
||||
const params: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
targetDir: '/tmp/test',
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
cwd: '/tmp/test',
|
||||
experimentalMemoryV2: false,
|
||||
experimentalMemoryManager: true,
|
||||
};
|
||||
|
||||
config = new Config(params);
|
||||
expect(config.isMemoryV2Enabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when experimentalMemoryV2 is true', () => {
|
||||
const params: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
targetDir: '/tmp/test',
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
cwd: '/tmp/test',
|
||||
experimentalMemoryV2: true,
|
||||
};
|
||||
|
||||
config = new Config(params);
|
||||
expect(config.isMemoryV2Enabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT add the global ~/.gemini directory to the workspace when enabled', async () => {
|
||||
// The prompt-driven memoryV2 mode does not broaden the workspace
|
||||
// to include the global ~/.gemini/ directory. Cross-project personal
|
||||
// preferences are routed to ~/.gemini/GEMINI.md via the surgical
|
||||
// isPathAllowed allowlist instead — see the next two tests.
|
||||
const params: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
targetDir: '/tmp/test',
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
cwd: '/tmp/test',
|
||||
experimentalMemoryV2: true,
|
||||
};
|
||||
|
||||
config = new Config(params);
|
||||
await config.initialize();
|
||||
|
||||
const directories = config.getWorkspaceContext().getDirectories();
|
||||
expect(directories).not.toContain(Storage.getGlobalGeminiDir());
|
||||
});
|
||||
|
||||
it('should allow isPathAllowed to write the global ~/.gemini/GEMINI.md file', async () => {
|
||||
// Surgical allowlist: when memoryV2 is on, the prompt routes
|
||||
// cross-project personal preferences to ~/.gemini/GEMINI.md, so the
|
||||
// agent must be able to edit that exact file via edit/write_file.
|
||||
const params: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
targetDir: '/tmp/test',
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
cwd: '/tmp/test',
|
||||
experimentalMemoryV2: true,
|
||||
};
|
||||
|
||||
config = new Config(params);
|
||||
await config.initialize();
|
||||
|
||||
const globalGeminiMdPath = path.join(
|
||||
Storage.getGlobalGeminiDir(),
|
||||
'GEMINI.md',
|
||||
);
|
||||
expect(config.isPathAllowed(globalGeminiMdPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT allow isPathAllowed to write other files under ~/.gemini/ (least privilege)', async () => {
|
||||
// The allowlist is surgical: only ~/.gemini/GEMINI.md is reachable.
|
||||
// settings.json, keybindings.json, credentials, etc. remain disallowed.
|
||||
const params: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
targetDir: '/tmp/test',
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
cwd: '/tmp/test',
|
||||
experimentalMemoryV2: true,
|
||||
};
|
||||
|
||||
config = new Config(params);
|
||||
await config.initialize();
|
||||
|
||||
const globalDir = Storage.getGlobalGeminiDir();
|
||||
expect(config.isPathAllowed(path.join(globalDir, 'settings.json'))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
config.isPathAllowed(path.join(globalDir, 'keybindings.json')),
|
||||
).toBe(false);
|
||||
expect(
|
||||
config.isPathAllowed(path.join(globalDir, 'oauth_creds.json')),
|
||||
).toBe(false);
|
||||
expect(config.isMemoryManagerEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3642,18 +3557,18 @@ describe('Config JIT Initialization', () => {
|
||||
expect(config.isAutoMemoryEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should be independent of experimentalMemoryV2', () => {
|
||||
it('should be independent of experimentalMemoryManager', () => {
|
||||
const params: ConfigParameters = {
|
||||
sessionId: 'test-session',
|
||||
targetDir: '/tmp/test',
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
cwd: '/tmp/test',
|
||||
experimentalMemoryV2: true,
|
||||
experimentalMemoryManager: true,
|
||||
};
|
||||
|
||||
config = new Config(params);
|
||||
expect(config.isMemoryV2Enabled()).toBe(true);
|
||||
expect(config.isMemoryManagerEnabled()).toBe(true);
|
||||
expect(config.isAutoMemoryEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,11 +41,7 @@ import { EditTool } from '../tools/edit.js';
|
||||
import { ShellTool } from '../tools/shell.js';
|
||||
import { WriteFileTool } from '../tools/write-file.js';
|
||||
import { WebFetchTool } from '../tools/web-fetch.js';
|
||||
import {
|
||||
MemoryTool,
|
||||
setGeminiMdFilename,
|
||||
getCurrentGeminiMdFilename,
|
||||
} from '../tools/memoryTool.js';
|
||||
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
|
||||
import { WebSearchTool } from '../tools/web-search.js';
|
||||
import { AskUserTool } from '../tools/ask-user.js';
|
||||
import { UpdateTopicTool } from '../tools/topicTool.js';
|
||||
@@ -606,6 +602,7 @@ export interface ConfigParameters {
|
||||
toolDiscoveryCommand?: string;
|
||||
toolCallCommand?: string;
|
||||
mcpServerCommand?: string;
|
||||
shellToolRcFile?: string;
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
mcpEnablementCallbacks?: McpEnablementCallbacks;
|
||||
userMemory?: string | HierarchicalMemory;
|
||||
@@ -679,11 +676,6 @@ export interface ConfigParameters {
|
||||
policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest;
|
||||
output?: OutputSettings;
|
||||
gemmaModelRouter?: GemmaModelRouterSettings;
|
||||
autoRouting?: {
|
||||
bestEffortPro?: boolean;
|
||||
proTimeoutMinutes?: number;
|
||||
proTimeoutFallbackDurationMinutes?: number;
|
||||
};
|
||||
adk?: ADKSettings;
|
||||
disableModelRouterForAuth?: AuthType[];
|
||||
continueOnFailedApiCall?: boolean;
|
||||
@@ -714,7 +706,7 @@ export interface ConfigParameters {
|
||||
adminSkillsEnabled?: boolean;
|
||||
experimentalJitContext?: boolean;
|
||||
autoDistillation?: boolean;
|
||||
experimentalMemoryV2?: boolean;
|
||||
experimentalMemoryManager?: boolean;
|
||||
experimentalAutoMemory?: boolean;
|
||||
experimentalContextManagementConfig?: string;
|
||||
experimentalAgentHistoryTruncation?: boolean;
|
||||
@@ -788,6 +780,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly toolDiscoveryCommand: string | undefined;
|
||||
private readonly toolCallCommand: string | undefined;
|
||||
private readonly mcpServerCommand: string | undefined;
|
||||
private readonly shellToolRcFile: string | undefined;
|
||||
private readonly mcpEnabled: boolean;
|
||||
private readonly extensionsEnabled: boolean;
|
||||
private mcpServers: Record<string, MCPServerConfig> | undefined;
|
||||
@@ -959,7 +952,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private disabledSkills: string[];
|
||||
private readonly adminSkillsEnabled: boolean;
|
||||
private readonly experimentalJitContext: boolean;
|
||||
private readonly experimentalMemoryV2: boolean;
|
||||
private readonly experimentalMemoryManager: boolean;
|
||||
private readonly experimentalAutoMemory: boolean;
|
||||
private readonly experimentalContextManagementConfig?: string;
|
||||
private readonly memoryBoundaryMarkers: readonly string[];
|
||||
@@ -968,9 +961,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly planEnabled: boolean;
|
||||
private readonly trackerEnabled: boolean;
|
||||
private readonly planModeRoutingEnabled: boolean;
|
||||
private readonly autoRoutingBestEffortPro: boolean;
|
||||
private readonly autoRoutingProTimeoutMinutes: number;
|
||||
private readonly autoRoutingProTimeoutFallbackDurationMinutes: number;
|
||||
private readonly modelSteering: boolean;
|
||||
private memoryContextManager?: MemoryContextManager;
|
||||
private readonly contextManagement: ContextManagementConfig;
|
||||
@@ -1059,6 +1049,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.toolDiscoveryCommand = params.toolDiscoveryCommand;
|
||||
this.toolCallCommand = params.toolCallCommand;
|
||||
this.mcpServerCommand = params.mcpServerCommand;
|
||||
this.shellToolRcFile = params.shellToolRcFile;
|
||||
this.mcpServers = params.mcpServers;
|
||||
this.mcpEnablementCallbacks = params.mcpEnablementCallbacks;
|
||||
this.mcpEnabled = params.mcpEnabled ?? true;
|
||||
@@ -1125,11 +1116,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.planEnabled = params.plan ?? true;
|
||||
this.trackerEnabled = params.tracker ?? false;
|
||||
this.planModeRoutingEnabled = params.planSettings?.modelRouting ?? true;
|
||||
this.autoRoutingBestEffortPro = params.autoRouting?.bestEffortPro ?? false;
|
||||
this.autoRoutingProTimeoutMinutes =
|
||||
params.autoRouting?.proTimeoutMinutes ?? 5;
|
||||
this.autoRoutingProTimeoutFallbackDurationMinutes =
|
||||
params.autoRouting?.proTimeoutFallbackDurationMinutes ?? 60;
|
||||
this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true;
|
||||
this.skillsSupport = params.skillsSupport ?? true;
|
||||
this.disabledSkills = params.disabledSkills ?? [];
|
||||
@@ -1184,8 +1170,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
modelConfigServiceConfig ?? DEFAULT_MODEL_CONFIGS,
|
||||
);
|
||||
|
||||
this.experimentalJitContext = params.experimentalJitContext ?? true;
|
||||
this.experimentalMemoryV2 = params.experimentalMemoryV2 ?? true;
|
||||
this.experimentalJitContext = params.experimentalJitContext ?? false;
|
||||
this.experimentalMemoryManager = params.experimentalMemoryManager ?? false;
|
||||
this.experimentalAutoMemory = params.experimentalAutoMemory ?? false;
|
||||
this.experimentalContextManagementConfig =
|
||||
params.experimentalContextManagementConfig;
|
||||
@@ -2335,6 +2321,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.mcpServerCommand;
|
||||
}
|
||||
|
||||
getShellToolRcFile(): string | undefined {
|
||||
return this.shellToolRcFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user configured MCP servers (via gemini settings files).
|
||||
*
|
||||
@@ -2519,8 +2509,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.memoryBoundaryMarkers;
|
||||
}
|
||||
|
||||
isMemoryV2Enabled(): boolean {
|
||||
return this.experimentalMemoryV2;
|
||||
isMemoryManagerEnabled(): boolean {
|
||||
return this.experimentalMemoryManager;
|
||||
}
|
||||
|
||||
isAutoMemoryEnabled(): boolean {
|
||||
@@ -3048,10 +3038,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
/**
|
||||
* Checks if a given absolute path is allowed for file system operations.
|
||||
* A path is allowed if it's within the workspace context, the project's
|
||||
* temporary directory, or is exactly the global personal `~/.gemini/GEMINI.md`
|
||||
* file (the latter is the only file under `~/.gemini/` that is reachable —
|
||||
* settings, credentials, keybindings, etc. remain disallowed).
|
||||
* A path is allowed if it's within the workspace context or the project's temporary directory.
|
||||
*
|
||||
* @param absolutePath The absolute path to check.
|
||||
* @returns true if the path is allowed, false otherwise.
|
||||
@@ -3066,25 +3053,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
const projectTempDir = this.storage.getProjectTempDir();
|
||||
const resolvedTempDir = resolveToRealPath(projectTempDir);
|
||||
if (isSubpath(resolvedTempDir, resolvedPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Surgical allowlist: the global personal GEMINI.md file (and ONLY that
|
||||
// file) is reachable so the prompt-driven memory flow can persist
|
||||
// cross-project personal preferences. This deliberately does NOT
|
||||
// allowlist the rest of `~/.gemini/`.
|
||||
const globalMemoryFilePath = path.join(
|
||||
Storage.getGlobalGeminiDir(),
|
||||
getCurrentGeminiMdFilename(),
|
||||
);
|
||||
const resolvedGlobalMemoryFilePath =
|
||||
resolveToRealPath(globalMemoryFilePath);
|
||||
if (resolvedPath === resolvedGlobalMemoryFilePath) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return isSubpath(resolvedTempDir, resolvedPath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3157,18 +3127,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return flag?.boolValue ?? true;
|
||||
}
|
||||
|
||||
async getBestEffortProEnabled(): Promise<boolean> {
|
||||
return this.autoRoutingBestEffortPro;
|
||||
}
|
||||
|
||||
async getProTimeoutMinutes(): Promise<number> {
|
||||
return this.autoRoutingProTimeoutMinutes;
|
||||
}
|
||||
|
||||
async getProTimeoutFallbackDurationMinutes(): Promise<number> {
|
||||
return this.autoRoutingProTimeoutFallbackDurationMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the resolved complexity threshold for routing.
|
||||
* If a remote threshold is provided and within range (0-100), it is returned.
|
||||
@@ -3730,7 +3688,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
new ReadBackgroundOutputTool(this, this.messageBus),
|
||||
),
|
||||
);
|
||||
if (!this.isMemoryV2Enabled()) {
|
||||
if (!this.isMemoryManagerEnabled()) {
|
||||
maybeRegister(MemoryTool, () =>
|
||||
registry.registerTool(new MemoryTool(this.messageBus, this.storage)),
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ export function flattenMemory(memory?: string | HierarchicalMemory): string {
|
||||
}
|
||||
if (memory.userProjectMemory?.trim()) {
|
||||
sections.push({
|
||||
name: 'Private Project Memory',
|
||||
name: 'User Project Memory',
|
||||
content: memory.userProjectMemory.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,28 +45,19 @@ describe('Config Path Validation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow access to a file under ~/.gemini once that directory is added to the workspace', () => {
|
||||
// Use settings.json rather than GEMINI.md as the example: the latter is
|
||||
// now reachable via a surgical isPathAllowed allowlist regardless of
|
||||
// workspace membership (covered by dedicated tests in config.test.ts), so
|
||||
// it can no longer demonstrate the workspace-addition semantic on its
|
||||
// own. settings.json is NOT on the allowlist, so it preserves the
|
||||
// original "denied -> add to workspace -> allowed" flow this test was
|
||||
// written to verify, and additionally double-asserts the least-privilege
|
||||
// guarantee that the allowlist does not leak access to other files
|
||||
// under ~/.gemini/.
|
||||
const settingsPath = path.join(globalGeminiDir, 'settings.json');
|
||||
it('should allow access to ~/.gemini if it is added to the workspace', () => {
|
||||
const geminiMdPath = path.join(globalGeminiDir, 'GEMINI.md');
|
||||
|
||||
// Before adding, it should be denied
|
||||
expect(config.isPathAllowed(settingsPath)).toBe(false);
|
||||
expect(config.isPathAllowed(geminiMdPath)).toBe(false);
|
||||
|
||||
// Add to workspace
|
||||
config.getWorkspaceContext().addDirectory(globalGeminiDir);
|
||||
|
||||
// Now it should be allowed
|
||||
expect(config.isPathAllowed(settingsPath)).toBe(true);
|
||||
expect(config.validatePathAccess(settingsPath, 'read')).toBeNull();
|
||||
expect(config.validatePathAccess(settingsPath, 'write')).toBeNull();
|
||||
expect(config.isPathAllowed(geminiMdPath)).toBe(true);
|
||||
expect(config.validatePathAccess(geminiMdPath, 'read')).toBeNull();
|
||||
expect(config.validatePathAccess(geminiMdPath, 'write')).toBeNull();
|
||||
});
|
||||
|
||||
it('should still allow project workspace paths', () => {
|
||||
|
||||
@@ -20,7 +20,6 @@ import { ProjectRegistry } from './projectRegistry.js';
|
||||
import { StorageMigration } from './storageMigration.js';
|
||||
|
||||
export const OAUTH_FILE = 'oauth_creds.json';
|
||||
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
|
||||
const TMP_DIR_NAME = 'tmp';
|
||||
const BIN_DIR_NAME = 'bin';
|
||||
const AGENTS_DIR_NAME = '.agents';
|
||||
@@ -87,13 +86,6 @@ export class Storage {
|
||||
return path.join(Storage.getGlobalGeminiDir(), GOOGLE_ACCOUNTS_FILENAME);
|
||||
}
|
||||
|
||||
static getTrustedFoldersPath(): string {
|
||||
if (process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']) {
|
||||
return process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'];
|
||||
}
|
||||
return path.join(Storage.getGlobalGeminiDir(), TRUSTED_FOLDERS_FILENAME);
|
||||
}
|
||||
|
||||
static getUserCommandsDir(): string {
|
||||
return path.join(Storage.getGlobalGeminiDir(), 'commands');
|
||||
}
|
||||
|
||||
@@ -109,8 +109,6 @@ describe('BaseLlmClient', () => {
|
||||
.mockReturnValue({ authType: AuthType.USE_GEMINI }),
|
||||
getEmbeddingModel: vi.fn().mockReturnValue('test-embedding-model'),
|
||||
isInteractive: vi.fn().mockReturnValue(false),
|
||||
getProTimeoutMinutes: vi.fn().mockResolvedValue(5),
|
||||
getProTimeoutFallbackDurationMinutes: vi.fn().mockResolvedValue(60),
|
||||
modelConfigService: {
|
||||
getResolvedConfig: vi
|
||||
.fn()
|
||||
|
||||
@@ -325,19 +325,11 @@ export class BaseLlmClient {
|
||||
);
|
||||
};
|
||||
|
||||
const proTimeoutMinutes = await this.config.getProTimeoutMinutes();
|
||||
const proTimeoutFallbackDurationMinutes =
|
||||
await this.config.getProTimeoutFallbackDurationMinutes();
|
||||
|
||||
return await retryWithBackoff(apiCall, {
|
||||
shouldRetryOnContent,
|
||||
maxAttempts:
|
||||
availabilityMaxAttempts ?? maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
||||
getAvailabilityContext,
|
||||
timeoutFallback: {
|
||||
timeoutMs: proTimeoutMinutes * 60 * 1000,
|
||||
fallbackDurationMs: proTimeoutFallbackDurationMinutes * 60 * 1000,
|
||||
},
|
||||
onPersistent429: this.config.isInteractive()
|
||||
? (authType, error) =>
|
||||
handleFallback(this.config, currentModel, authType, error)
|
||||
|
||||
@@ -1244,6 +1244,9 @@ ${JSON.stringify(
|
||||
count: 2,
|
||||
});
|
||||
|
||||
const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
|
||||
|
||||
// Act
|
||||
const stream = client.sendMessageStream(
|
||||
[{ text: 'Hi' }],
|
||||
new AbortController().signal,
|
||||
@@ -1264,6 +1267,7 @@ ${JSON.stringify(
|
||||
|
||||
// Assert
|
||||
expect(events).toContainEqual({ type: GeminiEventType.LoopDetected });
|
||||
expect(abortSpy).toHaveBeenCalled();
|
||||
expect(finalResult).toBeInstanceOf(Turn);
|
||||
});
|
||||
|
||||
|
||||
@@ -684,6 +684,9 @@ export class GeminiClient {
|
||||
// Re-initialize turn with fresh history
|
||||
turn = new Turn(this.getChat(), prompt_id);
|
||||
|
||||
const controller = new AbortController();
|
||||
const linkedSignal = AbortSignal.any([signal, controller.signal]);
|
||||
|
||||
const loopResult = await this.loopDetector.turnStarted(signal);
|
||||
if (loopResult.count > 1) {
|
||||
yield { type: GeminiEventType.LoopDetected };
|
||||
@@ -744,7 +747,7 @@ export class GeminiClient {
|
||||
const resultStream = turn.run(
|
||||
modelConfigKey,
|
||||
request,
|
||||
signal,
|
||||
linkedSignal,
|
||||
displayContent,
|
||||
);
|
||||
let isError = false;
|
||||
@@ -780,6 +783,7 @@ export class GeminiClient {
|
||||
}
|
||||
|
||||
if (loopDetectedAbort) {
|
||||
controller.abort();
|
||||
return turn;
|
||||
}
|
||||
|
||||
@@ -791,8 +795,10 @@ export class GeminiClient {
|
||||
boundedTurns,
|
||||
isInvalidStreamRetry,
|
||||
displayContent,
|
||||
controller,
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return turn;
|
||||
}
|
||||
@@ -1246,7 +1252,10 @@ export class GeminiClient {
|
||||
boundedTurns: number,
|
||||
isInvalidStreamRetry: boolean,
|
||||
displayContent?: PartListUnion,
|
||||
controllerToAbort?: AbortController,
|
||||
): AsyncGenerator<ServerGeminiStreamEvent, Turn> {
|
||||
controllerToAbort?.abort();
|
||||
|
||||
// Clear the detection flag so the recursive turn can proceed, but the count remains 1.
|
||||
this.loopDetector.clearDetection();
|
||||
|
||||
|
||||
@@ -160,8 +160,6 @@ describe('GeminiChat', () => {
|
||||
authType: 'oauth-personal',
|
||||
model: currentModel,
|
||||
})),
|
||||
getProTimeoutMinutes: vi.fn().mockResolvedValue(5),
|
||||
getProTimeoutFallbackDurationMinutes: vi.fn().mockResolvedValue(60),
|
||||
getModel: vi.fn().mockImplementation(() => currentModel),
|
||||
setModel: vi.fn().mockImplementation((m: string) => {
|
||||
currentModel = m;
|
||||
|
||||
@@ -687,10 +687,6 @@ export class GeminiChat {
|
||||
);
|
||||
};
|
||||
|
||||
const proTimeoutMinutes = await this.context.config.getProTimeoutMinutes();
|
||||
const proTimeoutFallbackDurationMinutes =
|
||||
await this.context.config.getProTimeoutFallbackDurationMinutes();
|
||||
|
||||
const streamResponse = await retryWithBackoff(apiCall, {
|
||||
onPersistent429: onPersistent429Callback,
|
||||
onValidationRequired: onValidationRequiredCallback,
|
||||
@@ -700,10 +696,6 @@ export class GeminiChat {
|
||||
maxAttempts:
|
||||
availabilityMaxAttempts ?? this.context.config.getMaxAttempts(),
|
||||
getAvailabilityContext,
|
||||
timeoutFallback: {
|
||||
timeoutMs: proTimeoutMinutes * 60 * 1000,
|
||||
fallbackDurationMs: proTimeoutFallbackDurationMinutes * 60 * 1000,
|
||||
},
|
||||
onRetry: (attempt, error, delayMs) => {
|
||||
coreEvents.emitRetryAttempt({
|
||||
attempt,
|
||||
|
||||
@@ -103,8 +103,6 @@ describe('GeminiChat Network Retries', () => {
|
||||
authType: 'oauth-personal',
|
||||
model: 'test-model',
|
||||
}),
|
||||
getProTimeoutMinutes: vi.fn().mockResolvedValue(5),
|
||||
getProTimeoutFallbackDurationMinutes: vi.fn().mockResolvedValue(60),
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
setActiveModel: vi.fn(),
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
isInteractiveShellEnabled: vi.fn().mockReturnValue(true),
|
||||
isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false),
|
||||
isMemoryV2Enabled: vi.fn().mockReturnValue(false),
|
||||
isMemoryManagerEnabled: vi.fn().mockReturnValue(false),
|
||||
isAgentsEnabled: vi.fn().mockReturnValue(false),
|
||||
getPreviewFeatures: vi.fn().mockReturnValue(true),
|
||||
getModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO),
|
||||
@@ -458,7 +458,7 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
isInteractive: vi.fn().mockReturnValue(false),
|
||||
isInteractiveShellEnabled: vi.fn().mockReturnValue(false),
|
||||
isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false),
|
||||
isMemoryV2Enabled: vi.fn().mockReturnValue(false),
|
||||
isMemoryManagerEnabled: vi.fn().mockReturnValue(false),
|
||||
isAgentsEnabled: vi.fn().mockReturnValue(false),
|
||||
getModel: vi.fn().mockReturnValue('auto'),
|
||||
getActiveModel: vi.fn().mockReturnValue(PREVIEW_GEMINI_MODEL),
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
createProxyAwareFetch,
|
||||
type StdioConfig,
|
||||
} from './ide-connection-utils.js';
|
||||
import { getVersion } from '../utils/version.js';
|
||||
|
||||
const logger = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -589,7 +588,8 @@ export class IdeClient {
|
||||
logger.debug(`Server URL: ${serverUrl}`);
|
||||
this.client = new Client({
|
||||
name: 'streamable-http-client',
|
||||
version: await getVersion(),
|
||||
// TODO(#3487): use the CLI version here.
|
||||
version: '1.0.0',
|
||||
});
|
||||
transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
|
||||
fetch: await createProxyAwareFetch(ideServerHost),
|
||||
@@ -623,7 +623,8 @@ export class IdeClient {
|
||||
logger.debug('Attempting to connect to IDE via stdio');
|
||||
this.client = new Client({
|
||||
name: 'stdio-client',
|
||||
version: await getVersion(),
|
||||
// TODO(#3487): use the CLI version here.
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
transport = new StdioClientTransport({
|
||||
|
||||
@@ -294,6 +294,3 @@ export type { Content, Part, FunctionCall } from '@google/genai';
|
||||
// Export context types and profiles
|
||||
export * from './context/types.js';
|
||||
export * from './context/profiles.js';
|
||||
|
||||
// Export trust utility
|
||||
export * from './utils/trust.js';
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('PromptProvider', () => {
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
isInteractiveShellEnabled: vi.fn().mockReturnValue(true),
|
||||
isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false),
|
||||
isMemoryV2Enabled: vi.fn().mockReturnValue(false),
|
||||
isMemoryManagerEnabled: vi.fn().mockReturnValue(false),
|
||||
getSkillManager: vi.fn().mockReturnValue({
|
||||
getSkills: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
|
||||
@@ -30,11 +30,7 @@ import {
|
||||
} from '../tools/tool-names.js';
|
||||
import { resolveModel, supportsModernFeatures } from '../config/models.js';
|
||||
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
|
||||
import {
|
||||
getAllGeminiMdFilenames,
|
||||
getGlobalMemoryFilePath,
|
||||
getProjectMemoryIndexFilePath,
|
||||
} from '../tools/memoryTool.js';
|
||||
import { getAllGeminiMdFilenames } from '../tools/memoryTool.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
|
||||
/**
|
||||
@@ -227,13 +223,7 @@ export class PromptProvider {
|
||||
context.config.getEnableShellOutputEfficiency(),
|
||||
interactiveShellEnabled: context.config.isInteractiveShellEnabled(),
|
||||
topicUpdateNarration: isTopicUpdateNarrationEnabled,
|
||||
memoryV2Enabled: context.config.isMemoryV2Enabled(),
|
||||
userProjectMemoryPath: context.config.isMemoryV2Enabled()
|
||||
? getProjectMemoryIndexFilePath(context.config.storage)
|
||||
: undefined,
|
||||
globalMemoryPath: context.config.isMemoryV2Enabled()
|
||||
? getGlobalMemoryFilePath()
|
||||
: undefined,
|
||||
memoryManagerEnabled: context.config.isMemoryManagerEnabled(),
|
||||
}),
|
||||
),
|
||||
sandbox: this.withSection('sandbox', () => ({
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderOperationalGuidelines } from './snippets.js';
|
||||
|
||||
describe('renderOperationalGuidelines - memoryManagerEnabled', () => {
|
||||
const baseOptions = {
|
||||
interactive: true,
|
||||
interactiveShellEnabled: false,
|
||||
topicUpdateNarration: false,
|
||||
memoryManagerEnabled: false,
|
||||
};
|
||||
|
||||
it('should include standard memory tool guidance when memoryManagerEnabled is false', () => {
|
||||
const result = renderOperationalGuidelines(baseOptions);
|
||||
expect(result).toContain('save_memory');
|
||||
expect(result).toContain('persist facts across sessions');
|
||||
expect(result).not.toContain('subagent');
|
||||
});
|
||||
|
||||
it('should include subagent memory guidance when memoryManagerEnabled is true', () => {
|
||||
const result = renderOperationalGuidelines({
|
||||
...baseOptions,
|
||||
memoryManagerEnabled: true,
|
||||
});
|
||||
expect(result).toContain('save_memory');
|
||||
expect(result).toContain('subagent');
|
||||
expect(result).not.toContain('persistent user-related information');
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderOperationalGuidelines } from './snippets.js';
|
||||
|
||||
describe('renderOperationalGuidelines - memoryV2Enabled', () => {
|
||||
const baseOptions = {
|
||||
interactive: true,
|
||||
interactiveShellEnabled: false,
|
||||
topicUpdateNarration: false,
|
||||
memoryV2Enabled: false,
|
||||
};
|
||||
|
||||
it('should include standard memory tool guidance when memoryV2Enabled is false', () => {
|
||||
const result = renderOperationalGuidelines(baseOptions);
|
||||
expect(result).toContain('save_memory');
|
||||
expect(result).toContain('persist facts across sessions');
|
||||
expect(result).not.toContain('Instruction and Memory Files');
|
||||
});
|
||||
|
||||
it('should distinguish shared GEMINI.md instructions from private MEMORY.md when memoryV2Enabled is true', () => {
|
||||
const result = renderOperationalGuidelines({
|
||||
...baseOptions,
|
||||
memoryV2Enabled: true,
|
||||
});
|
||||
expect(result).toContain('Instruction and Memory Files');
|
||||
expect(result).toContain('GEMINI.md');
|
||||
expect(result).toContain('./GEMINI.md');
|
||||
expect(result).toContain('MEMORY.md');
|
||||
expect(result).toContain('sibling `*.md` file');
|
||||
expect(result).toContain('There is no `save_memory` tool');
|
||||
expect(result).not.toContain('subagent');
|
||||
|
||||
// The Global Personal Memory tier is now opt-in via globalMemoryPath.
|
||||
// When it is NOT provided (this case), the bullet and the cross-project
|
||||
// routing rule must not be rendered.
|
||||
expect(result).not.toContain('**Global Personal Memory**');
|
||||
expect(result).not.toContain('across all my projects');
|
||||
|
||||
// Per-tier routing block must be present so the model has one trigger
|
||||
// per home rather than a single broad "remember -> private folder"
|
||||
// default that causes duplicate writes across tiers.
|
||||
expect(result).toContain('Routing rules — pick exactly one tier per fact');
|
||||
expect(result).toContain('team-shared convention');
|
||||
expect(result).toContain('personal-to-them local setup');
|
||||
|
||||
// Explicit mutual-exclusion rule: each fact lives in exactly one tier.
|
||||
expect(result).toContain('Never duplicate or mirror the same fact');
|
||||
|
||||
// MEMORY.md must be scoped to its sibling notes only and must never
|
||||
// point at GEMINI.md topics.
|
||||
expect(result).toContain('index for its sibling `*.md` notes');
|
||||
expect(result).toContain('never use it to point at');
|
||||
});
|
||||
|
||||
it('should NOT include the Private Project Memory bullet when userProjectMemoryPath is undefined', () => {
|
||||
const result = renderOperationalGuidelines({
|
||||
...baseOptions,
|
||||
memoryV2Enabled: true,
|
||||
});
|
||||
expect(result).not.toContain('**Private Project Memory**');
|
||||
});
|
||||
|
||||
it('should include the Private Project Memory bullet with the absolute path when provided', () => {
|
||||
const userProjectMemoryPath =
|
||||
'/Users/test/.gemini/tmp/abc123/memory/MEMORY.md';
|
||||
const result = renderOperationalGuidelines({
|
||||
...baseOptions,
|
||||
memoryV2Enabled: true,
|
||||
userProjectMemoryPath,
|
||||
});
|
||||
expect(result).toContain('**Private Project Memory**');
|
||||
expect(result).toContain(userProjectMemoryPath);
|
||||
expect(result).toContain('NOT** be committed to the repo');
|
||||
});
|
||||
|
||||
it('should NOT include the Global Personal Memory bullet or cross-project routing rule when globalMemoryPath is undefined', () => {
|
||||
const result = renderOperationalGuidelines({
|
||||
...baseOptions,
|
||||
memoryV2Enabled: true,
|
||||
});
|
||||
expect(result).not.toContain('**Global Personal Memory**');
|
||||
expect(result).not.toContain('across all my projects');
|
||||
expect(result).not.toContain('cross-project personal preference');
|
||||
});
|
||||
|
||||
it('should include the Global Personal Memory bullet, cross-project routing rule, and four-tier mutual-exclusion when globalMemoryPath is provided', () => {
|
||||
const globalMemoryPath = '/Users/test/.gemini/GEMINI.md';
|
||||
const result = renderOperationalGuidelines({
|
||||
...baseOptions,
|
||||
memoryV2Enabled: true,
|
||||
globalMemoryPath,
|
||||
});
|
||||
expect(result).toContain('**Global Personal Memory**');
|
||||
expect(result).toContain(globalMemoryPath);
|
||||
expect(result).toContain('cross-project personal preference');
|
||||
expect(result).toContain('across all my projects');
|
||||
// Mutual-exclusion rule must explicitly cover all four tiers when the
|
||||
// global tier is surfaced.
|
||||
expect(result).toContain('across all four tiers');
|
||||
});
|
||||
});
|
||||
@@ -74,12 +74,7 @@ export interface OperationalGuidelinesOptions {
|
||||
enableShellEfficiency: boolean;
|
||||
interactiveShellEnabled: boolean;
|
||||
topicUpdateNarration?: boolean;
|
||||
memoryV2Enabled: boolean;
|
||||
/**
|
||||
* Absolute path to the user's per-project private memory index. See
|
||||
* snippets.ts for full semantics.
|
||||
*/
|
||||
userProjectMemoryPath?: string;
|
||||
memoryManagerEnabled: boolean;
|
||||
}
|
||||
|
||||
export type SandboxMode = 'macos-seatbelt' | 'generic' | 'outside';
|
||||
@@ -414,7 +409,7 @@ ${trimmed}
|
||||
}
|
||||
if (memory.userProjectMemory?.trim()) {
|
||||
sections.push(
|
||||
`<user_project_memory>\n--- Private Project Memory Index (private, not committed to repo) ---\n${memory.userProjectMemory.trim()}\n--- End Private Project Memory Index ---\n</user_project_memory>`,
|
||||
`<user_project_memory>\n--- User's Project Memory (private, not committed to repo) ---\n${memory.userProjectMemory.trim()}\n--- End User's Project Memory ---\n</user_project_memory>`,
|
||||
);
|
||||
}
|
||||
if (memory.extension?.trim()) {
|
||||
@@ -702,16 +697,9 @@ function toolUsageInteractive(
|
||||
function toolUsageRememberingFacts(
|
||||
options: OperationalGuidelinesOptions,
|
||||
): string {
|
||||
if (options.memoryV2Enabled) {
|
||||
const userProjectBullet = options.userProjectMemoryPath
|
||||
? `
|
||||
- **Private Project Memory** (\`${options.userProjectMemoryPath}\`): Personal-to-the-user, project-specific notes that must **NOT** be committed to the repo. Keep this file concise: it is the private index for this workspace. Store richer detail in sibling \`*.md\` files in the same folder and use \`MEMORY.md\` to point to them.`
|
||||
: '';
|
||||
if (options.memoryManagerEnabled) {
|
||||
return `
|
||||
- **Instruction and Memory Files:** You persist long-lived project context by editing markdown files directly with '${EDIT_TOOL_NAME}' or '${WRITE_FILE_TOOL_NAME}'. There is no \`save_memory\` tool. The current contents of all loaded \`GEMINI.md\` files and the private project \`MEMORY.md\` index are already in your context — do not re-read them before editing.
|
||||
- **Project Instructions** (\`./GEMINI.md\`): Team-shared architecture, conventions, workflows, and other repo guidance. **Committed to the repo and shared with the team.**
|
||||
- **Subdirectory Instructions** (e.g. \`./src/GEMINI.md\`): Scoped instructions for one part of the project. Reference them from \`./GEMINI.md\` so they remain discoverable.${userProjectBullet}
|
||||
Whenever the user tells you to "remember" something or states a durable personal workflow for this codebase, save it in the private project memory folder immediately. Put concise index entries in \`MEMORY.md\`; if more detail is useful, create or update a sibling \`*.md\` note in the same folder and keep \`MEMORY.md\` as the pointer. Only update \`GEMINI.md\` files when the memory is a shared project instruction or convention that belongs in the repo. If it could be either tier, ask the user. Never save transient session state, summaries of code changes, bug fixes, or task-specific findings — these files are loaded into every session and must stay lean.`;
|
||||
- **Memory Tool:** You MUST use the '${AGENT_TOOL_NAME}' tool with the 'save_memory' agent to proactively record facts, preferences, and workflows that apply across all sessions. Whenever the user explicitly tells you to "remember" something, or when they state a preference or workflow (like "always lint after editing"), you MUST immediately call the save_memory subagent. Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is strictly for persistent general knowledge.`;
|
||||
}
|
||||
const base = `
|
||||
- **Remembering Facts:** Use the '${MEMORY_TOOL_NAME}' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases, or a workflow like "always lint after editing"). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information.`;
|
||||
|
||||
@@ -83,25 +83,7 @@ export interface OperationalGuidelinesOptions {
|
||||
interactive: boolean;
|
||||
interactiveShellEnabled: boolean;
|
||||
topicUpdateNarration: boolean;
|
||||
memoryV2Enabled: boolean;
|
||||
/**
|
||||
* Absolute path to the user's per-project private memory index
|
||||
* (e.g. ~/.gemini/tmp/<project-hash>/memory/MEMORY.md). Surfaced to the
|
||||
* model when memoryV2Enabled is true so the prompt-driven memory flow
|
||||
* can route project-specific personal notes there instead of the committed
|
||||
* project GEMINI.md.
|
||||
*/
|
||||
userProjectMemoryPath?: string;
|
||||
/**
|
||||
* Absolute path to the user's global personal memory file
|
||||
* (e.g. ~/.gemini/GEMINI.md). Surfaced to the model when memoryV2Enabled
|
||||
* is true so the prompt-driven memory flow can route cross-project personal
|
||||
* preferences (preferences that follow the user across all workspaces) there
|
||||
* instead of the project-scoped tiers. Config.isPathAllowed surgically
|
||||
* allowlists this exact file (only this file, not the rest of `~/.gemini/`)
|
||||
* so the agent can edit it directly.
|
||||
*/
|
||||
globalMemoryPath?: string;
|
||||
memoryManagerEnabled: boolean;
|
||||
}
|
||||
|
||||
export type SandboxMode = 'macos-seatbelt' | 'generic' | 'outside';
|
||||
@@ -543,7 +525,7 @@ ${trimmed}
|
||||
}
|
||||
if (memory.userProjectMemory?.trim()) {
|
||||
sections.push(
|
||||
`<user_project_memory>\n--- Private Project Memory Index (private, not committed to repo) ---\n${memory.userProjectMemory.trim()}\n--- End Private Project Memory Index ---\n</user_project_memory>`,
|
||||
`<user_project_memory>\n--- User's Project Memory (private, not committed to repo) ---\n${memory.userProjectMemory.trim()}\n--- End User's Project Memory ---\n</user_project_memory>`,
|
||||
);
|
||||
}
|
||||
if (memory.extension?.trim()) {
|
||||
@@ -828,30 +810,9 @@ function toolUsageInteractive(
|
||||
function toolUsageRememberingFacts(
|
||||
options: OperationalGuidelinesOptions,
|
||||
): string {
|
||||
if (options.memoryV2Enabled) {
|
||||
const userProjectBullet = options.userProjectMemoryPath
|
||||
? `
|
||||
- **Private Project Memory** (\`${options.userProjectMemoryPath}\`): Personal-to-the-user, project-specific notes that must **NOT** be committed to the repo. Keep this file concise: it is the private index for this workspace. Store richer detail in sibling \`*.md\` files in the same folder and use \`MEMORY.md\` to point to them.`
|
||||
: '';
|
||||
const globalMemoryBullet = options.globalMemoryPath
|
||||
? `
|
||||
- **Global Personal Memory** (\`${options.globalMemoryPath}\`): Cross-project personal preferences and facts about the user that should follow them into every workspace (e.g. preferred testing framework across all projects, language preferences, coding-style defaults). Loaded automatically in every session. Keep entries concise and durable — never workspace-specific.`
|
||||
: '';
|
||||
const globalRoutingRule = options.globalMemoryPath
|
||||
? `
|
||||
- When the user states a **cross-project personal preference** that should follow them into every workspace ("I always prefer X", "across all my projects", "my personal coding style is Y", "in general I like Z"), update the global personal memory file. Do **not** also write it into a \`GEMINI.md\` file or the private memory folder.`
|
||||
: '';
|
||||
if (options.memoryManagerEnabled) {
|
||||
return `
|
||||
- **Instruction and Memory Files:** You persist long-lived project context by editing markdown files directly with ${formatToolName(EDIT_TOOL_NAME)} or ${formatToolName(WRITE_FILE_TOOL_NAME)}. There is no \`save_memory\` tool. The current contents of all loaded \`GEMINI.md\` files and the private project \`MEMORY.md\` index are already in your context — do not re-read them before editing.
|
||||
- **Project Instructions** (\`./GEMINI.md\`): Team-shared architecture, conventions, workflows, and other repo guidance. **Committed to the repo and shared with the team.**
|
||||
- **Subdirectory Instructions** (e.g. \`./src/GEMINI.md\`): Scoped instructions for one part of the project. Reference them from \`./GEMINI.md\` so they remain discoverable.${userProjectBullet}${globalMemoryBullet}
|
||||
**Routing rules — pick exactly one tier per fact:**
|
||||
- When the user states a **team-shared convention, architecture rule, or repo-wide workflow** ("our project uses X", "the team always Y", "for this repo, always Z"), update the relevant \`GEMINI.md\` file. Do **not** also write it into the private memory folder or the global personal memory file.
|
||||
- When the user states a **personal-to-them local setup, machine-specific note, or private workflow** for this codebase ("on my machine", "my local setup", "do not commit this"), save it under the private project memory folder. Do **not** also write it into a \`GEMINI.md\` file or the global personal memory file.${globalRoutingRule}
|
||||
- If a fact could plausibly belong to more than one tier, **ask the user** which tier they want before writing.
|
||||
**Never duplicate or mirror the same fact across tiers** — each fact lives in exactly one file across all four tiers (project \`GEMINI.md\`, subdirectory \`GEMINI.md\`, private project memory, global personal memory). Do not add cross-references between any of them.
|
||||
**Inside the private memory folder:** \`MEMORY.md\` is the index for its sibling \`*.md\` notes **in that same folder only** — never use it to point at, summarize, or duplicate content from any \`GEMINI.md\` file. For brief facts, write the entry directly into \`MEMORY.md\`. When a note has substantial detail (multiple sections, procedures, or fields), put the detail in a sibling \`*.md\` file in the same folder and add a one-line pointer entry in \`MEMORY.md\`.
|
||||
Never save transient session state, summaries of code changes, bug fixes, or task-specific findings — these files are loaded into every session and must stay lean.`;
|
||||
- **Memory Tool:** You MUST use the '${AGENT_TOOL_NAME}' tool with the 'save_memory' agent to proactively record facts, preferences, and workflows that apply across all sessions. Whenever the user explicitly tells you to "remember" something, or when they state a preference or workflow (like "always lint after editing"), you MUST immediately call the save_memory subagent. Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is strictly for persistent general knowledge.`;
|
||||
}
|
||||
const base = `
|
||||
- **Memory Tool:** Use ${formatToolName(MEMORY_TOOL_NAME)} to persist facts across sessions. It supports two scopes via the \`scope\` parameter:
|
||||
|
||||
@@ -32,9 +32,6 @@ vi.mock('./strategies/overrideStrategy.js');
|
||||
vi.mock('./strategies/approvalModeStrategy.js');
|
||||
vi.mock('./strategies/classifierStrategy.js');
|
||||
vi.mock('./strategies/numericalClassifierStrategy.js');
|
||||
import { BestEffortProStrategy } from './strategies/bestEffortProStrategy.js';
|
||||
|
||||
vi.mock('./strategies/bestEffortProStrategy.js');
|
||||
vi.mock('./strategies/gemmaClassifierStrategy.js');
|
||||
vi.mock('../telemetry/loggers.js');
|
||||
vi.mock('../telemetry/types.js');
|
||||
@@ -77,7 +74,6 @@ describe('ModelRouterService', () => {
|
||||
[
|
||||
new FallbackStrategy(),
|
||||
new OverrideStrategy(),
|
||||
new BestEffortProStrategy(),
|
||||
new ApprovalModeStrategy(),
|
||||
new ClassifierStrategy(),
|
||||
new NumericalClassifierStrategy(),
|
||||
@@ -108,14 +104,13 @@ describe('ModelRouterService', () => {
|
||||
const compositeStrategyArgs = vi.mocked(CompositeStrategy).mock.calls[0];
|
||||
const childStrategies = compositeStrategyArgs[0];
|
||||
|
||||
expect(childStrategies.length).toBe(7);
|
||||
expect(childStrategies.length).toBe(6);
|
||||
expect(childStrategies[0]).toBeInstanceOf(FallbackStrategy);
|
||||
expect(childStrategies[1]).toBeInstanceOf(OverrideStrategy);
|
||||
expect(childStrategies[2]).toBeInstanceOf(BestEffortProStrategy);
|
||||
expect(childStrategies[3]).toBeInstanceOf(ApprovalModeStrategy);
|
||||
expect(childStrategies[4]).toBeInstanceOf(ClassifierStrategy);
|
||||
expect(childStrategies[5]).toBeInstanceOf(NumericalClassifierStrategy);
|
||||
expect(childStrategies[6]).toBeInstanceOf(DefaultStrategy);
|
||||
expect(childStrategies[2]).toBeInstanceOf(ApprovalModeStrategy);
|
||||
expect(childStrategies[3]).toBeInstanceOf(ClassifierStrategy);
|
||||
expect(childStrategies[4]).toBeInstanceOf(NumericalClassifierStrategy);
|
||||
expect(childStrategies[5]).toBeInstanceOf(DefaultStrategy);
|
||||
expect(compositeStrategyArgs[1]).toBe('agent-router');
|
||||
});
|
||||
|
||||
@@ -138,15 +133,14 @@ describe('ModelRouterService', () => {
|
||||
const compositeStrategyArgs = vi.mocked(CompositeStrategy).mock.calls[0];
|
||||
const childStrategies = compositeStrategyArgs[0];
|
||||
|
||||
expect(childStrategies.length).toBe(8);
|
||||
expect(childStrategies.length).toBe(7);
|
||||
expect(childStrategies[0]).toBeInstanceOf(FallbackStrategy);
|
||||
expect(childStrategies[1]).toBeInstanceOf(OverrideStrategy);
|
||||
expect(childStrategies[2]).toBeInstanceOf(BestEffortProStrategy);
|
||||
expect(childStrategies[3]).toBeInstanceOf(ApprovalModeStrategy);
|
||||
expect(childStrategies[4]).toBeInstanceOf(GemmaClassifierStrategy);
|
||||
expect(childStrategies[5]).toBeInstanceOf(ClassifierStrategy);
|
||||
expect(childStrategies[6]).toBeInstanceOf(NumericalClassifierStrategy);
|
||||
expect(childStrategies[7]).toBeInstanceOf(DefaultStrategy);
|
||||
expect(childStrategies[2]).toBeInstanceOf(ApprovalModeStrategy);
|
||||
expect(childStrategies[3]).toBeInstanceOf(GemmaClassifierStrategy);
|
||||
expect(childStrategies[4]).toBeInstanceOf(ClassifierStrategy);
|
||||
expect(childStrategies[5]).toBeInstanceOf(NumericalClassifierStrategy);
|
||||
expect(childStrategies[6]).toBeInstanceOf(DefaultStrategy);
|
||||
expect(compositeStrategyArgs[1]).toBe('agent-router');
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import { NumericalClassifierStrategy } from './strategies/numericalClassifierStr
|
||||
import { CompositeStrategy } from './strategies/compositeStrategy.js';
|
||||
import { FallbackStrategy } from './strategies/fallbackStrategy.js';
|
||||
import { OverrideStrategy } from './strategies/overrideStrategy.js';
|
||||
import { BestEffortProStrategy } from './strategies/bestEffortProStrategy.js';
|
||||
import { ApprovalModeStrategy } from './strategies/approvalModeStrategy.js';
|
||||
|
||||
import { logModelRouting } from '../telemetry/loggers.js';
|
||||
@@ -44,9 +43,6 @@ export class ModelRouterService {
|
||||
strategies.push(new FallbackStrategy());
|
||||
strategies.push(new OverrideStrategy());
|
||||
|
||||
// Best Effort Pro is next.
|
||||
strategies.push(new BestEffortProStrategy());
|
||||
|
||||
// Approval mode is next.
|
||||
strategies.push(new ApprovalModeStrategy());
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '../../config/config.js';
|
||||
import { isAutoModel, resolveModel } from '../../config/models.js';
|
||||
import type {
|
||||
RoutingStrategy,
|
||||
RoutingDecision,
|
||||
RoutingContext,
|
||||
} from '../routingStrategy.js';
|
||||
|
||||
/**
|
||||
* A routing strategy that respects the "Best Effort Pro" setting.
|
||||
* If the setting is enabled and the Pro model is available, it routes to Pro
|
||||
* regardless of complexity. If Pro is unavailable, it routes to Flash.
|
||||
*/
|
||||
export class BestEffortProStrategy implements RoutingStrategy {
|
||||
name = 'best-effort-pro';
|
||||
|
||||
async route(
|
||||
context: RoutingContext,
|
||||
config: Config,
|
||||
): Promise<RoutingDecision | null> {
|
||||
const requestedModel = config.getModel();
|
||||
if (!isAutoModel(requestedModel)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isBestEffortProEnabled = await config.getBestEffortProEnabled();
|
||||
if (!isBestEffortProEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const useGemini3_1 = (await config.getGemini31Launched?.()) ?? false;
|
||||
const useGemini3_1FlashLite =
|
||||
(await config.getGemini31FlashLiteLaunched?.()) ?? false;
|
||||
const hasAccessToPreview = config.getHasAccessToPreviewModel?.() ?? true;
|
||||
|
||||
const availabilityService = config.getModelAvailabilityService();
|
||||
const proModel = resolveModel(
|
||||
'gemini-3.1-pro',
|
||||
useGemini3_1,
|
||||
useGemini3_1FlashLite,
|
||||
false,
|
||||
hasAccessToPreview,
|
||||
config,
|
||||
);
|
||||
const flashModel = resolveModel(
|
||||
'gemini-3.1-flash',
|
||||
useGemini3_1,
|
||||
useGemini3_1FlashLite,
|
||||
false,
|
||||
hasAccessToPreview,
|
||||
config,
|
||||
);
|
||||
|
||||
const proSnapshot = availabilityService.snapshot(proModel);
|
||||
|
||||
if (proSnapshot.available) {
|
||||
return {
|
||||
model: proModel,
|
||||
metadata: {
|
||||
source: this.name,
|
||||
latencyMs: 0,
|
||||
reasoning:
|
||||
'Best Effort Pro is enabled and the Pro model is available.',
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
model: flashModel,
|
||||
metadata: {
|
||||
source: this.name,
|
||||
latencyMs: 0,
|
||||
reasoning: `Best Effort Pro is enabled, but Pro is unavailable (${proSnapshot.reason}). Falling back to Flash.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,6 @@ export async function loadConversationRecord(
|
||||
): Promise<
|
||||
| (ConversationRecord & {
|
||||
messageCount?: number;
|
||||
userMessageCount?: number;
|
||||
firstUserMessage?: string;
|
||||
hasUserOrAssistantMessage?: boolean;
|
||||
})
|
||||
@@ -129,11 +128,8 @@ export async function loadConversationRecord(
|
||||
let metadata: Partial<ConversationRecord> = {};
|
||||
const messagesMap = new Map<string, MessageRecord>();
|
||||
const messageIds: string[] = [];
|
||||
const messageKinds = new Map<
|
||||
string,
|
||||
{ isUser: boolean; isUserOrAssistant: boolean }
|
||||
>();
|
||||
let firstUserMessageStr: string | undefined;
|
||||
let hasUserOrAssistant = false;
|
||||
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
@@ -144,14 +140,13 @@ export async function loadConversationRecord(
|
||||
if (options?.metadataOnly) {
|
||||
const idx = messageIds.indexOf(rewindId);
|
||||
if (idx !== -1) {
|
||||
const removedIds = messageIds.splice(idx);
|
||||
for (const removedId of removedIds) {
|
||||
messageKinds.delete(removedId);
|
||||
}
|
||||
messageIds.splice(idx);
|
||||
} else {
|
||||
messageIds.length = 0;
|
||||
messageKinds.clear();
|
||||
}
|
||||
// For metadataOnly we can't perfectly un-track hasUserOrAssistant if it was rewinded,
|
||||
// but we can assume false if messageIds is empty.
|
||||
if (messageIds.length === 0) hasUserOrAssistant = false;
|
||||
} else {
|
||||
let found = false;
|
||||
const idsToDelete: string[] = [];
|
||||
@@ -169,18 +164,20 @@ export async function loadConversationRecord(
|
||||
}
|
||||
} else if (isMessageRecord(record)) {
|
||||
const id = record.id;
|
||||
const isUser = hasProperty(record, 'type') && record.type === 'user';
|
||||
const isUserOrAssistant =
|
||||
if (
|
||||
hasProperty(record, 'type') &&
|
||||
(record.type === 'user' || record.type === 'gemini');
|
||||
(record.type === 'user' || record.type === 'gemini')
|
||||
) {
|
||||
hasUserOrAssistant = true;
|
||||
}
|
||||
// Track message count and first user message
|
||||
if (options?.metadataOnly) {
|
||||
messageIds.push(id);
|
||||
messageKinds.set(id, { isUser, isUserOrAssistant });
|
||||
}
|
||||
if (
|
||||
!firstUserMessageStr &&
|
||||
isUser &&
|
||||
hasProperty(record, 'type') &&
|
||||
record['type'] === 'user' &&
|
||||
hasProperty(record, 'content') &&
|
||||
record['content']
|
||||
) {
|
||||
@@ -224,33 +221,6 @@ export async function loadConversationRecord(
|
||||
return await parseLegacyRecordFallback(filePath, options);
|
||||
}
|
||||
|
||||
const metadataMessages = Array.isArray(metadata.messages)
|
||||
? metadata.messages
|
||||
: [];
|
||||
const loadedMessages =
|
||||
metadataMessages.length > 0
|
||||
? metadataMessages
|
||||
: Array.from(messagesMap.values());
|
||||
const metadataFirstUserMessage =
|
||||
metadataMessages.find((message) => message.type === 'user') ?? null;
|
||||
let fallbackFirstUserMessage = firstUserMessageStr;
|
||||
if (!fallbackFirstUserMessage && metadataFirstUserMessage) {
|
||||
const rawContent = metadataFirstUserMessage.content;
|
||||
if (Array.isArray(rawContent)) {
|
||||
fallbackFirstUserMessage = rawContent
|
||||
.map((part: unknown) => (isTextPart(part) ? part['text'] : ''))
|
||||
.join('');
|
||||
} else if (typeof rawContent === 'string') {
|
||||
fallbackFirstUserMessage = rawContent;
|
||||
}
|
||||
}
|
||||
const userMessageCount = options?.metadataOnly
|
||||
? Array.from(messageKinds.values()).filter((m) => m.isUser).length
|
||||
: loadedMessages.filter((m) => m.type === 'user').length;
|
||||
const hasUserOrAssistant = options?.metadataOnly
|
||||
? Array.from(messageKinds.values()).some((m) => m.isUserOrAssistant)
|
||||
: loadedMessages.some((m) => m.type === 'user' || m.type === 'gemini');
|
||||
|
||||
return {
|
||||
sessionId: metadata.sessionId,
|
||||
projectHash: metadata.projectHash,
|
||||
@@ -259,21 +229,16 @@ export async function loadConversationRecord(
|
||||
summary: metadata.summary,
|
||||
directories: metadata.directories,
|
||||
kind: metadata.kind,
|
||||
messages: options?.metadataOnly ? [] : loadedMessages,
|
||||
messages: Array.from(messagesMap.values()),
|
||||
messageCount: options?.metadataOnly
|
||||
? metadataMessages.length || messageIds.length
|
||||
: loadedMessages.length,
|
||||
userMessageCount:
|
||||
options?.metadataOnly && metadataMessages.length > 0
|
||||
? metadataMessages.filter((m) => m.type === 'user').length
|
||||
: userMessageCount,
|
||||
firstUserMessage: fallbackFirstUserMessage,
|
||||
hasUserOrAssistantMessage:
|
||||
options?.metadataOnly && metadataMessages.length > 0
|
||||
? metadataMessages.some(
|
||||
(m) => m.type === 'user' || m.type === 'gemini',
|
||||
)
|
||||
: hasUserOrAssistant,
|
||||
? messageIds.length
|
||||
: messagesMap.size,
|
||||
firstUserMessage: firstUserMessageStr,
|
||||
hasUserOrAssistantMessage: options?.metadataOnly
|
||||
? hasUserOrAssistant
|
||||
: Array.from(messagesMap.values()).some(
|
||||
(m) => m.type === 'user' || m.type === 'gemini',
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
debugLogger.error('Error loading conversation record from JSONL:', error);
|
||||
@@ -851,7 +816,6 @@ async function parseLegacyRecordFallback(
|
||||
): Promise<
|
||||
| (ConversationRecord & {
|
||||
messageCount?: number;
|
||||
userMessageCount?: number;
|
||||
firstUserMessage?: string;
|
||||
hasUserOrAssistantMessage?: boolean;
|
||||
})
|
||||
@@ -885,8 +849,6 @@ async function parseLegacyRecordFallback(
|
||||
...legacyRecord,
|
||||
messages: [],
|
||||
messageCount: legacyRecord.messages?.length || 0,
|
||||
userMessageCount:
|
||||
legacyRecord.messages?.filter((m) => m.type === 'user').length || 0,
|
||||
firstUserMessage: fallbackFirstUserMessageStr,
|
||||
hasUserOrAssistantMessage:
|
||||
legacyRecord.messages?.some(
|
||||
@@ -896,8 +858,6 @@ async function parseLegacyRecordFallback(
|
||||
}
|
||||
return {
|
||||
...legacyRecord,
|
||||
userMessageCount:
|
||||
legacyRecord.messages?.filter((m) => m.type === 'user').length || 0,
|
||||
hasUserOrAssistantMessage:
|
||||
legacyRecord.messages?.some(
|
||||
(m) => m.type === 'user' || m.type === 'gemini',
|
||||
|
||||
@@ -117,35 +117,6 @@ function createConversation(
|
||||
};
|
||||
}
|
||||
|
||||
async function writeConversationJsonl(
|
||||
filePath: string,
|
||||
conversation: ConversationRecord,
|
||||
): Promise<void> {
|
||||
const metadata = {
|
||||
sessionId: conversation.sessionId,
|
||||
projectHash: conversation.projectHash,
|
||||
startTime: conversation.startTime,
|
||||
lastUpdated: conversation.lastUpdated,
|
||||
summary: conversation.summary,
|
||||
directories: conversation.directories,
|
||||
kind: conversation.kind,
|
||||
};
|
||||
|
||||
const records = [metadata, ...conversation.messages];
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
records.map((record) => JSON.stringify(record)).join('\n') + '\n',
|
||||
);
|
||||
}
|
||||
|
||||
async function setSessionMtime(
|
||||
filePath: string,
|
||||
timestamp: string,
|
||||
): Promise<void> {
|
||||
const date = new Date(timestamp);
|
||||
await fs.utimes(filePath, date, date);
|
||||
}
|
||||
|
||||
describe('memoryService', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
@@ -564,150 +535,6 @@ describe('memoryService', () => {
|
||||
expect.stringContaining('/memory inbox'),
|
||||
);
|
||||
});
|
||||
|
||||
it('records only sessions whose read_file calls succeed as processed', async () => {
|
||||
const { startMemoryService, readExtractionState } = await import(
|
||||
'./memoryService.js'
|
||||
);
|
||||
const { LocalAgentExecutor } = await import(
|
||||
'../agents/local-executor.js'
|
||||
);
|
||||
|
||||
vi.mocked(LocalAgentExecutor.create).mockReset();
|
||||
|
||||
const memoryDir = path.join(tmpDir, 'memory-read-tracking');
|
||||
const skillsDir = path.join(tmpDir, 'skills-read-tracking');
|
||||
const projectTempDir = path.join(tmpDir, 'temp-read-tracking');
|
||||
const chatsDir = path.join(projectTempDir, 'chats');
|
||||
await fs.mkdir(memoryDir, { recursive: true });
|
||||
await fs.mkdir(skillsDir, { recursive: true });
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
|
||||
const openedConversation = createConversation({
|
||||
sessionId: 'opened-session',
|
||||
summary: 'Read this one',
|
||||
messageCount: 20,
|
||||
lastUpdated: '2025-01-02T01:00:00Z',
|
||||
});
|
||||
const skippedConversation = createConversation({
|
||||
sessionId: 'skipped-session',
|
||||
summary: 'Do not read this one',
|
||||
messageCount: 20,
|
||||
lastUpdated: '2025-01-01T01:00:00Z',
|
||||
});
|
||||
|
||||
const openedPath = path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2025-01-02T00-00-opened.jsonl`,
|
||||
);
|
||||
const skippedPath = path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2025-01-01T00-00-skipped.jsonl`,
|
||||
);
|
||||
await writeConversationJsonl(openedPath, openedConversation);
|
||||
await writeConversationJsonl(skippedPath, skippedConversation);
|
||||
|
||||
vi.mocked(LocalAgentExecutor.create).mockImplementationOnce(
|
||||
async (_definition, _context, onActivity) =>
|
||||
({
|
||||
run: vi.fn().mockImplementation(async () => {
|
||||
onActivity?.({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'Skill Extractor',
|
||||
type: 'TOOL_CALL_START',
|
||||
data: {
|
||||
name: 'read_file',
|
||||
args: { file_path: openedPath },
|
||||
callId: 'call-opened',
|
||||
},
|
||||
});
|
||||
onActivity?.({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'Skill Extractor',
|
||||
type: 'TOOL_CALL_START',
|
||||
data: {
|
||||
name: 'read_file',
|
||||
args: { file_path: skippedPath },
|
||||
callId: 'call-skipped',
|
||||
},
|
||||
});
|
||||
onActivity?.({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'Skill Extractor',
|
||||
type: 'ERROR',
|
||||
data: {
|
||||
name: 'read_file',
|
||||
callId: 'call-skipped',
|
||||
error: 'access denied',
|
||||
},
|
||||
});
|
||||
onActivity?.({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'Skill Extractor',
|
||||
type: 'TOOL_CALL_END',
|
||||
data: {
|
||||
name: 'read_file',
|
||||
id: 'call-opened',
|
||||
data: { content: 'Read this one' },
|
||||
},
|
||||
});
|
||||
onActivity?.({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'Skill Extractor',
|
||||
type: 'TOOL_CALL_START',
|
||||
data: {
|
||||
name: 'read_file',
|
||||
args: { file_path: path.join(chatsDir, 'unrelated.jsonl') },
|
||||
callId: 'call-unrelated',
|
||||
},
|
||||
});
|
||||
return undefined;
|
||||
}),
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const mockConfig = {
|
||||
storage: {
|
||||
getProjectMemoryDir: vi.fn().mockReturnValue(memoryDir),
|
||||
getProjectMemoryTempDir: vi.fn().mockReturnValue(memoryDir),
|
||||
getProjectSkillsMemoryDir: vi.fn().mockReturnValue(skillsDir),
|
||||
getProjectTempDir: vi.fn().mockReturnValue(projectTempDir),
|
||||
},
|
||||
getToolRegistry: vi.fn(),
|
||||
getMessageBus: vi.fn(),
|
||||
getGeminiClient: vi.fn(),
|
||||
getSkillManager: vi.fn().mockReturnValue({ getSkills: () => [] }),
|
||||
modelConfigService: {
|
||||
registerRuntimeModelConfig: vi.fn(),
|
||||
},
|
||||
getTargetDir: vi.fn().mockReturnValue(tmpDir),
|
||||
sandboxManager: undefined,
|
||||
} as unknown as Parameters<typeof startMemoryService>[0];
|
||||
|
||||
await startMemoryService(mockConfig);
|
||||
|
||||
const state = await readExtractionState(
|
||||
path.join(memoryDir, '.extraction-state.json'),
|
||||
);
|
||||
expect(state.runs).toHaveLength(1);
|
||||
expect(state.runs[0].candidateSessions).toEqual([
|
||||
{
|
||||
sessionId: 'opened-session',
|
||||
lastUpdated: '2025-01-02T01:00:00Z',
|
||||
},
|
||||
{
|
||||
sessionId: 'skipped-session',
|
||||
lastUpdated: '2025-01-01T01:00:00Z',
|
||||
},
|
||||
]);
|
||||
expect(state.runs[0].processedSessions).toEqual([
|
||||
{
|
||||
sessionId: 'opened-session',
|
||||
lastUpdated: '2025-01-02T01:00:00Z',
|
||||
},
|
||||
]);
|
||||
expect(state.runs[0].sessionIds).toEqual(['opened-session']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProcessedSessionIds', () => {
|
||||
@@ -836,7 +663,7 @@ describe('memoryService', () => {
|
||||
const state: ExtractionState = {
|
||||
runs: [
|
||||
{
|
||||
runAt: '2025-01-01T02:00:00Z',
|
||||
runAt: '2025-01-01T00:00:00Z',
|
||||
sessionIds: ['old-session'],
|
||||
skillsCreated: [],
|
||||
},
|
||||
@@ -849,39 +676,6 @@ describe('memoryService', () => {
|
||||
expect(result.sessionIndex).not.toContain('[NEW]');
|
||||
});
|
||||
|
||||
it('treats resumed legacy sessions as [NEW] when lastUpdated moved past the old run', async () => {
|
||||
const { buildSessionIndex } = await import('./memoryService.js');
|
||||
|
||||
const conversation = createConversation({
|
||||
sessionId: 'resumed-session',
|
||||
summary: 'Resumed after extraction',
|
||||
messageCount: 20,
|
||||
lastUpdated: '2025-01-01T03:00:00Z',
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2025-01-01T00-00-resumed01.json`,
|
||||
),
|
||||
JSON.stringify(conversation),
|
||||
);
|
||||
|
||||
const state: ExtractionState = {
|
||||
runs: [
|
||||
{
|
||||
runAt: '2025-01-01T02:00:00Z',
|
||||
sessionIds: ['resumed-session'],
|
||||
skillsCreated: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await buildSessionIndex(chatsDir, state);
|
||||
|
||||
expect(result.sessionIndex).toContain('[NEW]');
|
||||
expect(result.newSessionIds).toEqual(['resumed-session']);
|
||||
});
|
||||
|
||||
it('includes file path and summary in each line', async () => {
|
||||
const { buildSessionIndex } = await import('./memoryService.js');
|
||||
|
||||
@@ -1006,7 +800,7 @@ describe('memoryService', () => {
|
||||
const state: ExtractionState = {
|
||||
runs: [
|
||||
{
|
||||
runAt: '2025-01-01T02:00:00Z',
|
||||
runAt: '2025-01-01T00:00:00Z',
|
||||
sessionIds: ['processed-one'],
|
||||
skillsCreated: [],
|
||||
},
|
||||
@@ -1021,136 +815,6 @@ describe('memoryService', () => {
|
||||
expect(result.sessionIndex).toContain('[NEW]');
|
||||
expect(result.sessionIndex).toContain('[old]');
|
||||
});
|
||||
|
||||
it('reads JSONL sessions and sorts by actual lastUpdated instead of filename', async () => {
|
||||
const { buildSessionIndex } = await import('./memoryService.js');
|
||||
|
||||
const olderByName = createConversation({
|
||||
sessionId: 'older-by-name',
|
||||
summary: 'Filename looks newer',
|
||||
messageCount: 20,
|
||||
lastUpdated: '2025-01-01T01:00:00Z',
|
||||
});
|
||||
const newerByActivity = createConversation({
|
||||
sessionId: 'newer-by-activity',
|
||||
summary: 'Actually most recent',
|
||||
messageCount: 20,
|
||||
lastUpdated: '2025-02-01T01:00:00Z',
|
||||
});
|
||||
|
||||
await writeConversationJsonl(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2025-02-01T00-00-oldername.jsonl`,
|
||||
),
|
||||
olderByName,
|
||||
);
|
||||
await writeConversationJsonl(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2025-01-01T00-00-neweractv.jsonl`,
|
||||
),
|
||||
newerByActivity,
|
||||
);
|
||||
|
||||
const result = await buildSessionIndex(chatsDir, { runs: [] });
|
||||
const firstLine = result.sessionIndex.split('\n')[0];
|
||||
|
||||
expect(firstLine).toContain('Actually most recent');
|
||||
expect(firstLine).not.toContain('Filename looks newer');
|
||||
});
|
||||
|
||||
it('rotates in older unprocessed sessions instead of starving them behind retried recent ones', async () => {
|
||||
const { buildSessionIndex } = await import('./memoryService.js');
|
||||
|
||||
for (let i = 0; i < 11; i++) {
|
||||
const day = String(11 - i).padStart(2, '0');
|
||||
const conversation = createConversation({
|
||||
sessionId: `backlog-${i}`,
|
||||
summary: `Backlog ${i}`,
|
||||
messageCount: 20,
|
||||
lastUpdated: `2025-01-${day}T01:00:00Z`,
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2025-01-${day}T00-00-backlog${i}.json`,
|
||||
),
|
||||
JSON.stringify(conversation),
|
||||
);
|
||||
}
|
||||
|
||||
const state: ExtractionState = {
|
||||
runs: [
|
||||
{
|
||||
runAt: '2025-02-01T00:00:00Z',
|
||||
sessionIds: [],
|
||||
candidateSessions: Array.from({ length: 10 }, (_, i) => ({
|
||||
sessionId: `backlog-${i}`,
|
||||
lastUpdated: `2025-01-${String(11 - i).padStart(2, '0')}T01:00:00Z`,
|
||||
})),
|
||||
skillsCreated: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await buildSessionIndex(chatsDir, state);
|
||||
|
||||
expect(result.newSessionIds).toContain('backlog-10');
|
||||
expect(result.newSessionIds).not.toContain('backlog-9');
|
||||
});
|
||||
|
||||
it('surfaces older unprocessed sessions even when the newest 100 files were already processed', async () => {
|
||||
const { buildSessionIndex } = await import('./memoryService.js');
|
||||
|
||||
const processedSessions: ExtractionRun['processedSessions'] = [];
|
||||
|
||||
for (let i = 0; i < 105; i++) {
|
||||
const timestamp = new Date(
|
||||
Date.UTC(2025, 0, 1, 0, 0, 105 - i),
|
||||
).toISOString();
|
||||
const conversation = createConversation({
|
||||
sessionId: `backlog-${i}`,
|
||||
summary: `Backlog ${i}`,
|
||||
messageCount: 20,
|
||||
lastUpdated: timestamp,
|
||||
});
|
||||
const filePath = path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2025-01-01T00-00-backlog${String(i).padStart(3, '0')}.json`,
|
||||
);
|
||||
await fs.writeFile(filePath, JSON.stringify(conversation));
|
||||
await setSessionMtime(filePath, timestamp);
|
||||
|
||||
if (i < 100) {
|
||||
processedSessions.push({
|
||||
sessionId: conversation.sessionId,
|
||||
lastUpdated: conversation.lastUpdated,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await buildSessionIndex(chatsDir, {
|
||||
runs: [
|
||||
{
|
||||
runAt: '2025-02-01T00:00:00Z',
|
||||
sessionIds: processedSessions.map((session) => session.sessionId),
|
||||
processedSessions,
|
||||
skillsCreated: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.newSessionIds).toEqual([
|
||||
'backlog-100',
|
||||
'backlog-101',
|
||||
'backlog-102',
|
||||
'backlog-103',
|
||||
'backlog-104',
|
||||
]);
|
||||
expect(result.sessionIndex).toContain('Backlog 100');
|
||||
expect(result.sessionIndex).toContain('Backlog 104');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExtractionState runs tracking', () => {
|
||||
@@ -1163,18 +827,6 @@ describe('memoryService', () => {
|
||||
{
|
||||
runAt: '2025-06-01T00:00:00Z',
|
||||
sessionIds: ['s1'],
|
||||
candidateSessions: [
|
||||
{
|
||||
sessionId: 's1',
|
||||
lastUpdated: '2025-05-31T12:00:00Z',
|
||||
},
|
||||
],
|
||||
processedSessions: [
|
||||
{
|
||||
sessionId: 's1',
|
||||
lastUpdated: '2025-05-31T12:00:00Z',
|
||||
},
|
||||
],
|
||||
skillsCreated: ['debug-helper', 'test-gen'],
|
||||
},
|
||||
],
|
||||
@@ -1188,18 +840,6 @@ describe('memoryService', () => {
|
||||
'debug-helper',
|
||||
'test-gen',
|
||||
]);
|
||||
expect(result.runs[0].candidateSessions).toEqual([
|
||||
{
|
||||
sessionId: 's1',
|
||||
lastUpdated: '2025-05-31T12:00:00Z',
|
||||
},
|
||||
]);
|
||||
expect(result.runs[0].processedSessions).toEqual([
|
||||
{
|
||||
sessionId: 's1',
|
||||
lastUpdated: '2025-05-31T12:00:00Z',
|
||||
},
|
||||
]);
|
||||
expect(result.runs[0].sessionIds).toEqual(['s1']);
|
||||
expect(result.runs[0].runAt).toBe('2025-06-01T00:00:00Z');
|
||||
});
|
||||
@@ -1214,26 +854,6 @@ describe('memoryService', () => {
|
||||
{
|
||||
runAt: '2025-01-01T00:00:00Z',
|
||||
sessionIds: ['a', 'b'],
|
||||
candidateSessions: [
|
||||
{
|
||||
sessionId: 'a',
|
||||
lastUpdated: '2024-12-31T23:00:00Z',
|
||||
},
|
||||
{
|
||||
sessionId: 'b',
|
||||
lastUpdated: '2024-12-31T22:00:00Z',
|
||||
},
|
||||
],
|
||||
processedSessions: [
|
||||
{
|
||||
sessionId: 'a',
|
||||
lastUpdated: '2024-12-31T23:00:00Z',
|
||||
},
|
||||
{
|
||||
sessionId: 'b',
|
||||
lastUpdated: '2024-12-31T22:00:00Z',
|
||||
},
|
||||
],
|
||||
skillsCreated: ['skill-x'],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -12,7 +12,6 @@ import * as Diff from 'diff';
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
SESSION_FILE_PREFIX,
|
||||
loadConversationRecord,
|
||||
type ConversationRecord,
|
||||
} from './chatRecordingService.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
@@ -22,7 +21,6 @@ import { FRONTMATTER_REGEX, parseFrontmatter } from '../skills/skillLoader.js';
|
||||
import { LocalAgentExecutor } from '../agents/local-executor.js';
|
||||
import { SkillExtractionAgent } from '../agents/skill-extraction-agent.js';
|
||||
import { getModelConfigAlias } from '../agents/registry.js';
|
||||
import type { SubagentActivityEvent } from '../agents/types.js';
|
||||
import { ExecutionLifecycleService } from './executionLifecycleService.js';
|
||||
import { PromptRegistry } from '../prompts/prompt-registry.js';
|
||||
import { ResourceRegistry } from '../resources/resource-registry.js';
|
||||
@@ -31,7 +29,6 @@ import { PolicyDecision } from '../policy/types.js';
|
||||
import { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { READ_FILE_TOOL_NAME } from '../tools/tool-names.js';
|
||||
import {
|
||||
applyParsedSkillPatches,
|
||||
hasParsedPatchHunks,
|
||||
@@ -43,7 +40,6 @@ const LOCK_STALE_MS = 35 * 60 * 1000; // 35 minutes (exceeds agent's 30-min time
|
||||
const MIN_USER_MESSAGES = 10;
|
||||
const MIN_IDLE_MS = 3 * 60 * 60 * 1000; // 3 hours
|
||||
const MAX_SESSION_INDEX_SIZE = 50;
|
||||
const MAX_NEW_SESSION_BATCH_SIZE = 10;
|
||||
|
||||
/**
|
||||
* Lock file content for coordinating across CLI instances.
|
||||
@@ -53,39 +49,12 @@ interface LockInfo {
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
function hasProperty<T extends string>(
|
||||
obj: unknown,
|
||||
prop: T,
|
||||
): obj is { [key in T]: unknown } {
|
||||
return obj !== null && typeof obj === 'object' && prop in obj;
|
||||
}
|
||||
|
||||
function isStringProperty<T extends string>(
|
||||
obj: unknown,
|
||||
prop: T,
|
||||
): obj is { [key in T]: string } {
|
||||
return hasProperty(obj, prop) && typeof obj[prop] === 'string';
|
||||
}
|
||||
|
||||
interface SessionVersion {
|
||||
sessionId: string;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
interface IndexedSession extends SessionVersion {
|
||||
filePath: string;
|
||||
summary?: string;
|
||||
userMessageCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for a single extraction run.
|
||||
*/
|
||||
export interface ExtractionRun {
|
||||
runAt: string;
|
||||
sessionIds: string[];
|
||||
candidateSessions?: SessionVersion[];
|
||||
processedSessions?: SessionVersion[];
|
||||
skillsCreated: string[];
|
||||
}
|
||||
|
||||
@@ -102,10 +71,7 @@ export interface ExtractionState {
|
||||
export function getProcessedSessionIds(state: ExtractionState): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const run of state.runs) {
|
||||
const processedSessionIds =
|
||||
run.processedSessions?.map((session) => session.sessionId) ??
|
||||
run.sessionIds;
|
||||
for (const id of processedSessionIds) {
|
||||
for (const id of run.sessionIds) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
@@ -123,49 +89,30 @@ function isLockInfo(value: unknown): value is LockInfo {
|
||||
);
|
||||
}
|
||||
|
||||
function isSessionVersion(value: unknown): value is SessionVersion {
|
||||
function isConversationRecord(value: unknown): value is ConversationRecord {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'sessionId' in value &&
|
||||
typeof value.sessionId === 'string' &&
|
||||
'lastUpdated' in value &&
|
||||
typeof value.lastUpdated === 'string'
|
||||
'messages' in value &&
|
||||
Array.isArray(value.messages) &&
|
||||
'projectHash' in value &&
|
||||
'startTime' in value &&
|
||||
'lastUpdated' in value
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSessionVersions(value: unknown): SessionVersion[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.filter(isSessionVersion).map((session) => ({
|
||||
sessionId: session.sessionId,
|
||||
lastUpdated: session.lastUpdated,
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.filter((item): item is string => typeof item === 'string');
|
||||
}
|
||||
|
||||
function isExtractionRunLike(value: unknown): value is {
|
||||
runAt: string;
|
||||
sessionIds?: unknown;
|
||||
candidateSessions?: unknown;
|
||||
processedSessions?: unknown;
|
||||
skillsCreated: unknown;
|
||||
} {
|
||||
function isExtractionRun(value: unknown): value is ExtractionRun {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'runAt' in value &&
|
||||
typeof value.runAt === 'string' &&
|
||||
'skillsCreated' in value
|
||||
'sessionIds' in value &&
|
||||
Array.isArray(value.sessionIds) &&
|
||||
'skillsCreated' in value &&
|
||||
Array.isArray(value.skillsCreated)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,208 +125,6 @@ function isExtractionState(value: unknown): value is { runs: unknown[] } {
|
||||
);
|
||||
}
|
||||
|
||||
function buildExtractionRun(value: unknown): ExtractionRun | null {
|
||||
if (!isExtractionRunLike(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidateSessions = normalizeSessionVersions(value.candidateSessions);
|
||||
const processedSessions = normalizeSessionVersions(value.processedSessions);
|
||||
const sessionIds = normalizeStringArray(value.sessionIds);
|
||||
|
||||
return {
|
||||
runAt: value.runAt,
|
||||
sessionIds:
|
||||
sessionIds.length > 0
|
||||
? sessionIds
|
||||
: processedSessions.map((session) => session.sessionId),
|
||||
candidateSessions:
|
||||
candidateSessions.length > 0 ? candidateSessions : undefined,
|
||||
processedSessions:
|
||||
processedSessions.length > 0 ? processedSessions : undefined,
|
||||
skillsCreated: normalizeStringArray(value.skillsCreated),
|
||||
};
|
||||
}
|
||||
|
||||
function getTimestampMs(timestamp: string): number {
|
||||
const parsed = Date.parse(timestamp);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
function getSessionVersionKey(session: SessionVersion): string {
|
||||
return `${session.sessionId}\u0000${session.lastUpdated}`;
|
||||
}
|
||||
|
||||
function hasLegacyRunProcessedSession(
|
||||
run: ExtractionRun,
|
||||
session: SessionVersion,
|
||||
): boolean {
|
||||
return (
|
||||
run.sessionIds.includes(session.sessionId) &&
|
||||
getTimestampMs(run.runAt) >= getTimestampMs(session.lastUpdated)
|
||||
);
|
||||
}
|
||||
|
||||
function isSessionVersionProcessed(
|
||||
state: ExtractionState,
|
||||
session: SessionVersion,
|
||||
): boolean {
|
||||
const sessionKey = getSessionVersionKey(session);
|
||||
|
||||
for (const run of state.runs) {
|
||||
if (
|
||||
run.processedSessions?.some(
|
||||
(processed) => getSessionVersionKey(processed) === sessionKey,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!run.processedSessions && hasLegacyRunProcessedSession(run, session)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getSessionAttemptCount(
|
||||
state: ExtractionState,
|
||||
session: SessionVersion,
|
||||
): number {
|
||||
const sessionKey = getSessionVersionKey(session);
|
||||
let attempts = 0;
|
||||
|
||||
for (const run of state.runs) {
|
||||
if (run.candidateSessions) {
|
||||
if (
|
||||
run.candidateSessions.some(
|
||||
(candidate) => getSessionVersionKey(candidate) === sessionKey,
|
||||
)
|
||||
) {
|
||||
attempts++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasLegacyRunProcessedSession(run, session)) {
|
||||
attempts++;
|
||||
}
|
||||
}
|
||||
|
||||
return attempts;
|
||||
}
|
||||
|
||||
function compareIndexedSessions(a: IndexedSession, b: IndexedSession): number {
|
||||
const timestampDelta =
|
||||
getTimestampMs(b.lastUpdated) - getTimestampMs(a.lastUpdated);
|
||||
if (timestampDelta !== 0) {
|
||||
return timestampDelta;
|
||||
}
|
||||
|
||||
if (a.filePath.endsWith('.jsonl') !== b.filePath.endsWith('.jsonl')) {
|
||||
return a.filePath.endsWith('.jsonl') ? -1 : 1;
|
||||
}
|
||||
|
||||
return b.filePath.localeCompare(a.filePath);
|
||||
}
|
||||
|
||||
function shouldReplaceIndexedSession(
|
||||
existing: IndexedSession,
|
||||
candidate: IndexedSession,
|
||||
): boolean {
|
||||
return compareIndexedSessions(candidate, existing) < 0;
|
||||
}
|
||||
|
||||
function isReadFileStartActivity(
|
||||
activity: SubagentActivityEvent,
|
||||
): activity is SubagentActivityEvent & {
|
||||
data: { name: string; args?: { file_path?: unknown }; callId?: unknown };
|
||||
} {
|
||||
return (
|
||||
activity.type === 'TOOL_CALL_START' &&
|
||||
activity.data['name'] === READ_FILE_TOOL_NAME
|
||||
);
|
||||
}
|
||||
|
||||
function getResolvedReadFilePath(
|
||||
config: Config,
|
||||
activity: SubagentActivityEvent,
|
||||
): string | null {
|
||||
if (!isReadFileStartActivity(activity)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const args = activity.data.args;
|
||||
if (
|
||||
typeof args !== 'object' ||
|
||||
args === null ||
|
||||
!('file_path' in args) ||
|
||||
typeof args.file_path !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.resolve(config.getTargetDir(), args.file_path);
|
||||
}
|
||||
|
||||
function getReadFileStartCallId(
|
||||
activity: SubagentActivityEvent,
|
||||
): string | null {
|
||||
if (
|
||||
!isReadFileStartActivity(activity) ||
|
||||
!isStringProperty(activity.data, 'callId')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return activity.data.callId;
|
||||
}
|
||||
|
||||
function getCompletedReadFileCallId(
|
||||
activity: SubagentActivityEvent,
|
||||
): string | null {
|
||||
if (
|
||||
activity.type !== 'TOOL_CALL_END' ||
|
||||
activity.data['name'] !== READ_FILE_TOOL_NAME ||
|
||||
!isStringProperty(activity.data, 'id')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return activity.data['id'];
|
||||
}
|
||||
|
||||
function getFailedReadFileCallId(
|
||||
activity: SubagentActivityEvent,
|
||||
): string | null {
|
||||
if (
|
||||
activity.type !== 'ERROR' ||
|
||||
activity.data['name'] !== READ_FILE_TOOL_NAME ||
|
||||
!isStringProperty(activity.data, 'callId')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return activity.data['callId'];
|
||||
}
|
||||
|
||||
function getUserMessageCount(
|
||||
conversation: ConversationRecord & { userMessageCount?: number },
|
||||
): number {
|
||||
return (
|
||||
conversation.userMessageCount ??
|
||||
conversation.messages.filter((message) => message.type === 'user').length
|
||||
);
|
||||
}
|
||||
|
||||
function isSupportedSessionFile(fileName: string): boolean {
|
||||
return (
|
||||
fileName.startsWith(SESSION_FILE_PREFIX) &&
|
||||
(fileName.endsWith('.json') || fileName.endsWith('.jsonl'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to acquire an exclusive lock file using O_CREAT | O_EXCL.
|
||||
* Returns true if the lock was acquired, false if another instance owns it.
|
||||
@@ -486,9 +231,16 @@ export async function readExtractionState(
|
||||
|
||||
const runs: ExtractionRun[] = [];
|
||||
for (const run of parsed.runs) {
|
||||
const normalizedRun = buildExtractionRun(run);
|
||||
if (!normalizedRun) continue;
|
||||
runs.push(normalizedRun);
|
||||
if (!isExtractionRun(run)) continue;
|
||||
runs.push({
|
||||
runAt: run.runAt,
|
||||
sessionIds: run.sessionIds.filter(
|
||||
(sid): sid is string => typeof sid === 'string',
|
||||
),
|
||||
skillsCreated: run.skillsCreated.filter(
|
||||
(sk): sk is string => typeof sk === 'string',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return { runs };
|
||||
@@ -518,32 +270,30 @@ export async function writeExtractionState(
|
||||
* Filters out subagent sessions, sessions that haven't been idle long enough,
|
||||
* and sessions with too few user messages.
|
||||
*/
|
||||
function shouldProcessConversation(
|
||||
parsed: ConversationRecord & { userMessageCount?: number },
|
||||
): boolean {
|
||||
function shouldProcessConversation(parsed: ConversationRecord): boolean {
|
||||
// Skip subagent sessions
|
||||
if (parsed.kind === 'subagent') return false;
|
||||
|
||||
// Skip sessions that are still active (not idle for 3+ hours)
|
||||
const lastUpdated = getTimestampMs(parsed.lastUpdated);
|
||||
const lastUpdated = new Date(parsed.lastUpdated).getTime();
|
||||
if (Date.now() - lastUpdated < MIN_IDLE_MS) return false;
|
||||
|
||||
// Skip sessions with too few user messages
|
||||
if (getUserMessageCount(parsed) < MIN_USER_MESSAGES) return false;
|
||||
const userMessageCount = parsed.messages.filter(
|
||||
(m) => m.type === 'user',
|
||||
).length;
|
||||
if (userMessageCount < MIN_USER_MESSAGES) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the chats directory for eligible session files, loading metadata from
|
||||
* both JSONL and legacy JSON sessions, deduplicating migrated sessions by
|
||||
* session ID, and sorting by actual lastUpdated. We scan the full directory
|
||||
* here so already-processed recent sessions cannot permanently block older
|
||||
* backlog sessions from surfacing as new candidates.
|
||||
* Scans the chats directory for eligible session files (sorted most-recent-first,
|
||||
* capped at MAX_SESSION_INDEX_SIZE). Shared by buildSessionIndex.
|
||||
*/
|
||||
async function scanEligibleSessions(
|
||||
chatsDir: string,
|
||||
): Promise<IndexedSession[]> {
|
||||
): Promise<Array<{ conversation: ConversationRecord; filePath: string }>> {
|
||||
let allFiles: string[];
|
||||
try {
|
||||
allFiles = await fs.readdir(chatsDir);
|
||||
@@ -551,48 +301,33 @@ async function scanEligibleSessions(
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates: Array<{ filePath: string; mtimeMs: number }> = [];
|
||||
for (const file of allFiles) {
|
||||
if (!isSupportedSessionFile(file)) continue;
|
||||
const sessionFiles = allFiles.filter(
|
||||
(f) => f.startsWith(SESSION_FILE_PREFIX) && f.endsWith('.json'),
|
||||
);
|
||||
|
||||
// Sort by filename descending (most recent first)
|
||||
sessionFiles.sort((a, b) => b.localeCompare(a));
|
||||
|
||||
const results: Array<{ conversation: ConversationRecord; filePath: string }> =
|
||||
[];
|
||||
|
||||
for (const file of sessionFiles) {
|
||||
if (results.length >= MAX_SESSION_INDEX_SIZE) break;
|
||||
|
||||
const filePath = path.join(chatsDir, file);
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
if (!stat.isFile()) continue;
|
||||
candidates.push({ filePath, mtimeMs: stat.mtimeMs });
|
||||
} catch {
|
||||
// Skip files that disappeared between readdir and stat.
|
||||
}
|
||||
}
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
if (!isConversationRecord(parsed)) continue;
|
||||
if (!shouldProcessConversation(parsed)) continue;
|
||||
|
||||
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
|
||||
const latestBySessionId = new Map<string, IndexedSession>();
|
||||
|
||||
for (const { filePath } of candidates) {
|
||||
try {
|
||||
const conversation = await loadConversationRecord(filePath, {
|
||||
metadataOnly: true,
|
||||
});
|
||||
if (!conversation || !shouldProcessConversation(conversation)) continue;
|
||||
|
||||
const indexedSession: IndexedSession = {
|
||||
sessionId: conversation.sessionId,
|
||||
lastUpdated: conversation.lastUpdated,
|
||||
filePath,
|
||||
summary: conversation.summary,
|
||||
userMessageCount: getUserMessageCount(conversation),
|
||||
};
|
||||
|
||||
const existing = latestBySessionId.get(indexedSession.sessionId);
|
||||
if (!existing || shouldReplaceIndexedSession(existing, indexedSession)) {
|
||||
latestBySessionId.set(indexedSession.sessionId, indexedSession);
|
||||
}
|
||||
results.push({ conversation: parsed, filePath });
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(latestBySessionId.values()).sort(compareIndexedSessions);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -600,67 +335,39 @@ async function scanEligibleSessions(
|
||||
* eligible sessions with their summary, file path, and new/previously-processed status.
|
||||
* The agent can use read_file on paths to inspect sessions that look promising.
|
||||
*
|
||||
* Returns the index text, the list of selected new (unprocessed) session IDs,
|
||||
* and the surfaced candidate sessions for this run.
|
||||
* Returns the index text and the list of new (unprocessed) session IDs.
|
||||
*/
|
||||
export async function buildSessionIndex(
|
||||
chatsDir: string,
|
||||
state: ExtractionState,
|
||||
): Promise<{
|
||||
sessionIndex: string;
|
||||
newSessionIds: string[];
|
||||
candidateSessions: IndexedSession[];
|
||||
}> {
|
||||
): Promise<{ sessionIndex: string; newSessionIds: string[] }> {
|
||||
const processedSet = getProcessedSessionIds(state);
|
||||
const eligible = await scanEligibleSessions(chatsDir);
|
||||
|
||||
if (eligible.length === 0) {
|
||||
return { sessionIndex: '', newSessionIds: [], candidateSessions: [] };
|
||||
return { sessionIndex: '', newSessionIds: [] };
|
||||
}
|
||||
|
||||
const newSessions: IndexedSession[] = [];
|
||||
const oldSessions: IndexedSession[] = [];
|
||||
for (const session of eligible) {
|
||||
if (isSessionVersionProcessed(state, session)) {
|
||||
oldSessions.push(session);
|
||||
} else {
|
||||
newSessions.push(session);
|
||||
const lines: string[] = [];
|
||||
const newSessionIds: string[] = [];
|
||||
|
||||
for (const { conversation, filePath } of eligible) {
|
||||
const userMessageCount = conversation.messages.filter(
|
||||
(m) => m.type === 'user',
|
||||
).length;
|
||||
const isNew = !processedSet.has(conversation.sessionId);
|
||||
if (isNew) {
|
||||
newSessionIds.push(conversation.sessionId);
|
||||
}
|
||||
|
||||
const status = isNew ? '[NEW]' : '[old]';
|
||||
const summary = conversation.summary ?? '(no summary)';
|
||||
lines.push(
|
||||
`${status} ${summary} (${userMessageCount} user msgs) — ${filePath}`,
|
||||
);
|
||||
}
|
||||
|
||||
newSessions.sort((a, b) => {
|
||||
const attemptDelta =
|
||||
getSessionAttemptCount(state, a) - getSessionAttemptCount(state, b);
|
||||
if (attemptDelta !== 0) {
|
||||
return attemptDelta;
|
||||
}
|
||||
return compareIndexedSessions(a, b);
|
||||
});
|
||||
|
||||
const candidateSessions = newSessions.slice(0, MAX_NEW_SESSION_BATCH_SIZE);
|
||||
const remainingSlots = Math.max(
|
||||
0,
|
||||
MAX_SESSION_INDEX_SIZE - candidateSessions.length,
|
||||
);
|
||||
const displayedOldSessions = oldSessions.slice(0, remainingSlots);
|
||||
const candidateSessionIds = new Set(
|
||||
candidateSessions.map((session) => getSessionVersionKey(session)),
|
||||
);
|
||||
|
||||
const lines = [...candidateSessions, ...displayedOldSessions].map(
|
||||
(session) => {
|
||||
const status = candidateSessionIds.has(getSessionVersionKey(session))
|
||||
? '[NEW]'
|
||||
: '[old]';
|
||||
const summary = session.summary ?? '(no summary)';
|
||||
return `${status} ${summary} (${session.userMessageCount} user msgs) — ${session.filePath}`;
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
sessionIndex: lines.join('\n'),
|
||||
newSessionIds: candidateSessions.map((session) => session.sessionId),
|
||||
candidateSessions,
|
||||
};
|
||||
return { sessionIndex: lines.join('\n'), newSessionIds };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -925,12 +632,14 @@ export async function startMemoryService(config: Config): Promise<void> {
|
||||
|
||||
// Build session index: all eligible sessions with summaries + file paths.
|
||||
// The agent decides which to read in full via read_file.
|
||||
const { sessionIndex, newSessionIds, candidateSessions } =
|
||||
await buildSessionIndex(chatsDir, state);
|
||||
const { sessionIndex, newSessionIds } = await buildSessionIndex(
|
||||
chatsDir,
|
||||
state,
|
||||
);
|
||||
|
||||
const totalInIndex = sessionIndex ? sessionIndex.split('\n').length : 0;
|
||||
debugLogger.log(
|
||||
`[MemoryService] Session scan: ${totalInIndex} indexed session(s), ${candidateSessions.length} surfaced as new candidates`,
|
||||
`[MemoryService] Session scan: ${totalInIndex} eligible session(s) found, ${newSessionIds.length} new`,
|
||||
);
|
||||
|
||||
if (newSessionIds.length === 0) {
|
||||
@@ -993,59 +702,8 @@ export async function startMemoryService(config: Config): Promise<void> {
|
||||
`[MemoryService] Starting extraction agent (model: ${agentDefinition.modelConfig.model}, maxTurns: 30, maxTime: 30min)`,
|
||||
);
|
||||
|
||||
const candidateSessionsByPath = new Map(
|
||||
candidateSessions.map((session) => [
|
||||
path.resolve(session.filePath),
|
||||
session,
|
||||
]),
|
||||
);
|
||||
const processedSessionKeys = new Set<string>();
|
||||
const pendingReadFileSessions = new Map<string, string>();
|
||||
|
||||
// Create and run the extraction agent
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
agentDefinition,
|
||||
context,
|
||||
(activity) => {
|
||||
const readFileCallId = getReadFileStartCallId(activity);
|
||||
if (readFileCallId) {
|
||||
const resolvedPath = getResolvedReadFilePath(config, activity);
|
||||
if (!resolvedPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const session = candidateSessionsByPath.get(resolvedPath);
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingReadFileSessions.set(
|
||||
readFileCallId,
|
||||
getSessionVersionKey(session),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const completedReadFileCallId = getCompletedReadFileCallId(activity);
|
||||
if (completedReadFileCallId) {
|
||||
const sessionKey = pendingReadFileSessions.get(
|
||||
completedReadFileCallId,
|
||||
);
|
||||
if (!sessionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
processedSessionKeys.add(sessionKey);
|
||||
pendingReadFileSessions.delete(completedReadFileCallId);
|
||||
return;
|
||||
}
|
||||
|
||||
const failedReadFileCallId = getFailedReadFileCallId(activity);
|
||||
if (failedReadFileCallId) {
|
||||
pendingReadFileSessions.delete(failedReadFileCallId);
|
||||
}
|
||||
},
|
||||
);
|
||||
const executor = await LocalAgentExecutor.create(agentDefinition, context);
|
||||
|
||||
await executor.run(
|
||||
{ request: 'Extract skills from the provided sessions.' },
|
||||
@@ -1088,24 +746,10 @@ export async function startMemoryService(config: Config): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
const processedSessions = candidateSessions
|
||||
.filter((session) =>
|
||||
processedSessionKeys.has(getSessionVersionKey(session)),
|
||||
)
|
||||
.map((session) => ({
|
||||
sessionId: session.sessionId,
|
||||
lastUpdated: session.lastUpdated,
|
||||
}));
|
||||
|
||||
// Record the run with full metadata
|
||||
const run: ExtractionRun = {
|
||||
runAt: new Date().toISOString(),
|
||||
sessionIds: processedSessions.map((session) => session.sessionId),
|
||||
candidateSessions: candidateSessions.map((session) => ({
|
||||
sessionId: session.sessionId,
|
||||
lastUpdated: session.lastUpdated,
|
||||
})),
|
||||
processedSessions,
|
||||
sessionIds: newSessionIds,
|
||||
skillsCreated,
|
||||
};
|
||||
const updatedState: ExtractionState = {
|
||||
@@ -1126,7 +770,7 @@ export async function startMemoryService(config: Config): Promise<void> {
|
||||
);
|
||||
}
|
||||
debugLogger.log(
|
||||
`[MemoryService] Completed in ${elapsed}s. ${completionParts.join('; ')} (read ${processedSessions.length}/${candidateSessions.length} surfaced session(s))`,
|
||||
`[MemoryService] Completed in ${elapsed}s. ${completionParts.join('; ')} (processed ${newSessionIds.length} session(s))`,
|
||||
);
|
||||
const feedbackParts: string[] = [];
|
||||
if (skillsCreated.length > 0) {
|
||||
@@ -1145,7 +789,7 @@ export async function startMemoryService(config: Config): Promise<void> {
|
||||
);
|
||||
} else {
|
||||
debugLogger.log(
|
||||
`[MemoryService] Completed in ${elapsed}s. No new skills or patches created (read ${processedSessions.length}/${candidateSessions.length} surfaced session(s))`,
|
||||
`[MemoryService] Completed in ${elapsed}s. No new skills or patches created (processed ${newSessionIds.length} session(s))`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||