mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-01 20:51:00 -07:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc06ac3202 | |||
| d6492830b6 | |||
| bcf599b3b2 | |||
| 02792264ed | |||
| 059d9175eb | |||
| 212edf31ed | |||
| 1bb41262b0 | |||
| daf5006237 | |||
| 706d4d4707 | |||
| 24f9ec51d2 | |||
| 050c30330e | |||
| a172b328e2 | |||
| a4318f22ec | |||
| 82e8d67a78 | |||
| 95944ec5af | |||
| ea36ccb567 | |||
| a05c5ed56a | |||
| 0d6d5d90b9 | |||
| b91d177bde | |||
| 36dca862cc | |||
| a5f7b453ca | |||
| 26f04c9d9a | |||
| 5d8bd41937 | |||
| 6b6ea56437 | |||
| 0179726222 | |||
| 0957f7d3e2 | |||
| 773567c6ca | |||
| 58901207bd | |||
| 21e1c60922 | |||
| 35907057ad | |||
| e2a5231e30 | |||
| db6943fbee | |||
| 128e3567cb | |||
| a74bb603c0 | |||
| 565eafc1ec | |||
| 7bba7f437c | |||
| b499562921 | |||
| 86f5a198bd | |||
| 31069329bb | |||
| 3b7c17a22c | |||
| 1d36309f5f | |||
| 7d1de3bccc | |||
| f6c08a114b | |||
| c1fd6027e2 | |||
| 0fd0851e1a | |||
| 5fc8fea8d7 |
@@ -2,6 +2,7 @@
|
||||
"experimental": {
|
||||
"extensionReloading": true,
|
||||
"modelSteering": true,
|
||||
"memoryManager": true,
|
||||
"topicUpdateNarration": true
|
||||
},
|
||||
"general": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: 'Weekly Docs Audit'
|
||||
name: 'Automated Documentation Audit'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -26,6 +26,7 @@ jobs:
|
||||
node-version: '20'
|
||||
|
||||
- name: 'Run Docs Audit with Gemini'
|
||||
id: 'run_gemini'
|
||||
uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31'
|
||||
with:
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
@@ -33,17 +34,28 @@ jobs:
|
||||
Activate the 'docs-writer' skill.
|
||||
|
||||
**Task:** Execute the docs audit procedure, as defined in your 'docs-auditing.md' reference.
|
||||
Provide a detailed summary of the changes you make.
|
||||
|
||||
- name: 'Get current date'
|
||||
id: 'date'
|
||||
run: |
|
||||
echo "date=$(date +'%Y-%m-%d')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Create Pull Request with Audit Results'
|
||||
uses: 'peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c'
|
||||
with:
|
||||
token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
|
||||
commit-message: 'docs: weekly audit results for ${{ github.run_id }}'
|
||||
title: 'Docs Audit for Week of ${{ github.event.schedule }}'
|
||||
title: 'Docs audit: ${{ steps.date.outputs.date }}'
|
||||
body: |
|
||||
This PR contains the auto-generated documentation audit for the week. It includes a new `audit-results-*.md` file with findings and any direct fixes applied by the agent.
|
||||
|
||||
### Audit Summary:
|
||||
${{ steps.run_gemini.outputs.summary || 'No summary provided.' }}
|
||||
|
||||
Please review the suggestions and merge.
|
||||
|
||||
Related to #25152
|
||||
branch: 'docs-audit-${{ github.run_id }}'
|
||||
base: 'main'
|
||||
team-reviewers: 'gemini-cli-docs, gemini-cli-maintainers'
|
||||
|
||||
+3
-1
@@ -110,7 +110,9 @@ assign or unassign the issue as requested, provided the conditions are met
|
||||
(e.g., an issue must be unassigned to be assigned).
|
||||
|
||||
Please note that you can have a maximum of 3 issues assigned to you at any given
|
||||
time.
|
||||
time and that only
|
||||
[issues labeled "help wanted"](https://github.com/google-gemini/gemini-cli/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22help%20wanted%22)
|
||||
may be self-assigned.
|
||||
|
||||
### Pull request guidelines
|
||||
|
||||
|
||||
@@ -505,15 +505,19 @@ events. For more information, see the [telemetry documentation](./telemetry.md).
|
||||
## Authentication
|
||||
|
||||
You can enforce a specific authentication method for all users by setting the
|
||||
`enforcedAuthType` in the system-level `settings.json` file. This prevents users
|
||||
from choosing a different authentication method. See the
|
||||
`security.auth.enforcedType` in the system-level `settings.json` file. This
|
||||
prevents users from choosing a different authentication method. See the
|
||||
[Authentication docs](../get-started/authentication.md) for more details.
|
||||
|
||||
**Example:** Enforce the use of Google login for all users.
|
||||
|
||||
```json
|
||||
{
|
||||
"enforcedAuthType": "oauth-personal"
|
||||
"security": {
|
||||
"auth": {
|
||||
"enforcedType": "oauth-personal"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+10
-2
@@ -327,8 +327,12 @@ Storage whenever Gemini CLI exits Plan Mode to start the implementation.
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# Extract the plan path from the tool input JSON
|
||||
plan_path=$(jq -r '.tool_input.plan_path // empty')
|
||||
# Extract the plan filename from the tool input JSON
|
||||
plan_filename=$(jq -r '.tool_input.plan_filename // empty')
|
||||
plan_filename=$(basename -- "$plan_filename")
|
||||
|
||||
# Construct the absolute path using the GEMINI_PLANS_DIR environment variable
|
||||
plan_path="$GEMINI_PLANS_DIR/$plan_filename"
|
||||
|
||||
if [ -f "$plan_path" ]; then
|
||||
# Generate a unique filename using a timestamp
|
||||
@@ -441,6 +445,10 @@ on the current phase of your task:
|
||||
switches to a high-speed **Flash** model. This provides a faster, more
|
||||
responsive experience during the implementation of the plan.
|
||||
|
||||
If the high-reasoning model is unavailable or you don't have access to it,
|
||||
Gemini CLI automatically and silently falls back to a faster model to ensure
|
||||
your workflow isn't interrupted.
|
||||
|
||||
This behavior is enabled by default to provide the best balance of quality and
|
||||
performance. You can disable this automatic switching in your settings:
|
||||
|
||||
|
||||
+3
-1
@@ -309,7 +309,9 @@ $env:SANDBOX_SET_UID_GID="false" # Disable UID/GID mapping
|
||||
|
||||
**Missing commands**
|
||||
|
||||
- Add to custom Dockerfile.
|
||||
- Add to a custom Dockerfile. Automatic `BUILD_SANDBOX` builds are only
|
||||
available when running Gemini CLI from source; npm installs need a prebuilt
|
||||
image instead.
|
||||
- Install via `sandbox.bashrc`.
|
||||
|
||||
**Network issues**
|
||||
|
||||
+17
-5
@@ -87,11 +87,23 @@ Gemini CLI comes with the following built-in subagents:
|
||||
|
||||
### Generalist Agent
|
||||
|
||||
- **Name:** `generalist_agent`
|
||||
- **Purpose:** Route tasks to the appropriate specialized subagent.
|
||||
- **When to use:** Implicitly used by the main agent for routing. Not directly
|
||||
invoked by the user.
|
||||
- **Configuration:** Enabled by default. No specific configuration options.
|
||||
- **Name:** `generalist`
|
||||
- **Purpose:** A general, all-purpose subagent that uses the inherited tool
|
||||
access and configurations from the main agent. Useful for executing broad,
|
||||
resource-heavy subtasks in an isolated conversation, optimizing your main
|
||||
agent's context by returning only the final result of that given task.
|
||||
- **When to use:** Use this agent when a task requires many steps, handles large
|
||||
volumes of information, or requires the same full capabilities as the main
|
||||
agent. It is ideal for:
|
||||
- **Multi-file modifications:** Applying refactors or fixing errors across
|
||||
several files at once.
|
||||
- **High-volume execution:** Running commands or tests that produce extensive
|
||||
terminal output.
|
||||
- **Action-oriented research:** Investigations where the agent needs to both
|
||||
search code and run commands or make edits to find a solution. By delegating
|
||||
these tasks, you prevent your main conversation from becoming cluttered or
|
||||
slow. You can invoke it explicitly using `@generalist`.
|
||||
- **Configuration:** Enabled by default.
|
||||
|
||||
### Browser Agent (experimental)
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ multiple layers in the following order of precedence (highest to lowest):
|
||||
Hooks are executed with a sanitized environment.
|
||||
|
||||
- `GEMINI_PROJECT_DIR`: The absolute path to the project root.
|
||||
- `GEMINI_PLANS_DIR`: The absolute path to the plans directory.
|
||||
- `GEMINI_SESSION_ID`: The unique ID for the current session.
|
||||
- `GEMINI_CWD`: The current working directory.
|
||||
- `CLAUDE_PROJECT_DIR`: (Alias) Provided for compatibility.
|
||||
|
||||
@@ -2509,6 +2509,10 @@ sandbox image:
|
||||
BUILD_SANDBOX=1 gemini -s
|
||||
```
|
||||
|
||||
Building a custom sandbox with `BUILD_SANDBOX` is only supported when running
|
||||
Gemini CLI from source. If you installed the CLI with npm, build the Docker
|
||||
image separately and reference that image in your sandbox configuration.
|
||||
|
||||
## Usage statistics
|
||||
|
||||
To help us improve Gemini CLI, we collect anonymized usage statistics. This data
|
||||
|
||||
@@ -99,7 +99,7 @@ available combinations.
|
||||
|
||||
| Command | Action | Keys |
|
||||
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
|
||||
| `app.showErrorDetails` | Toggle detailed error information. | `F12` |
|
||||
| `app.showErrorDetails` | Toggle the debug console for detailed error information. | `F12` |
|
||||
| `app.showFullTodos` | Toggle the full TODO list. | `Ctrl+T` |
|
||||
| `app.showIdeContextDetail` | Show IDE context details. | `F4` |
|
||||
| `app.toggleMarkdown` | Toggle Markdown rendering. | `Alt+M` |
|
||||
@@ -248,6 +248,83 @@ a `key` combination.
|
||||
- `Double-click` on a paste placeholder (alternate buffer mode only): Expand to
|
||||
view full content inline. Double-click again to collapse.
|
||||
|
||||
## Vi mode shortcuts
|
||||
|
||||
When vim mode is enabled with `/vim` or `general.vimMode: true`, Gemini CLI
|
||||
supports NORMAL and INSERT modes.
|
||||
|
||||
### Mode switching
|
||||
|
||||
| Action | Keys |
|
||||
| -------------------------------------------- | --------- |
|
||||
| Enter NORMAL mode from INSERT mode | `Esc` |
|
||||
| Enter INSERT mode at the cursor | `i` |
|
||||
| Enter INSERT mode after the cursor | `a` |
|
||||
| Enter INSERT mode at the start of the line | `I` |
|
||||
| Enter INSERT mode at the end of the line | `A` |
|
||||
| Insert a new line below and switch to INSERT | `o` |
|
||||
| Insert a new line above and switch to INSERT | `O` |
|
||||
| Clear input in NORMAL mode | `Esc Esc` |
|
||||
|
||||
### Navigation in NORMAL mode
|
||||
|
||||
| Action | Keys |
|
||||
| --------------------------------- | --------------- |
|
||||
| Move left | `h` |
|
||||
| Move down | `j` |
|
||||
| Move up | `k` |
|
||||
| Move right | `l` |
|
||||
| Move to start of line | `0` |
|
||||
| Move to first non-whitespace char | `^` |
|
||||
| Move to end of line | `$` |
|
||||
| Move forward by word | `w` |
|
||||
| Move backward by word | `b` |
|
||||
| Move to end of word | `e` |
|
||||
| Move forward by WORD | `W` |
|
||||
| Move backward by WORD | `B` |
|
||||
| Move to end of WORD | `E` |
|
||||
| Go to first line | `gg` |
|
||||
| Go to last line | `G` |
|
||||
| Go to line N | `N G` or `N gg` |
|
||||
|
||||
Counts are supported for navigation commands. For example, `5j` moves down five
|
||||
lines and `3w` moves forward three words.
|
||||
|
||||
### Editing in NORMAL mode
|
||||
|
||||
| Action | Keys |
|
||||
| ------------------------------ | ----- |
|
||||
| Delete character under cursor | `x` |
|
||||
| Delete to end of line | `D` |
|
||||
| Delete line | `dd` |
|
||||
| Change to end of line | `C` |
|
||||
| Change line | `cc` |
|
||||
| Delete forward word | `dw` |
|
||||
| Delete backward word | `db` |
|
||||
| Delete to end of word | `de` |
|
||||
| Delete forward WORD | `dW` |
|
||||
| Delete backward WORD | `dB` |
|
||||
| Delete to end of WORD | `dE` |
|
||||
| Change forward word | `cw` |
|
||||
| Change backward word | `cb` |
|
||||
| Change to end of word | `ce` |
|
||||
| Change forward WORD | `cW` |
|
||||
| Change backward WORD | `cB` |
|
||||
| Change to end of WORD | `cE` |
|
||||
| Delete to start of line | `d0` |
|
||||
| Delete to first non-whitespace | `d^` |
|
||||
| Change to start of line | `c0` |
|
||||
| Change to first non-whitespace | `c^` |
|
||||
| Delete from first line to here | `dgg` |
|
||||
| Delete from here to last line | `dG` |
|
||||
| Change from first line to here | `cgg` |
|
||||
| Change from here to last line | `cG` |
|
||||
| Undo last change | `u` |
|
||||
| Repeat last command | `.` |
|
||||
|
||||
Counts are also supported for editing commands. For example, `3dd` deletes three
|
||||
lines and `2cw` changes two words.
|
||||
|
||||
## Limitations
|
||||
|
||||
- On [Windows Terminal](https://en.wikipedia.org/wiki/Windows_Terminal):
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ const external = [
|
||||
'@lydell/node-pty-linux-x64',
|
||||
'@lydell/node-pty-win32-arm64',
|
||||
'@lydell/node-pty-win32-x64',
|
||||
'keytar',
|
||||
'@github/keytar',
|
||||
'@google/gemini-cli-devtools',
|
||||
];
|
||||
|
||||
|
||||
@@ -76,7 +76,9 @@ describe('ripgrep-real-direct', () => {
|
||||
|
||||
it('should find matches using the real ripgrep binary', async () => {
|
||||
const invocation = tool.build({ pattern: 'hello' });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.llmContent).toContain('Found 2 matches');
|
||||
expect(result.llmContent).toContain('file1.txt');
|
||||
@@ -90,7 +92,9 @@ describe('ripgrep-real-direct', () => {
|
||||
|
||||
it('should handle no matches correctly', async () => {
|
||||
const invocation = tool.build({ pattern: 'nonexistent_pattern_123' });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.llmContent).toContain('No matches found');
|
||||
});
|
||||
@@ -106,7 +110,9 @@ describe('ripgrep-real-direct', () => {
|
||||
pattern: 'hello',
|
||||
include_pattern: '*.js',
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.llmContent).toContain('Found 1 match');
|
||||
expect(result.llmContent).toContain('script.js');
|
||||
@@ -124,7 +130,9 @@ describe('ripgrep-real-direct', () => {
|
||||
pattern: 'match',
|
||||
context: 1,
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.llmContent).toContain('Found 1 match');
|
||||
expect(result.llmContent).toContain('context.txt');
|
||||
|
||||
@@ -1,30 +1,55 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-04-08T01:21:58.770Z",
|
||||
"updatedAt": "2026-04-10T15:36:04.547Z",
|
||||
"scenarios": {
|
||||
"multi-turn-conversation": {
|
||||
"heapUsedBytes": 120082704,
|
||||
"heapTotalBytes": 177586176,
|
||||
"rssBytes": 269172736,
|
||||
"timestamp": "2026-04-08T01:21:57.127Z"
|
||||
"externalBytes": 4304053,
|
||||
"timestamp": "2026-04-10T15:35:17.603Z"
|
||||
},
|
||||
"multi-function-call-repo-search": {
|
||||
"heapUsedBytes": 104644984,
|
||||
"heapTotalBytes": 111575040,
|
||||
"rssBytes": 204079104,
|
||||
"timestamp": "2026-04-08T01:21:58.770Z"
|
||||
"externalBytes": 4304053,
|
||||
"timestamp": "2026-04-10T15:35:22.480Z"
|
||||
},
|
||||
"idle-session-startup": {
|
||||
"heapUsedBytes": 119813672,
|
||||
"heapTotalBytes": 177061888,
|
||||
"rssBytes": 267943936,
|
||||
"timestamp": "2026-04-08T01:21:53.855Z"
|
||||
"externalBytes": 4304053,
|
||||
"timestamp": "2026-04-10T15:35:08.035Z"
|
||||
},
|
||||
"simple-prompt-response": {
|
||||
"heapUsedBytes": 119722064,
|
||||
"heapTotalBytes": 177324032,
|
||||
"rssBytes": 268812288,
|
||||
"timestamp": "2026-04-08T01:21:55.491Z"
|
||||
"externalBytes": 4304053,
|
||||
"timestamp": "2026-04-10T15:35:12.770Z"
|
||||
},
|
||||
"resume-large-chat-with-messages": {
|
||||
"heapUsedBytes": 106545568,
|
||||
"heapTotalBytes": 111509504,
|
||||
"rssBytes": 202596352,
|
||||
"externalBytes": 4306101,
|
||||
"timestamp": "2026-04-10T15:36:04.547Z"
|
||||
},
|
||||
"resume-large-chat": {
|
||||
"heapUsedBytes": 106513760,
|
||||
"heapTotalBytes": 111509504,
|
||||
"rssBytes": 202596352,
|
||||
"externalBytes": 4306101,
|
||||
"timestamp": "2026-04-10T15:35:59.528Z"
|
||||
},
|
||||
"large-chat": {
|
||||
"heapUsedBytes": 106471568,
|
||||
"heapTotalBytes": 111509504,
|
||||
"rssBytes": 202596352,
|
||||
"externalBytes": 4306101,
|
||||
"timestamp": "2026-04-10T15:35:53.180Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -8,6 +8,15 @@ import { describe, it, beforeAll, afterAll, afterEach } from 'vitest';
|
||||
import { TestRig, MemoryTestHarness } from '@google/gemini-cli-test-utils';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
createWriteStream,
|
||||
copyFileSync,
|
||||
readFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
} from 'node:fs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const BASELINES_PATH = join(__dirname, 'baselines.json');
|
||||
@@ -182,4 +191,312 @@ describe('Memory Usage Tests', () => {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
describe('Large Chat Scenarios', () => {
|
||||
let sharedResumeResponsesPath: string;
|
||||
let sharedActiveResponsesPath: string;
|
||||
let sharedHistoryPath: string;
|
||||
let sharedPrompts: string;
|
||||
let tempDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDir = join(__dirname, `large-chat-tmp-${randomUUID()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const { resumeResponsesPath, activeResponsesPath, historyPath, prompts } =
|
||||
await generateSharedLargeChatData(tempDir);
|
||||
sharedActiveResponsesPath = activeResponsesPath;
|
||||
sharedResumeResponsesPath = resumeResponsesPath;
|
||||
sharedHistoryPath = historyPath;
|
||||
sharedPrompts = prompts;
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
if (existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rig.cleanup();
|
||||
});
|
||||
|
||||
it('large-chat: memory usage within baseline', async () => {
|
||||
rig = new TestRig();
|
||||
rig.setup('memory-large-chat', {
|
||||
fakeResponsesPath: sharedActiveResponsesPath,
|
||||
});
|
||||
|
||||
const result = await harness.runScenario(
|
||||
'large-chat',
|
||||
async (recordSnapshot) => {
|
||||
await rig.run({
|
||||
stdin: sharedPrompts,
|
||||
timeout: 600000,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
await recordSnapshot('after-large-chat');
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
console.log(
|
||||
`Updated baseline for large-chat: ${(result.finalHeapUsed / (1024 * 1024)).toFixed(1)} MB`,
|
||||
);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
it('resume-large-chat: memory usage within baseline', async () => {
|
||||
rig = new TestRig();
|
||||
rig.setup('memory-resume-large-chat', {
|
||||
fakeResponsesPath: sharedResumeResponsesPath,
|
||||
});
|
||||
|
||||
const result = await harness.runScenario(
|
||||
'resume-large-chat',
|
||||
async (recordSnapshot) => {
|
||||
// Ensure the history file is linked
|
||||
const targetChatsDir = join(
|
||||
rig.testDir!,
|
||||
'tmp',
|
||||
'test-project-hash',
|
||||
'chats',
|
||||
);
|
||||
mkdirSync(targetChatsDir, { recursive: true });
|
||||
const targetHistoryPath = join(
|
||||
targetChatsDir,
|
||||
'large-chat-session.json',
|
||||
);
|
||||
if (existsSync(targetHistoryPath)) rmSync(targetHistoryPath);
|
||||
copyFileSync(sharedHistoryPath, targetHistoryPath);
|
||||
|
||||
await rig.run({
|
||||
// add a prompt to make sure it does not hang there and exits immediately
|
||||
args: ['--resume', 'latest', '--prompt', 'hello'],
|
||||
timeout: 600000,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
await recordSnapshot('after-resume-large-chat');
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
console.log(
|
||||
`Updated baseline for resume-large-chat: ${(result.finalHeapUsed / (1024 * 1024)).toFixed(1)} MB`,
|
||||
);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
|
||||
it('resume-large-chat-with-messages: memory usage within baseline', async () => {
|
||||
rig = new TestRig();
|
||||
rig.setup('memory-resume-large-chat-msgs', {
|
||||
fakeResponsesPath: sharedResumeResponsesPath,
|
||||
});
|
||||
|
||||
const result = await harness.runScenario(
|
||||
'resume-large-chat-with-messages',
|
||||
async (recordSnapshot) => {
|
||||
// Ensure the history file is linked
|
||||
const targetChatsDir = join(
|
||||
rig.testDir!,
|
||||
'tmp',
|
||||
'test-project-hash',
|
||||
'chats',
|
||||
);
|
||||
mkdirSync(targetChatsDir, { recursive: true });
|
||||
const targetHistoryPath = join(
|
||||
targetChatsDir,
|
||||
'large-chat-session.json',
|
||||
);
|
||||
if (existsSync(targetHistoryPath)) rmSync(targetHistoryPath);
|
||||
copyFileSync(sharedHistoryPath, targetHistoryPath);
|
||||
|
||||
const stdinContent = 'new prompt 1\nnew prompt 2\n';
|
||||
|
||||
await rig.run({
|
||||
args: ['--resume', 'latest'],
|
||||
stdin: stdinContent,
|
||||
timeout: 600000,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
await recordSnapshot('after-resume-and-append');
|
||||
},
|
||||
);
|
||||
|
||||
if (UPDATE_BASELINES) {
|
||||
harness.updateScenarioBaseline(result);
|
||||
console.log(
|
||||
`Updated baseline for resume-large-chat-with-messages: ${(result.finalHeapUsed / (1024 * 1024)).toFixed(1)} MB`,
|
||||
);
|
||||
} else {
|
||||
harness.assertWithinBaseline(result);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function generateSharedLargeChatData(tempDir: string) {
|
||||
const resumeResponsesPath = join(tempDir, 'large-chat-resume-chat.responses');
|
||||
const activeResponsesPath = join(tempDir, 'large-chat-active-chat.responses');
|
||||
const historyPath = join(tempDir, 'large-chat-history.json');
|
||||
const sourceSessionPath = join(__dirname, 'large-chat-session.json');
|
||||
|
||||
const session = JSON.parse(readFileSync(sourceSessionPath, 'utf8'));
|
||||
const messages = session.messages;
|
||||
|
||||
copyFileSync(sourceSessionPath, historyPath);
|
||||
|
||||
// Generate fake responses for active chat
|
||||
const promptsList: string[] = [];
|
||||
const activeResponsesStream = createWriteStream(activeResponsesPath);
|
||||
const complexityResponse = {
|
||||
method: 'generateContent',
|
||||
response: {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
text: '{"complexity_reasoning":"simple","complexity_score":1}',
|
||||
},
|
||||
],
|
||||
role: 'model',
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const summaryResponse = {
|
||||
method: 'generateContent',
|
||||
response: {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{ text: '{"originalSummary":"large chat summary","events":[]}' },
|
||||
],
|
||||
role: 'model',
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
if (msg.type === 'user') {
|
||||
promptsList.push(msg.content[0].text);
|
||||
|
||||
// Start of a new turn
|
||||
activeResponsesStream.write(JSON.stringify(complexityResponse) + '\n');
|
||||
|
||||
// Find all subsequent gemini messages until the next user message
|
||||
let j = i + 1;
|
||||
while (j < messages.length && messages[j].type === 'gemini') {
|
||||
const geminiMsg = messages[j];
|
||||
const parts = [];
|
||||
if (geminiMsg.content) {
|
||||
parts.push({ text: geminiMsg.content });
|
||||
}
|
||||
if (geminiMsg.toolCalls) {
|
||||
for (const tc of geminiMsg.toolCalls) {
|
||||
parts.push({
|
||||
functionCall: {
|
||||
name: tc.name,
|
||||
args: tc.args,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
activeResponsesStream.write(
|
||||
JSON.stringify({
|
||||
method: 'generateContentStream',
|
||||
response: [
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: { parts, role: 'model' },
|
||||
finishReason: 'STOP',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 100,
|
||||
totalTokenCount: 200,
|
||||
promptTokensDetails: [{ modality: 'TEXT', tokenCount: 100 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}) + '\n',
|
||||
);
|
||||
j++;
|
||||
}
|
||||
// End of turn
|
||||
activeResponsesStream.write(JSON.stringify(summaryResponse) + '\n');
|
||||
// Skip the gemini messages we just processed
|
||||
i = j - 1;
|
||||
}
|
||||
}
|
||||
activeResponsesStream.end();
|
||||
|
||||
// Generate responses for resumed chat
|
||||
const resumeResponsesStream = createWriteStream(resumeResponsesPath);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
resumeResponsesStream.write(JSON.stringify(complexityResponse) + '\n');
|
||||
resumeResponsesStream.write(
|
||||
JSON.stringify({
|
||||
method: 'generateContentStream',
|
||||
response: [
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: `Resume response ${i}` }],
|
||||
role: 'model',
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 10,
|
||||
totalTokenCount: 20,
|
||||
promptTokensDetails: [{ modality: 'TEXT', tokenCount: 10 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}) + '\n',
|
||||
);
|
||||
resumeResponsesStream.write(JSON.stringify(summaryResponse) + '\n');
|
||||
}
|
||||
resumeResponsesStream.end();
|
||||
|
||||
// Wait for streams to finish
|
||||
await Promise.all([
|
||||
new Promise((res) => activeResponsesStream.on('finish', res)),
|
||||
new Promise((res) => resumeResponsesStream.on('finish', res)),
|
||||
]);
|
||||
|
||||
return {
|
||||
resumeResponsesPath,
|
||||
activeResponsesPath,
|
||||
historyPath,
|
||||
prompts: promptsList.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
Generated
+550
-81
@@ -74,13 +74,13 @@
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@github/keytar": "^7.10.6",
|
||||
"@lydell/node-pty": "1.1.0",
|
||||
"@lydell/node-pty-darwin-arm64": "1.1.0",
|
||||
"@lydell/node-pty-darwin-x64": "1.1.0",
|
||||
"@lydell/node-pty-linux-x64": "1.1.0",
|
||||
"@lydell/node-pty-win32-arm64": "1.1.0",
|
||||
"@lydell/node-pty-win32-x64": "1.1.0",
|
||||
"keytar": "^7.9.0",
|
||||
"node-pty": "^1.0.0"
|
||||
}
|
||||
},
|
||||
@@ -1099,6 +1099,27 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/keytar": {
|
||||
"version": "7.10.6",
|
||||
"resolved": "https://registry.npmjs.org/@github/keytar/-/keytar-7.10.6.tgz",
|
||||
"integrity": "sha512-mRW6cUsSG+nj4jp5gp8e91zPySaT73r+2JM6VyMZfrEgksjPmjSMr+tPGNOK3HUHV+GUU9B1LAiiYy/wmAnIxA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/keytar/node_modules/node-addon-api": {
|
||||
"version": "8.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
|
||||
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/@google-cloud/common": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-5.0.2.tgz",
|
||||
@@ -1477,9 +1498,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
|
||||
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
|
||||
"version": "1.19.13",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz",
|
||||
"integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
@@ -5820,9 +5841,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/basic-ftp": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz",
|
||||
"integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==",
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.2.tgz",
|
||||
"integrity": "sha512-1tDrzKsdCg70WGvbFss/ulVAxupNauGnOlgpyjKzeQxzyllBLS0CGLV7tjIXTK3ZQA9/FBEm9qyFFN1bciA6pw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -5915,9 +5936,9 @@
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -6761,9 +6782,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cosmiconfig/node_modules/yaml": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
||||
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
|
||||
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
@@ -7847,6 +7868,7 @@
|
||||
"version": "0.25.6",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz",
|
||||
"integrity": "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -9778,9 +9800,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.7",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
|
||||
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
|
||||
"version": "4.12.12",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
|
||||
"integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -10817,6 +10839,18 @@
|
||||
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isbinaryfile": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz",
|
||||
"integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/gjtorikian/"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -11197,26 +11231,6 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/keytar": {
|
||||
"version": "7.9.0",
|
||||
"resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz",
|
||||
"integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "^4.3.0",
|
||||
"prebuild-install": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/keytar/node_modules/prebuild-install": {
|
||||
"name": "nop",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nop/-/nop-1.0.0.tgz",
|
||||
"integrity": "sha512-XdkOuXGx0DTwlqb0DWTcDqelgU/F3YyZ+PTRaecpDVpkYskcnh3OeUYKfvjcRQ2D1diTIGxi/a3eHVjW5yPupQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -11494,9 +11508,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -12239,13 +12253,6 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz",
|
||||
"integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
@@ -12553,9 +12560,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/npm-run-all2/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -13238,6 +13245,16 @@
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz",
|
||||
@@ -13288,9 +13305,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14371,15 +14388,6 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/router/node_modules/path-to-regexp": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
|
||||
"integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/run-applescript": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz",
|
||||
@@ -15988,9 +15996,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -16605,12 +16613,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.2.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
@@ -16700,6 +16708,463 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/esbuild": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.7",
|
||||
"@esbuild/android-arm": "0.27.7",
|
||||
"@esbuild/android-arm64": "0.27.7",
|
||||
"@esbuild/android-x64": "0.27.7",
|
||||
"@esbuild/darwin-arm64": "0.27.7",
|
||||
"@esbuild/darwin-x64": "0.27.7",
|
||||
"@esbuild/freebsd-arm64": "0.27.7",
|
||||
"@esbuild/freebsd-x64": "0.27.7",
|
||||
"@esbuild/linux-arm": "0.27.7",
|
||||
"@esbuild/linux-arm64": "0.27.7",
|
||||
"@esbuild/linux-ia32": "0.27.7",
|
||||
"@esbuild/linux-loong64": "0.27.7",
|
||||
"@esbuild/linux-mips64el": "0.27.7",
|
||||
"@esbuild/linux-ppc64": "0.27.7",
|
||||
"@esbuild/linux-riscv64": "0.27.7",
|
||||
"@esbuild/linux-s390x": "0.27.7",
|
||||
"@esbuild/linux-x64": "0.27.7",
|
||||
"@esbuild/netbsd-arm64": "0.27.7",
|
||||
"@esbuild/netbsd-x64": "0.27.7",
|
||||
"@esbuild/openbsd-arm64": "0.27.7",
|
||||
"@esbuild/openbsd-x64": "0.27.7",
|
||||
"@esbuild/openharmony-arm64": "0.27.7",
|
||||
"@esbuild/sunos-x64": "0.27.7",
|
||||
"@esbuild/win32-arm64": "0.27.7",
|
||||
"@esbuild/win32-ia32": "0.27.7",
|
||||
"@esbuild/win32-x64": "0.27.7"
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -16718,9 +17183,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -16802,9 +17267,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -17234,15 +17699,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
||||
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
@@ -17731,6 +18199,7 @@
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"ignore": "^7.0.0",
|
||||
"ipaddr.js": "^1.9.1",
|
||||
"isbinaryfile": "^5.0.7",
|
||||
"js-yaml": "^4.1.1",
|
||||
"json-stable-stringify": "^1.3.0",
|
||||
"marked": "^15.0.12",
|
||||
@@ -17768,13 +18237,13 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@github/keytar": "^7.10.6",
|
||||
"@lydell/node-pty": "1.1.0",
|
||||
"@lydell/node-pty-darwin-arm64": "1.1.0",
|
||||
"@lydell/node-pty-darwin-x64": "1.1.0",
|
||||
"@lydell/node-pty-linux-x64": "1.1.0",
|
||||
"@lydell/node-pty-win32-arm64": "1.1.0",
|
||||
"@lydell/node-pty-win32-x64": "1.1.0",
|
||||
"keytar": "^7.9.0",
|
||||
"node-pty": "^1.0.0"
|
||||
}
|
||||
},
|
||||
@@ -17923,9 +18392,9 @@
|
||||
}
|
||||
},
|
||||
"packages/core/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
|
||||
+1
-1
@@ -150,13 +150,13 @@
|
||||
"simple-git": "^3.28.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@github/keytar": "^7.10.6",
|
||||
"@lydell/node-pty": "1.1.0",
|
||||
"@lydell/node-pty-darwin-arm64": "1.1.0",
|
||||
"@lydell/node-pty-darwin-x64": "1.1.0",
|
||||
"@lydell/node-pty-linux-x64": "1.1.0",
|
||||
"@lydell/node-pty-win32-arm64": "1.1.0",
|
||||
"@lydell/node-pty-win32-x64": "1.1.0",
|
||||
"keytar": "^7.9.0",
|
||||
"node-pty": "^1.0.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
|
||||
@@ -101,8 +101,8 @@ export class AddMemoryCommand implements Command {
|
||||
const tool = toolRegistry.getTool(result.toolName);
|
||||
if (tool) {
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
await tool.buildAndExecute(result.toolArgs, signal, undefined, {
|
||||
const abortSignal = abortController.signal;
|
||||
await tool.buildAndExecute(result.toolArgs, abortSignal, undefined, {
|
||||
shellExecutionConfig: {
|
||||
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
|
||||
sandboxManager: loopContext.sandboxManager,
|
||||
|
||||
@@ -1129,7 +1129,9 @@ export class Session {
|
||||
});
|
||||
}
|
||||
|
||||
const toolResult: ToolResult = await invocation.execute(abortSignal);
|
||||
const toolResult: ToolResult = await invocation.execute({
|
||||
abortSignal,
|
||||
});
|
||||
const content = toToolCallContent(toolResult);
|
||||
|
||||
const updateContent: acp.ToolCallContent[] = content ? [content] : [];
|
||||
@@ -1671,7 +1673,7 @@ export class Session {
|
||||
kind: toAcpToolKind(readManyFilesTool.kind),
|
||||
});
|
||||
|
||||
const result = await invocation.execute(abortSignal);
|
||||
const result = await invocation.execute({ abortSignal });
|
||||
const content = toToolCallContent(result) || {
|
||||
type: 'content',
|
||||
content: {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import {
|
||||
addMemory,
|
||||
listInboxSkills,
|
||||
listInboxPatches,
|
||||
listMemoryFiles,
|
||||
refreshMemory,
|
||||
showMemory,
|
||||
@@ -141,22 +142,34 @@ export class InboxMemoryCommand implements Command {
|
||||
};
|
||||
}
|
||||
|
||||
const skills = await listInboxSkills(context.agentContext.config);
|
||||
const [skills, patches] = await Promise.all([
|
||||
listInboxSkills(context.agentContext.config),
|
||||
listInboxPatches(context.agentContext.config),
|
||||
]);
|
||||
|
||||
if (skills.length === 0) {
|
||||
return { name: this.name, data: 'No extracted skills in inbox.' };
|
||||
if (skills.length === 0 && patches.length === 0) {
|
||||
return { name: this.name, data: 'No items in inbox.' };
|
||||
}
|
||||
|
||||
const lines = skills.map((s) => {
|
||||
const lines: string[] = [];
|
||||
for (const s of skills) {
|
||||
const date = s.extractedAt
|
||||
? ` (extracted: ${new Date(s.extractedAt).toLocaleDateString()})`
|
||||
: '';
|
||||
return `- **${s.name}**: ${s.description}${date}`;
|
||||
});
|
||||
lines.push(`- **${s.name}**: ${s.description}${date}`);
|
||||
}
|
||||
for (const p of patches) {
|
||||
const targets = p.entries.map((e) => e.targetPath).join(', ');
|
||||
const date = p.extractedAt
|
||||
? ` (extracted: ${new Date(p.extractedAt).toLocaleDateString()})`
|
||||
: '';
|
||||
lines.push(`- **${p.name}** (update): patches ${targets}${date}`);
|
||||
}
|
||||
|
||||
const total = skills.length + patches.length;
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Skill inbox (${skills.length}):\n${lines.join('\n')}`,
|
||||
data: `Memory inbox (${total}):\n${lines.join('\n')}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ describe('fetchJson', () => {
|
||||
const res = new EventEmitter() as IncomingMessage;
|
||||
res.statusCode = 302;
|
||||
res.headers = { location: 'https://example.com/final' };
|
||||
res.resume = vi.fn();
|
||||
(callback as (res: IncomingMessage) => void)(res);
|
||||
res.emit('end');
|
||||
return new EventEmitter() as ClientRequest;
|
||||
@@ -85,6 +86,7 @@ describe('fetchJson', () => {
|
||||
const res = new EventEmitter() as IncomingMessage;
|
||||
res.statusCode = 301;
|
||||
res.headers = { location: 'https://example.com/final-permanent' };
|
||||
res.resume = vi.fn();
|
||||
(callback as (res: IncomingMessage) => void)(res);
|
||||
res.emit('end');
|
||||
return new EventEmitter() as ClientRequest;
|
||||
|
||||
@@ -31,7 +31,11 @@ export async function fetchJson<T>(
|
||||
if (!res.headers.location) {
|
||||
return reject(new Error('No location header in redirect response'));
|
||||
}
|
||||
fetchJson<T>(res.headers.location, redirectCount++)
|
||||
res.resume();
|
||||
fetchJson<T>(
|
||||
new URL(res.headers.location, url).toString(),
|
||||
redirectCount + 1,
|
||||
)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
return;
|
||||
|
||||
@@ -34,8 +34,8 @@ export const ALL_ITEMS = [
|
||||
},
|
||||
{
|
||||
id: 'quota',
|
||||
header: '/stats',
|
||||
description: 'Remaining usage on daily limit (not shown when unavailable)',
|
||||
header: 'quota',
|
||||
description: 'Percentage of daily limit used (not shown when unavailable)',
|
||||
},
|
||||
{
|
||||
id: 'memory-usage',
|
||||
|
||||
@@ -1428,12 +1428,13 @@ describe('startInteractiveUI', () => {
|
||||
vi.mock('./ui/utils/updateCheck.js', () => ({
|
||||
checkForUpdates: vi.fn(() => Promise.resolve(null)),
|
||||
}));
|
||||
|
||||
vi.mock('./utils/cleanup.js', () => ({
|
||||
cleanupCheckpoints: vi.fn(() => Promise.resolve()),
|
||||
registerCleanup: vi.fn(),
|
||||
removeCleanup: vi.fn(),
|
||||
runExitCleanup: vi.fn(),
|
||||
registerSyncCleanup: vi.fn(),
|
||||
removeSyncCleanup: vi.fn(),
|
||||
registerTelemetryConfig: vi.fn(),
|
||||
setupSignalHandlers: vi.fn(),
|
||||
setupTtyCheck: vi.fn(() => vi.fn()),
|
||||
|
||||
@@ -142,7 +142,9 @@ vi.mock('./utils/cleanup.js', async (importOriginal) => {
|
||||
...actual,
|
||||
cleanupCheckpoints: vi.fn().mockResolvedValue(undefined),
|
||||
registerCleanup: vi.fn(),
|
||||
removeCleanup: vi.fn(),
|
||||
registerSyncCleanup: vi.fn(),
|
||||
removeSyncCleanup: vi.fn(),
|
||||
registerTelemetryConfig: vi.fn(),
|
||||
runExitCleanup: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
@@ -9,7 +9,11 @@ import { render } from 'ink';
|
||||
import { basename } from 'node:path';
|
||||
import { AppContainer } from './ui/AppContainer.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { registerCleanup, setupTtyCheck } from './utils/cleanup.js';
|
||||
import {
|
||||
registerCleanup,
|
||||
removeCleanup,
|
||||
setupTtyCheck,
|
||||
} from './utils/cleanup.js';
|
||||
import {
|
||||
type StartupWarning,
|
||||
type Config,
|
||||
@@ -89,7 +93,6 @@ export async function startInteractiveUI(
|
||||
debugMode: config.getDebugMode(),
|
||||
});
|
||||
consolePatcher.patch();
|
||||
registerCleanup(consolePatcher.cleanup);
|
||||
|
||||
const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio();
|
||||
|
||||
@@ -167,11 +170,11 @@ export async function startInteractiveUI(
|
||||
},
|
||||
);
|
||||
|
||||
let cleanupLineWrapping: (() => void) | undefined;
|
||||
if (useAlternateBuffer) {
|
||||
disableLineWrapping();
|
||||
registerCleanup(() => {
|
||||
enableLineWrapping();
|
||||
});
|
||||
cleanupLineWrapping = () => enableLineWrapping();
|
||||
registerCleanup(cleanupLineWrapping);
|
||||
}
|
||||
|
||||
checkForUpdates(settings)
|
||||
@@ -185,9 +188,48 @@ export async function startInteractiveUI(
|
||||
}
|
||||
});
|
||||
|
||||
registerCleanup(() => instance.unmount());
|
||||
const cleanupUnmount = () => instance.unmount();
|
||||
registerCleanup(cleanupUnmount);
|
||||
|
||||
registerCleanup(setupTtyCheck());
|
||||
const cleanupTtyCheck = setupTtyCheck();
|
||||
registerCleanup(cleanupTtyCheck);
|
||||
|
||||
const cleanupConsolePatcher = () => consolePatcher.cleanup();
|
||||
registerCleanup(cleanupConsolePatcher);
|
||||
|
||||
try {
|
||||
await instance.waitUntilExit();
|
||||
} finally {
|
||||
try {
|
||||
removeCleanup(cleanupConsolePatcher);
|
||||
cleanupConsolePatcher();
|
||||
} catch (e: unknown) {
|
||||
debugLogger.error('Error cleaning up console patcher:', e);
|
||||
}
|
||||
|
||||
try {
|
||||
removeCleanup(cleanupUnmount);
|
||||
instance.unmount();
|
||||
} catch (e: unknown) {
|
||||
debugLogger.error('Error unmounting Ink instance:', e);
|
||||
}
|
||||
|
||||
try {
|
||||
removeCleanup(cleanupTtyCheck);
|
||||
cleanupTtyCheck();
|
||||
} catch (e: unknown) {
|
||||
debugLogger.error('Error in TTY cleanup:', e);
|
||||
}
|
||||
|
||||
if (cleanupLineWrapping) {
|
||||
try {
|
||||
removeCleanup(cleanupLineWrapping);
|
||||
cleanupLineWrapping();
|
||||
} catch (e: unknown) {
|
||||
debugLogger.error('Error restoring line wrapping:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setWindowTitle(title: string, settings: LoadedSettings) {
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
LegacyAgentSession,
|
||||
ToolErrorType,
|
||||
geminiPartsToContentParts,
|
||||
displayContentToString,
|
||||
debugLogger,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
@@ -470,7 +471,8 @@ export async function runNonInteractive({
|
||||
case 'tool_response': {
|
||||
textOutput.ensureTrailingNewline();
|
||||
if (streamFormatter) {
|
||||
const displayText = getTextContent(event.displayContent);
|
||||
const display = event.display?.result;
|
||||
const displayText = displayContentToString(display);
|
||||
const errorMsg = getTextContent(event.content) ?? 'Tool error';
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.TOOL_RESULT,
|
||||
@@ -490,7 +492,8 @@ export async function runNonInteractive({
|
||||
});
|
||||
}
|
||||
if (event.isError) {
|
||||
const displayText = getTextContent(event.displayContent);
|
||||
const display = event.display?.result;
|
||||
const displayText = displayContentToString(display);
|
||||
const errorMsg = getTextContent(event.content) ?? 'Tool error';
|
||||
|
||||
if (event.data?.['errorType'] === ToolErrorType.STOP_EXECUTION) {
|
||||
|
||||
@@ -602,6 +602,7 @@ const mockUIActions: UIActions = {
|
||||
|
||||
import { type TextBuffer } from '../ui/components/shared/text-buffer.js';
|
||||
import { InputContext, type InputState } from '../ui/contexts/InputContext.js';
|
||||
import { QuotaContext, type QuotaState } from '../ui/contexts/QuotaContext.js';
|
||||
|
||||
let capturedOverflowState: OverflowState | undefined;
|
||||
let capturedOverflowActions: OverflowActions | undefined;
|
||||
@@ -619,6 +620,7 @@ export const renderWithProviders = async (
|
||||
shellFocus = true,
|
||||
settings = mockSettings,
|
||||
uiState: providedUiState,
|
||||
quotaState: providedQuotaState,
|
||||
inputState: providedInputState,
|
||||
width,
|
||||
mouseEventsEnabled = false,
|
||||
@@ -631,6 +633,7 @@ export const renderWithProviders = async (
|
||||
shellFocus?: boolean;
|
||||
settings?: LoadedSettings;
|
||||
uiState?: Partial<UIState>;
|
||||
quotaState?: Partial<QuotaState>;
|
||||
inputState?: Partial<InputState>;
|
||||
width?: number;
|
||||
mouseEventsEnabled?: boolean;
|
||||
@@ -666,6 +669,16 @@ export const renderWithProviders = async (
|
||||
},
|
||||
) as UIState;
|
||||
|
||||
const quotaState: QuotaState = {
|
||||
userTier: undefined,
|
||||
stats: undefined,
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
overageMenuRequest: null,
|
||||
emptyWalletRequest: null,
|
||||
...providedQuotaState,
|
||||
};
|
||||
|
||||
const inputState = {
|
||||
buffer: { text: '' } as unknown as TextBuffer,
|
||||
userMessages: [],
|
||||
@@ -727,65 +740,67 @@ export const renderWithProviders = async (
|
||||
<AppContext.Provider value={appState}>
|
||||
<ConfigContext.Provider value={config}>
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<InputContext.Provider value={inputState}>
|
||||
<UIStateContext.Provider value={finalUiState}>
|
||||
<VimModeProvider>
|
||||
<ShellFocusContext.Provider value={shellFocus}>
|
||||
<SessionStatsProvider sessionId={config.getSessionId()}>
|
||||
<StreamingContext.Provider
|
||||
value={finalUiState.streamingState}
|
||||
>
|
||||
<UIActionsContext.Provider value={finalUIActions}>
|
||||
<OverflowProvider>
|
||||
<ToolActionsProvider
|
||||
config={config}
|
||||
toolCalls={allToolCalls}
|
||||
isExpanded={
|
||||
toolActions?.isExpanded ??
|
||||
vi.fn().mockReturnValue(false)
|
||||
}
|
||||
toggleExpansion={
|
||||
toolActions?.toggleExpansion ?? vi.fn()
|
||||
}
|
||||
toggleAllExpansion={
|
||||
toolActions?.toggleAllExpansion ?? vi.fn()
|
||||
}
|
||||
>
|
||||
<AskUserActionsProvider
|
||||
request={null}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
<QuotaContext.Provider value={quotaState}>
|
||||
<InputContext.Provider value={inputState}>
|
||||
<UIStateContext.Provider value={finalUiState}>
|
||||
<VimModeProvider>
|
||||
<ShellFocusContext.Provider value={shellFocus}>
|
||||
<SessionStatsProvider sessionId={config.getSessionId()}>
|
||||
<StreamingContext.Provider
|
||||
value={finalUiState.streamingState}
|
||||
>
|
||||
<UIActionsContext.Provider value={finalUIActions}>
|
||||
<OverflowProvider>
|
||||
<ToolActionsProvider
|
||||
config={config}
|
||||
toolCalls={allToolCalls}
|
||||
isExpanded={
|
||||
toolActions?.isExpanded ??
|
||||
vi.fn().mockReturnValue(false)
|
||||
}
|
||||
toggleExpansion={
|
||||
toolActions?.toggleExpansion ?? vi.fn()
|
||||
}
|
||||
toggleAllExpansion={
|
||||
toolActions?.toggleAllExpansion ?? vi.fn()
|
||||
}
|
||||
>
|
||||
<KeypressProvider>
|
||||
<MouseProvider
|
||||
mouseEventsEnabled={mouseEventsEnabled}
|
||||
>
|
||||
<TerminalProvider>
|
||||
<ScrollProvider>
|
||||
<ContextCapture>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{comp}
|
||||
</Box>
|
||||
</ContextCapture>
|
||||
</ScrollProvider>
|
||||
</TerminalProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</AskUserActionsProvider>
|
||||
</ToolActionsProvider>
|
||||
</OverflowProvider>
|
||||
</UIActionsContext.Provider>
|
||||
</StreamingContext.Provider>
|
||||
</SessionStatsProvider>
|
||||
</ShellFocusContext.Provider>
|
||||
</VimModeProvider>
|
||||
</UIStateContext.Provider>
|
||||
</InputContext.Provider>
|
||||
<AskUserActionsProvider
|
||||
request={null}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
>
|
||||
<KeypressProvider>
|
||||
<MouseProvider
|
||||
mouseEventsEnabled={mouseEventsEnabled}
|
||||
>
|
||||
<TerminalProvider>
|
||||
<ScrollProvider>
|
||||
<ContextCapture>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{comp}
|
||||
</Box>
|
||||
</ContextCapture>
|
||||
</ScrollProvider>
|
||||
</TerminalProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</AskUserActionsProvider>
|
||||
</ToolActionsProvider>
|
||||
</OverflowProvider>
|
||||
</UIActionsContext.Provider>
|
||||
</StreamingContext.Provider>
|
||||
</SessionStatsProvider>
|
||||
</ShellFocusContext.Provider>
|
||||
</VimModeProvider>
|
||||
</UIStateContext.Provider>
|
||||
</InputContext.Provider>
|
||||
</QuotaContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
</AppContext.Provider>
|
||||
|
||||
@@ -123,16 +123,19 @@ vi.mock('ink', async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { InputContext, type InputState } from './contexts/InputContext.js';
|
||||
import { QuotaContext, type QuotaState } from './contexts/QuotaContext.js';
|
||||
|
||||
// Helper component will read the context values provided by AppContainer
|
||||
// so we can assert against them in our tests.
|
||||
let capturedUIState: UIState;
|
||||
let capturedInputState: InputState;
|
||||
let capturedQuotaState: QuotaState;
|
||||
let capturedUIActions: UIActions;
|
||||
let capturedOverflowActions: OverflowActions;
|
||||
function TestContextConsumer() {
|
||||
capturedUIState = useContext(UIStateContext)!;
|
||||
capturedInputState = useContext(InputContext)!;
|
||||
capturedQuotaState = useContext(QuotaContext)!;
|
||||
capturedUIActions = useContext(UIActionsContext)!;
|
||||
capturedOverflowActions = useOverflowActions()!;
|
||||
return null;
|
||||
@@ -1309,15 +1312,15 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('Quota and Fallback Integration', () => {
|
||||
it('passes a null proQuotaRequest to UIStateContext by default', async () => {
|
||||
it('passes a null proQuotaRequest to QuotaContext by default', async () => {
|
||||
// The default mock from beforeEach already sets proQuotaRequest to null
|
||||
const { unmount } = await act(async () => renderAppContainer());
|
||||
// Assert that the context value is as expected
|
||||
expect(capturedUIState.quota.proQuotaRequest).toBeNull();
|
||||
expect(capturedQuotaState.proQuotaRequest).toBeNull();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('passes a valid proQuotaRequest to UIStateContext when provided by the hook', async () => {
|
||||
it('passes a valid proQuotaRequest to QuotaContext when provided by the hook', async () => {
|
||||
// Arrange: Create a mock request object that a UI dialog would receive
|
||||
const mockRequest = {
|
||||
failedModel: 'gemini-pro',
|
||||
@@ -1332,7 +1335,7 @@ describe('AppContainer State Management', () => {
|
||||
// Act: Render the container
|
||||
const { unmount } = await act(async () => renderAppContainer());
|
||||
// Assert: The mock request is correctly passed through the context
|
||||
expect(capturedUIState.quota.proQuotaRequest).toEqual(mockRequest);
|
||||
expect(capturedQuotaState.proQuotaRequest).toEqual(mockRequest);
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { App } from './App.js';
|
||||
import { AppContext } from './contexts/AppContext.js';
|
||||
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
|
||||
import { QuotaContext } from './contexts/QuotaContext.js';
|
||||
import {
|
||||
UIActionsContext,
|
||||
type UIActions,
|
||||
@@ -136,7 +137,11 @@ import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
|
||||
import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js';
|
||||
import { type UpdateObject } from './utils/updateCheck.js';
|
||||
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
|
||||
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
|
||||
import {
|
||||
registerCleanup,
|
||||
removeCleanup,
|
||||
runExitCleanup,
|
||||
} from '../utils/cleanup.js';
|
||||
import { relaunchApp } from '../utils/processUtils.js';
|
||||
import type { SessionInfo } from '../utils/sessionUtils.js';
|
||||
import { useMessageQueue } from './hooks/useMessageQueue.js';
|
||||
@@ -519,7 +524,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
debugLogger.warn('Background summary generation failed:', e);
|
||||
});
|
||||
})();
|
||||
registerCleanup(async () => {
|
||||
const cleanupFn = async () => {
|
||||
// Turn off mouse scroll.
|
||||
disableMouseEvents();
|
||||
|
||||
@@ -535,7 +540,15 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
// Fire SessionEnd hook on cleanup (only if hooks are enabled)
|
||||
await config?.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
|
||||
});
|
||||
};
|
||||
registerCleanup(cleanupFn);
|
||||
|
||||
return () => {
|
||||
removeCleanup(cleanupFn);
|
||||
cleanupFn().catch((e: unknown) =>
|
||||
debugLogger.error('Error during cleanup:', e),
|
||||
);
|
||||
};
|
||||
// Disable the dependencies check here. historyManager gets flagged
|
||||
// but we don't want to react to changes to it because each new history
|
||||
// item, including the ones from the start session hook will cause a
|
||||
@@ -2389,6 +2402,26 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
],
|
||||
);
|
||||
|
||||
const quotaState = useMemo(
|
||||
() => ({
|
||||
userTier,
|
||||
stats: quotaStats,
|
||||
proQuotaRequest,
|
||||
validationRequest,
|
||||
// G1 AI Credits dialog state
|
||||
overageMenuRequest,
|
||||
emptyWalletRequest,
|
||||
}),
|
||||
[
|
||||
userTier,
|
||||
quotaStats,
|
||||
proQuotaRequest,
|
||||
validationRequest,
|
||||
overageMenuRequest,
|
||||
emptyWalletRequest,
|
||||
],
|
||||
);
|
||||
|
||||
const uiState: UIState = useMemo(
|
||||
() => ({
|
||||
history: historyManager.history,
|
||||
@@ -2461,15 +2494,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
showApprovalModeIndicator,
|
||||
allowPlanMode,
|
||||
currentModel,
|
||||
quota: {
|
||||
userTier,
|
||||
stats: quotaStats,
|
||||
proQuotaRequest,
|
||||
validationRequest,
|
||||
// G1 AI Credits dialog state
|
||||
overageMenuRequest,
|
||||
emptyWalletRequest,
|
||||
},
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
availableTerminalHeight,
|
||||
@@ -2580,12 +2604,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
queueErrorMessage,
|
||||
showApprovalModeIndicator,
|
||||
allowPlanMode,
|
||||
userTier,
|
||||
quotaStats,
|
||||
proQuotaRequest,
|
||||
validationRequest,
|
||||
overageMenuRequest,
|
||||
emptyWalletRequest,
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
availableTerminalHeight,
|
||||
@@ -2804,34 +2822,36 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
return (
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<InputContext.Provider value={inputState}>
|
||||
<UIActionsContext.Provider value={uiActions}>
|
||||
<ConfigContext.Provider value={config}>
|
||||
<AppContext.Provider
|
||||
value={{
|
||||
version: props.version,
|
||||
startupWarnings: props.startupWarnings || [],
|
||||
}}
|
||||
>
|
||||
<ToolActionsProvider
|
||||
config={config}
|
||||
toolCalls={allToolCalls}
|
||||
isExpanded={isExpanded}
|
||||
toggleExpansion={toggleExpansion}
|
||||
toggleAllExpansion={toggleAllExpansion}
|
||||
<QuotaContext.Provider value={quotaState}>
|
||||
<InputContext.Provider value={inputState}>
|
||||
<UIActionsContext.Provider value={uiActions}>
|
||||
<ConfigContext.Provider value={config}>
|
||||
<AppContext.Provider
|
||||
value={{
|
||||
version: props.version,
|
||||
startupWarnings: props.startupWarnings || [],
|
||||
}}
|
||||
>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
<MouseProvider mouseEventsEnabled={mouseMode}>
|
||||
<ScrollProvider>
|
||||
<App key={`app-${forceRerenderKey}`} />
|
||||
</ScrollProvider>
|
||||
</MouseProvider>
|
||||
</ShellFocusContext.Provider>
|
||||
</ToolActionsProvider>
|
||||
</AppContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
</UIActionsContext.Provider>
|
||||
</InputContext.Provider>
|
||||
<ToolActionsProvider
|
||||
config={config}
|
||||
toolCalls={allToolCalls}
|
||||
isExpanded={isExpanded}
|
||||
toggleExpansion={toggleExpansion}
|
||||
toggleAllExpansion={toggleAllExpansion}
|
||||
>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
<MouseProvider mouseEventsEnabled={mouseMode}>
|
||||
<ScrollProvider>
|
||||
<App key={`app-${forceRerenderKey}`} />
|
||||
</ScrollProvider>
|
||||
</MouseProvider>
|
||||
</ShellFocusContext.Provider>
|
||||
</ToolActionsProvider>
|
||||
</AppContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
</UIActionsContext.Provider>
|
||||
</InputContext.Provider>
|
||||
</QuotaContext.Provider>
|
||||
</UIStateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
CoreToolCallStatus,
|
||||
ApprovalMode,
|
||||
makeFakeConfig,
|
||||
type SerializableConfirmationDetails,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type UIState } from './contexts/UIStateContext.js';
|
||||
import type { SerializableConfirmationDetails } from '@google/gemini-cli-core';
|
||||
import { act } from 'react';
|
||||
import { StreamingState } from './types.js';
|
||||
|
||||
@@ -107,15 +107,6 @@ describe('Full Terminal Tool Confirmation Snapshot', () => {
|
||||
constrainHeight: true,
|
||||
isConfigInitialized: true,
|
||||
cleanUiDetailsVisible: true,
|
||||
quota: {
|
||||
userTier: 'PRO',
|
||||
stats: {
|
||||
limits: {},
|
||||
usage: {},
|
||||
},
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
},
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
id: 2,
|
||||
@@ -145,6 +136,13 @@ describe('Full Terminal Tool Confirmation Snapshot', () => {
|
||||
const { waitUntilReady, lastFrame, generateSvg, unmount } =
|
||||
await renderWithProviders(<App />, {
|
||||
uiState: mockUIState,
|
||||
quotaState: {
|
||||
userTier: 'PRO',
|
||||
stats: {
|
||||
remaining: 100,
|
||||
limit: 1000,
|
||||
},
|
||||
},
|
||||
config: mockConfig,
|
||||
settings: createMockSettings({
|
||||
merged: {
|
||||
|
||||
@@ -15,7 +15,6 @@ export const settingsCommand: SlashCommand = {
|
||||
description: 'View and edit Gemini CLI settings',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
isSafeConcurrent: true,
|
||||
action: (_context, _args): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'settings',
|
||||
|
||||
@@ -201,12 +201,6 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
isBackgroundTaskVisible: false,
|
||||
embeddedShellFocused: false,
|
||||
showIsExpandableHint: false,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: undefined,
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
},
|
||||
...overrides,
|
||||
}) as UIState;
|
||||
|
||||
@@ -245,6 +239,7 @@ const createMockConfig = (overrides = {}): Config =>
|
||||
...overrides,
|
||||
}) as unknown as Config;
|
||||
|
||||
import { QuotaContext, type QuotaState } from '../contexts/QuotaContext.js';
|
||||
import { InputContext, type InputState } from '../contexts/InputContext.js';
|
||||
|
||||
const renderComposer = async (
|
||||
@@ -253,6 +248,7 @@ const renderComposer = async (
|
||||
config = createMockConfig(),
|
||||
uiActions = createMockUIActions(),
|
||||
inputStateOverrides: Partial<InputState> = {},
|
||||
quotaStateOverrides: Partial<QuotaState> = {},
|
||||
) => {
|
||||
const inputState = {
|
||||
buffer: { text: '' } as unknown as TextBuffer,
|
||||
@@ -266,16 +262,28 @@ const renderComposer = async (
|
||||
...inputStateOverrides,
|
||||
};
|
||||
|
||||
const quotaState: QuotaState = {
|
||||
userTier: undefined,
|
||||
stats: undefined,
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
overageMenuRequest: null,
|
||||
emptyWalletRequest: null,
|
||||
...quotaStateOverrides,
|
||||
};
|
||||
|
||||
const result = await render(
|
||||
<ConfigContext.Provider value={config as unknown as Config}>
|
||||
<SettingsContext.Provider value={settings as unknown as LoadedSettings}>
|
||||
<InputContext.Provider value={inputState}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<UIActionsContext.Provider value={uiActions}>
|
||||
<Composer isFocused={true} />
|
||||
</UIActionsContext.Provider>
|
||||
</UIStateContext.Provider>
|
||||
</InputContext.Provider>
|
||||
<QuotaContext.Provider value={quotaState}>
|
||||
<InputContext.Provider value={inputState}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<UIActionsContext.Provider value={uiActions}>
|
||||
<Composer isFocused={true} />
|
||||
</UIActionsContext.Provider>
|
||||
</UIStateContext.Provider>
|
||||
</InputContext.Provider>
|
||||
</QuotaContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DialogManager } from './DialogManager.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Text } from 'ink';
|
||||
import { type UIState } from '../contexts/UIStateContext.js';
|
||||
import { type QuotaState } from '../contexts/QuotaContext.js';
|
||||
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
|
||||
import { type IdeInfo } from '@google/gemini-cli-core';
|
||||
|
||||
@@ -75,14 +76,6 @@ describe('DialogManager', () => {
|
||||
terminalWidth: 80,
|
||||
confirmUpdateExtensionRequests: [],
|
||||
showIdeRestartPrompt: false,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: undefined,
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
overageMenuRequest: null,
|
||||
emptyWalletRequest: null,
|
||||
},
|
||||
shouldShowIdePrompt: false,
|
||||
isFolderTrustDialogOpen: false,
|
||||
loopDetectionConfirmationRequest: null,
|
||||
@@ -112,7 +105,7 @@ describe('DialogManager', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
const testCases: Array<[Partial<UIState>, string]> = [
|
||||
const testCases: Array<[Partial<UIState>, string, Partial<QuotaState>?]> = [
|
||||
[
|
||||
{
|
||||
showIdeRestartPrompt: true,
|
||||
@@ -121,23 +114,17 @@ describe('DialogManager', () => {
|
||||
'IdeTrustChangeDialog',
|
||||
],
|
||||
[
|
||||
{},
|
||||
'ProQuotaDialog',
|
||||
{
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: undefined,
|
||||
proQuotaRequest: {
|
||||
failedModel: 'a',
|
||||
fallbackModel: 'b',
|
||||
message: 'c',
|
||||
isTerminalQuotaError: false,
|
||||
resolve: vi.fn(),
|
||||
},
|
||||
validationRequest: null,
|
||||
overageMenuRequest: null,
|
||||
emptyWalletRequest: null,
|
||||
proQuotaRequest: {
|
||||
failedModel: 'a',
|
||||
fallbackModel: 'b',
|
||||
message: 'c',
|
||||
isTerminalQuotaError: false,
|
||||
resolve: vi.fn(),
|
||||
},
|
||||
},
|
||||
'ProQuotaDialog',
|
||||
],
|
||||
[
|
||||
{
|
||||
@@ -195,7 +182,11 @@ describe('DialogManager', () => {
|
||||
|
||||
it.each(testCases)(
|
||||
'renders %s when state is %o',
|
||||
async (uiStateOverride, expectedComponent) => {
|
||||
async (
|
||||
uiStateOverride: Partial<UIState>,
|
||||
expectedComponent: string,
|
||||
quotaStateOverride?: Partial<QuotaState>,
|
||||
) => {
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<DialogManager {...defaultProps} />,
|
||||
{
|
||||
@@ -203,6 +194,7 @@ describe('DialogManager', () => {
|
||||
...baseUiState,
|
||||
...uiStateOverride,
|
||||
} as Partial<UIState> as UIState,
|
||||
quotaState: quotaStateOverride,
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toContain(expectedComponent);
|
||||
|
||||
@@ -27,6 +27,7 @@ import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js'
|
||||
import { ModelDialog } from './ModelDialog.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useQuotaState } from '../contexts/QuotaContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
@@ -52,6 +53,7 @@ export const DialogManager = ({
|
||||
const settings = useSettings();
|
||||
|
||||
const uiState = useUIState();
|
||||
const quotaState = useQuotaState();
|
||||
const uiActions = useUIActions();
|
||||
const {
|
||||
constrainHeight,
|
||||
@@ -74,54 +76,50 @@ export const DialogManager = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.quota.proQuotaRequest) {
|
||||
if (quotaState.proQuotaRequest) {
|
||||
return (
|
||||
<ProQuotaDialog
|
||||
failedModel={uiState.quota.proQuotaRequest.failedModel}
|
||||
fallbackModel={uiState.quota.proQuotaRequest.fallbackModel}
|
||||
message={uiState.quota.proQuotaRequest.message}
|
||||
isTerminalQuotaError={
|
||||
uiState.quota.proQuotaRequest.isTerminalQuotaError
|
||||
}
|
||||
isModelNotFoundError={
|
||||
!!uiState.quota.proQuotaRequest.isModelNotFoundError
|
||||
}
|
||||
authType={uiState.quota.proQuotaRequest.authType}
|
||||
failedModel={quotaState.proQuotaRequest.failedModel}
|
||||
fallbackModel={quotaState.proQuotaRequest.fallbackModel}
|
||||
message={quotaState.proQuotaRequest.message}
|
||||
isTerminalQuotaError={quotaState.proQuotaRequest.isTerminalQuotaError}
|
||||
isModelNotFoundError={!!quotaState.proQuotaRequest.isModelNotFoundError}
|
||||
authType={quotaState.proQuotaRequest.authType}
|
||||
tierName={config?.getUserTierName()}
|
||||
onChoice={uiActions.handleProQuotaChoice}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.quota.validationRequest) {
|
||||
if (quotaState.validationRequest) {
|
||||
return (
|
||||
<ValidationDialog
|
||||
validationLink={uiState.quota.validationRequest.validationLink}
|
||||
validationLink={quotaState.validationRequest.validationLink}
|
||||
validationDescription={
|
||||
uiState.quota.validationRequest.validationDescription
|
||||
quotaState.validationRequest.validationDescription
|
||||
}
|
||||
learnMoreUrl={uiState.quota.validationRequest.learnMoreUrl}
|
||||
learnMoreUrl={quotaState.validationRequest.learnMoreUrl}
|
||||
onChoice={uiActions.handleValidationChoice}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.quota.overageMenuRequest) {
|
||||
if (quotaState.overageMenuRequest) {
|
||||
return (
|
||||
<OverageMenuDialog
|
||||
failedModel={uiState.quota.overageMenuRequest.failedModel}
|
||||
fallbackModel={uiState.quota.overageMenuRequest.fallbackModel}
|
||||
resetTime={uiState.quota.overageMenuRequest.resetTime}
|
||||
creditBalance={uiState.quota.overageMenuRequest.creditBalance}
|
||||
failedModel={quotaState.overageMenuRequest.failedModel}
|
||||
fallbackModel={quotaState.overageMenuRequest.fallbackModel}
|
||||
resetTime={quotaState.overageMenuRequest.resetTime}
|
||||
creditBalance={quotaState.overageMenuRequest.creditBalance}
|
||||
onChoice={uiActions.handleOverageMenuChoice}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.quota.emptyWalletRequest) {
|
||||
if (quotaState.emptyWalletRequest) {
|
||||
return (
|
||||
<EmptyWalletDialog
|
||||
failedModel={uiState.quota.emptyWalletRequest.failedModel}
|
||||
fallbackModel={uiState.quota.emptyWalletRequest.fallbackModel}
|
||||
resetTime={uiState.quota.emptyWalletRequest.resetTime}
|
||||
onGetCredits={uiState.quota.emptyWalletRequest.onGetCredits}
|
||||
failedModel={quotaState.emptyWalletRequest.failedModel}
|
||||
fallbackModel={quotaState.emptyWalletRequest.fallbackModel}
|
||||
resetTime={quotaState.emptyWalletRequest.resetTime}
|
||||
onGetCredits={quotaState.emptyWalletRequest.onGetCredits}
|
||||
onChoice={uiActions.handleEmptyWalletChoice}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -267,21 +267,16 @@ describe('<Footer />', () => {
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: {
|
||||
remaining: 15,
|
||||
limit: 100,
|
||||
resetTime: undefined,
|
||||
},
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
overageMenuRequest: null,
|
||||
emptyWalletRequest: null,
|
||||
},
|
||||
quotaState: {
|
||||
stats: {
|
||||
remaining: 15,
|
||||
limit: 100,
|
||||
resetTime: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(lastFrame()).toContain('85%');
|
||||
expect(lastFrame()).toContain('85% used');
|
||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
@@ -292,21 +287,16 @@ describe('<Footer />', () => {
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: {
|
||||
remaining: 85,
|
||||
limit: 100,
|
||||
resetTime: undefined,
|
||||
},
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
overageMenuRequest: null,
|
||||
emptyWalletRequest: null,
|
||||
},
|
||||
quotaState: {
|
||||
stats: {
|
||||
remaining: 85,
|
||||
limit: 100,
|
||||
resetTime: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(normalizeFrame(lastFrame())).not.toContain('used');
|
||||
expect(normalizeFrame(lastFrame())).toContain('15% used');
|
||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
@@ -317,17 +307,12 @@ describe('<Footer />', () => {
|
||||
width: 120,
|
||||
uiState: {
|
||||
sessionStats: mockSessionStats,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: {
|
||||
remaining: 0,
|
||||
limit: 100,
|
||||
resetTime: undefined,
|
||||
},
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
overageMenuRequest: null,
|
||||
emptyWalletRequest: null,
|
||||
},
|
||||
quotaState: {
|
||||
stats: {
|
||||
remaining: 0,
|
||||
limit: 100,
|
||||
resetTime: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
||||
import { QuotaDisplay } from './QuotaDisplay.js';
|
||||
import { DebugProfiler } from './DebugProfiler.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useQuotaState } from '../contexts/QuotaContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
@@ -174,6 +175,7 @@ interface FooterColumn {
|
||||
|
||||
export const Footer: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
const quotaState = useQuotaState();
|
||||
const { copyModeEnabled } = useInputState();
|
||||
const config = useConfig();
|
||||
const settings = useSettings();
|
||||
@@ -203,7 +205,6 @@ export const Footer: React.FC = () => {
|
||||
promptTokenCount,
|
||||
isTrustedFolder,
|
||||
terminalWidth,
|
||||
quotaStats,
|
||||
} = {
|
||||
model: uiState.currentModel,
|
||||
targetDir: config.getTargetDir(),
|
||||
@@ -216,9 +217,10 @@ export const Footer: React.FC = () => {
|
||||
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
|
||||
isTrustedFolder: uiState.isTrustedFolder,
|
||||
terminalWidth: uiState.terminalWidth,
|
||||
quotaStats: uiState.quota.stats,
|
||||
};
|
||||
|
||||
const quotaStats = quotaState.stats;
|
||||
|
||||
const isFullErrorVerbosity = settings.merged.ui.errorVerbosity === 'full';
|
||||
const showErrorSummary =
|
||||
!showErrorDetails &&
|
||||
@@ -351,13 +353,11 @@ export const Footer: React.FC = () => {
|
||||
<QuotaDisplay
|
||||
remaining={quotaStats.remaining}
|
||||
limit={quotaStats.limit}
|
||||
resetTime={quotaStats.resetTime}
|
||||
terse={true}
|
||||
forceShow={true}
|
||||
lowercase={true}
|
||||
/>
|
||||
),
|
||||
10, // "daily 100%" is 10 chars, but terse is "100%" (4 chars)
|
||||
9, // "100% used" is 9 chars
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -256,7 +256,7 @@ describe('<FooterConfigDialog />', () => {
|
||||
expect(nextLine).toContain('·');
|
||||
expect(nextLine).toContain('~/project/path');
|
||||
expect(nextLine).toContain('docker');
|
||||
expect(nextLine).toContain('97%');
|
||||
expect(nextLine).toContain('42% used');
|
||||
});
|
||||
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
|
||||
@@ -242,7 +242,7 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
|
||||
'context-used': (
|
||||
<Text color={getColor('context-used', itemColor)}>85% used</Text>
|
||||
),
|
||||
quota: <Text color={getColor('quota', itemColor)}>97%</Text>,
|
||||
quota: <Text color={getColor('quota', itemColor)}>42% used</Text>,
|
||||
'memory-usage': (
|
||||
<Text color={getColor('memory-usage', itemColor)}>260 MB</Text>
|
||||
),
|
||||
|
||||
@@ -50,7 +50,6 @@ interface HistoryItemDisplayProps {
|
||||
isFirstThinking?: boolean;
|
||||
isFirstAfterThinking?: boolean;
|
||||
isToolGroupBoundary?: boolean;
|
||||
suppressNarration?: boolean;
|
||||
}
|
||||
|
||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
@@ -64,7 +63,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
isFirstThinking = false,
|
||||
isFirstAfterThinking = false,
|
||||
isToolGroupBoundary = false,
|
||||
suppressNarration = false,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const inlineThinkingMode = getInlineThinkingMode(settings);
|
||||
@@ -75,17 +73,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
isToolGroupBoundary
|
||||
);
|
||||
|
||||
// If there's a topic update in this turn, we suppress the regular narration
|
||||
// and thoughts as they are being "replaced" by the update_topic tool.
|
||||
if (
|
||||
suppressNarration &&
|
||||
(itemForDisplay.type === 'thinking' ||
|
||||
itemForDisplay.type === 'gemini' ||
|
||||
itemForDisplay.type === 'gemini_content')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
@@ -205,7 +192,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
borderTop={itemForDisplay.borderTop}
|
||||
borderBottom={itemForDisplay.borderBottom}
|
||||
isExpandable={isExpandable}
|
||||
isToolGroupBoundary={isToolGroupBoundary}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'subagent' && (
|
||||
|
||||
@@ -1836,7 +1836,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
height={Math.min(buffer.viewportHeight, scrollableData.length)}
|
||||
width="100%"
|
||||
>
|
||||
{isAlternateBuffer ? (
|
||||
{config.getUseTerminalBuffer() ? (
|
||||
<ScrollableList
|
||||
ref={listRef}
|
||||
hasFocus={focus}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import { Box, Static } from 'ink';
|
||||
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useAppContext } from '../contexts/AppContext.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
|
||||
@@ -22,7 +21,6 @@ import { useMemo, memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
|
||||
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
|
||||
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
|
||||
import { isTopicTool } from './messages/TopicMessage.js';
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
|
||||
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
|
||||
@@ -82,35 +80,6 @@ export const MainContent = () => {
|
||||
return -1;
|
||||
}, [uiState.history]);
|
||||
|
||||
const settings = useSettings();
|
||||
const topicUpdateNarrationEnabled =
|
||||
settings.merged.experimental?.topicUpdateNarration === true;
|
||||
|
||||
const suppressNarrationFlags = useMemo(() => {
|
||||
const combinedHistory = [...uiState.history, ...pendingHistoryItems];
|
||||
const flags = new Array<boolean>(combinedHistory.length).fill(false);
|
||||
|
||||
if (topicUpdateNarrationEnabled) {
|
||||
let toolGroupInTurn = false;
|
||||
for (let i = combinedHistory.length - 1; i >= 0; i--) {
|
||||
const item = combinedHistory[i];
|
||||
if (item.type === 'user' || item.type === 'user_shell') {
|
||||
toolGroupInTurn = false;
|
||||
} else if (item.type === 'tool_group') {
|
||||
toolGroupInTurn = item.tools.some((t) => isTopicTool(t.name));
|
||||
} else if (
|
||||
(item.type === 'thinking' ||
|
||||
item.type === 'gemini' ||
|
||||
item.type === 'gemini_content') &&
|
||||
toolGroupInTurn
|
||||
) {
|
||||
flags[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
}, [uiState.history, pendingHistoryItems, topicUpdateNarrationEnabled]);
|
||||
|
||||
const augmentedHistory = useMemo(
|
||||
() =>
|
||||
uiState.history.map((item, i) => {
|
||||
@@ -129,10 +98,9 @@ export const MainContent = () => {
|
||||
isFirstThinking,
|
||||
isFirstAfterThinking,
|
||||
isToolGroupBoundary,
|
||||
suppressNarration: suppressNarrationFlags[i] ?? false,
|
||||
};
|
||||
}),
|
||||
[uiState.history, lastUserPromptIndex, suppressNarrationFlags],
|
||||
[uiState.history, lastUserPromptIndex],
|
||||
);
|
||||
|
||||
const historyItems = useMemo(
|
||||
@@ -144,7 +112,6 @@ export const MainContent = () => {
|
||||
isFirstThinking,
|
||||
isFirstAfterThinking,
|
||||
isToolGroupBoundary,
|
||||
suppressNarration,
|
||||
}) => (
|
||||
<MemoizedHistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
@@ -162,7 +129,6 @@ export const MainContent = () => {
|
||||
isFirstThinking={isFirstThinking}
|
||||
isFirstAfterThinking={isFirstAfterThinking}
|
||||
isToolGroupBoundary={isToolGroupBoundary}
|
||||
suppressNarration={suppressNarration}
|
||||
/>
|
||||
),
|
||||
),
|
||||
@@ -201,9 +167,6 @@ export const MainContent = () => {
|
||||
(item.type !== 'tool_group' && prevType === 'tool_group') ||
|
||||
(item.type === 'tool_group' && prevType !== 'tool_group');
|
||||
|
||||
const suppressNarration =
|
||||
suppressNarrationFlags[uiState.history.length + i] ?? false;
|
||||
|
||||
return (
|
||||
<HistoryItemDisplay
|
||||
key={`pending-${i}`}
|
||||
@@ -217,7 +180,6 @@ export const MainContent = () => {
|
||||
isFirstThinking={isFirstThinking}
|
||||
isFirstAfterThinking={isFirstAfterThinking}
|
||||
isToolGroupBoundary={isToolGroupBoundary}
|
||||
suppressNarration={suppressNarration}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -237,7 +199,6 @@ export const MainContent = () => {
|
||||
showConfirmationQueue,
|
||||
confirmingTool,
|
||||
uiState.history,
|
||||
suppressNarrationFlags,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -5,12 +5,16 @@
|
||||
*/
|
||||
|
||||
import { act } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Config, InboxSkill } from '@google/gemini-cli-core';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Config, InboxSkill, InboxPatch } from '@google/gemini-cli-core';
|
||||
import {
|
||||
dismissInboxSkill,
|
||||
listInboxSkills,
|
||||
listInboxPatches,
|
||||
moveInboxSkill,
|
||||
applyInboxPatch,
|
||||
dismissInboxPatch,
|
||||
isProjectSkillPatchTarget,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
@@ -24,7 +28,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
...original,
|
||||
dismissInboxSkill: vi.fn(),
|
||||
listInboxSkills: vi.fn(),
|
||||
listInboxPatches: vi.fn(),
|
||||
moveInboxSkill: vi.fn(),
|
||||
applyInboxPatch: vi.fn(),
|
||||
dismissInboxPatch: vi.fn(),
|
||||
isProjectSkillPatchTarget: vi.fn(),
|
||||
getErrorMessage: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error),
|
||||
),
|
||||
@@ -32,20 +40,108 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
});
|
||||
|
||||
const mockListInboxSkills = vi.mocked(listInboxSkills);
|
||||
const mockListInboxPatches = vi.mocked(listInboxPatches);
|
||||
const mockMoveInboxSkill = vi.mocked(moveInboxSkill);
|
||||
const mockDismissInboxSkill = vi.mocked(dismissInboxSkill);
|
||||
const mockApplyInboxPatch = vi.mocked(applyInboxPatch);
|
||||
const mockDismissInboxPatch = vi.mocked(dismissInboxPatch);
|
||||
const mockIsProjectSkillPatchTarget = vi.mocked(isProjectSkillPatchTarget);
|
||||
|
||||
const inboxSkill: InboxSkill = {
|
||||
dirName: 'inbox-skill',
|
||||
name: 'Inbox Skill',
|
||||
description: 'A test skill',
|
||||
content:
|
||||
'---\nname: Inbox Skill\ndescription: A test skill\n---\n\n## Procedure\n1. Do the thing\n',
|
||||
extractedAt: '2025-01-15T10:00:00Z',
|
||||
};
|
||||
|
||||
const inboxPatch: InboxPatch = {
|
||||
fileName: 'update-docs.patch',
|
||||
name: 'update-docs',
|
||||
entries: [
|
||||
{
|
||||
targetPath: '/home/user/.gemini/skills/docs-writer/SKILL.md',
|
||||
diffContent: [
|
||||
'--- /home/user/.gemini/skills/docs-writer/SKILL.md',
|
||||
'+++ /home/user/.gemini/skills/docs-writer/SKILL.md',
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' line1',
|
||||
' line2',
|
||||
'+line2.5',
|
||||
' line3',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
extractedAt: '2025-01-20T14:00:00Z',
|
||||
};
|
||||
|
||||
const workspacePatch: InboxPatch = {
|
||||
fileName: 'workspace-update.patch',
|
||||
name: 'workspace-update',
|
||||
entries: [
|
||||
{
|
||||
targetPath: '/repo/.gemini/skills/docs-writer/SKILL.md',
|
||||
diffContent: [
|
||||
'--- /repo/.gemini/skills/docs-writer/SKILL.md',
|
||||
'+++ /repo/.gemini/skills/docs-writer/SKILL.md',
|
||||
'@@ -1,1 +1,2 @@',
|
||||
' line1',
|
||||
'+line2',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const multiSectionPatch: InboxPatch = {
|
||||
fileName: 'multi-section.patch',
|
||||
name: 'multi-section',
|
||||
entries: [
|
||||
{
|
||||
targetPath: '/home/user/.gemini/skills/docs-writer/SKILL.md',
|
||||
diffContent: [
|
||||
'--- /home/user/.gemini/skills/docs-writer/SKILL.md',
|
||||
'+++ /home/user/.gemini/skills/docs-writer/SKILL.md',
|
||||
'@@ -1,1 +1,2 @@',
|
||||
' line1',
|
||||
'+line2',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
targetPath: '/home/user/.gemini/skills/docs-writer/SKILL.md',
|
||||
diffContent: [
|
||||
'--- /home/user/.gemini/skills/docs-writer/SKILL.md',
|
||||
'+++ /home/user/.gemini/skills/docs-writer/SKILL.md',
|
||||
'@@ -3,1 +4,2 @@',
|
||||
' line3',
|
||||
'+line4',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const windowsGlobalPatch: InboxPatch = {
|
||||
fileName: 'windows-update.patch',
|
||||
name: 'windows-update',
|
||||
entries: [
|
||||
{
|
||||
targetPath: 'C:\\Users\\sandy\\.gemini\\skills\\docs-writer\\SKILL.md',
|
||||
diffContent: [
|
||||
'--- C:\\Users\\sandy\\.gemini\\skills\\docs-writer\\SKILL.md',
|
||||
'+++ C:\\Users\\sandy\\.gemini\\skills\\docs-writer\\SKILL.md',
|
||||
'@@ -1,1 +1,2 @@',
|
||||
' line1',
|
||||
'+line2',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('SkillInboxDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockListInboxSkills.mockResolvedValue([inboxSkill]);
|
||||
mockListInboxPatches.mockResolvedValue([]);
|
||||
mockMoveInboxSkill.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Moved "inbox-skill" to ~/.gemini/skills.',
|
||||
@@ -54,6 +150,30 @@ describe('SkillInboxDialog', () => {
|
||||
success: true,
|
||||
message: 'Dismissed "inbox-skill" from inbox.',
|
||||
});
|
||||
mockApplyInboxPatch.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Applied patch to 1 file.',
|
||||
});
|
||||
mockDismissInboxPatch.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Dismissed "update-docs.patch" from inbox.',
|
||||
});
|
||||
mockIsProjectSkillPatchTarget.mockImplementation(
|
||||
async (targetPath: string, config: Config) => {
|
||||
const projectSkillsDir = config.storage
|
||||
?.getProjectSkillsDir?.()
|
||||
?.replaceAll('\\', '/')
|
||||
?.replace(/\/+$/, '');
|
||||
|
||||
return projectSkillsDir
|
||||
? targetPath.replaceAll('\\', '/').startsWith(projectSkillsDir)
|
||||
: false;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('disables the project destination when the workspace is untrusted', async () => {
|
||||
@@ -75,6 +195,17 @@ describe('SkillInboxDialog', () => {
|
||||
expect(lastFrame()).toContain('Inbox Skill');
|
||||
});
|
||||
|
||||
// Select skill → lands on preview
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Review new skill');
|
||||
});
|
||||
|
||||
// Select "Move" → lands on destination chooser
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
@@ -86,22 +217,6 @@ describe('SkillInboxDialog', () => {
|
||||
expect(frame).toContain('unavailable until this workspace is trusted');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x1b[B');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDismissInboxSkill).toHaveBeenCalledWith(config, 'inbox-skill');
|
||||
});
|
||||
expect(mockMoveInboxSkill).not.toHaveBeenCalled();
|
||||
expect(onReloadSkills).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -125,11 +240,19 @@ describe('SkillInboxDialog', () => {
|
||||
expect(lastFrame()).toContain('Inbox Skill');
|
||||
});
|
||||
|
||||
// Select skill → preview
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
// Select "Move" → destination chooser
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
// Select "Global" → triggers move
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
@@ -165,11 +288,19 @@ describe('SkillInboxDialog', () => {
|
||||
expect(lastFrame()).toContain('Inbox Skill');
|
||||
});
|
||||
|
||||
// Select skill → preview
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
// Select "Move" → destination chooser
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
// Select "Global" → triggers move
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
@@ -184,4 +315,346 @@ describe('SkillInboxDialog', () => {
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('patch support', () => {
|
||||
it('shows patches alongside skills with section headers', async () => {
|
||||
mockListInboxPatches.mockResolvedValue([inboxPatch]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
const { lastFrame, unmount } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('New Skills');
|
||||
expect(frame).toContain('Inbox Skill');
|
||||
expect(frame).toContain('Skill Updates');
|
||||
expect(frame).toContain('update-docs');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows diff preview when a patch is selected', async () => {
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxPatches.mockResolvedValue([inboxPatch]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('update-docs');
|
||||
});
|
||||
|
||||
// Select the patch
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Review changes before applying');
|
||||
expect(frame).toContain('Apply');
|
||||
expect(frame).toContain('Dismiss');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('applies a patch when Apply is selected', async () => {
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxPatches.mockResolvedValue([inboxPatch]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
|
||||
const { stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={onReloadSkills}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockListInboxPatches).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Select the patch
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
// Select "Apply"
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApplyInboxPatch).toHaveBeenCalledWith(
|
||||
config,
|
||||
'update-docs.patch',
|
||||
);
|
||||
});
|
||||
expect(onReloadSkills).toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('disables Apply for workspace patches in an untrusted workspace', async () => {
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxPatches.mockResolvedValue([workspacePatch]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(false),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('workspace-update');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Apply');
|
||||
expect(frame).toContain(
|
||||
'.gemini/skills — unavailable until this workspace is trusted',
|
||||
);
|
||||
});
|
||||
expect(mockApplyInboxPatch).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('uses canonical project-scope checks before enabling Apply', async () => {
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxPatches.mockResolvedValue([workspacePatch]);
|
||||
mockIsProjectSkillPatchTarget.mockResolvedValue(true);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(false),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi
|
||||
.fn()
|
||||
.mockReturnValue('/symlinked/workspace/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('workspace-update');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain(
|
||||
'.gemini/skills — unavailable until this workspace is trusted',
|
||||
);
|
||||
});
|
||||
expect(mockIsProjectSkillPatchTarget).toHaveBeenCalledWith(
|
||||
'/repo/.gemini/skills/docs-writer/SKILL.md',
|
||||
config,
|
||||
);
|
||||
expect(mockApplyInboxPatch).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('dismisses a patch when Dismiss is selected', async () => {
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxPatches.mockResolvedValue([inboxPatch]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
|
||||
const { stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={onReloadSkills}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockListInboxPatches).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Select the patch
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
// Move down to "Dismiss" and select
|
||||
await act(async () => {
|
||||
stdin.write('\x1b[B');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDismissInboxPatch).toHaveBeenCalledWith(
|
||||
config,
|
||||
'update-docs.patch',
|
||||
);
|
||||
});
|
||||
expect(onReloadSkills).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows Windows patch entries with a basename and origin tag', async () => {
|
||||
vi.stubEnv('USERPROFILE', 'C:\\Users\\sandy');
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxPatches.mockResolvedValue([windowsGlobalPatch]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi
|
||||
.fn()
|
||||
.mockReturnValue('C:\\repo\\.gemini\\skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
const { lastFrame, unmount } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('[Global]');
|
||||
expect(frame).toContain('SKILL.md');
|
||||
expect(frame).not.toContain('C:\\Users\\sandy\\.gemini\\skills');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders multi-section patches without duplicate React keys', async () => {
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxPatches.mockResolvedValue([multiSectionPatch]);
|
||||
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('multi-section');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Review changes before applying');
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('Encountered two children with the same key'),
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import type React from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Box, Text, useStdout } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
@@ -14,25 +15,42 @@ import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
import { BaseSelectionList } from './shared/BaseSelectionList.js';
|
||||
import type { SelectionListItem } from '../hooks/useSelectionList.js';
|
||||
import { DialogFooter } from './shared/DialogFooter.js';
|
||||
import { DiffRenderer } from './messages/DiffRenderer.js';
|
||||
import {
|
||||
type Config,
|
||||
type InboxSkill,
|
||||
type InboxPatch,
|
||||
type InboxSkillDestination,
|
||||
getErrorMessage,
|
||||
listInboxSkills,
|
||||
listInboxPatches,
|
||||
moveInboxSkill,
|
||||
dismissInboxSkill,
|
||||
applyInboxPatch,
|
||||
dismissInboxPatch,
|
||||
isProjectSkillPatchTarget,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
type Phase = 'list' | 'action';
|
||||
type Phase = 'list' | 'skill-preview' | 'skill-action' | 'patch-preview';
|
||||
|
||||
type InboxItem =
|
||||
| { type: 'skill'; skill: InboxSkill }
|
||||
| { type: 'patch'; patch: InboxPatch; targetsProjectSkills: boolean }
|
||||
| { type: 'header'; label: string };
|
||||
|
||||
interface DestinationChoice {
|
||||
destination: InboxSkillDestination | 'dismiss';
|
||||
destination: InboxSkillDestination;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const DESTINATION_CHOICES: DestinationChoice[] = [
|
||||
interface PatchAction {
|
||||
action: 'apply' | 'dismiss';
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SKILL_DESTINATION_CHOICES: DestinationChoice[] = [
|
||||
{
|
||||
destination: 'global',
|
||||
label: 'Global',
|
||||
@@ -43,13 +61,105 @@ const DESTINATION_CHOICES: DestinationChoice[] = [
|
||||
label: 'Project',
|
||||
description: '.gemini/skills — available in this workspace',
|
||||
},
|
||||
];
|
||||
|
||||
interface SkillPreviewAction {
|
||||
action: 'move' | 'dismiss';
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SKILL_PREVIEW_CHOICES: SkillPreviewAction[] = [
|
||||
{
|
||||
destination: 'dismiss',
|
||||
action: 'move',
|
||||
label: 'Move',
|
||||
description: 'Choose where to install this skill',
|
||||
},
|
||||
{
|
||||
action: 'dismiss',
|
||||
label: 'Dismiss',
|
||||
description: 'Delete from inbox',
|
||||
},
|
||||
];
|
||||
|
||||
const PATCH_ACTION_CHOICES: PatchAction[] = [
|
||||
{
|
||||
action: 'apply',
|
||||
label: 'Apply',
|
||||
description: 'Apply patch and delete from inbox',
|
||||
},
|
||||
{
|
||||
action: 'dismiss',
|
||||
label: 'Dismiss',
|
||||
description: 'Delete from inbox without applying',
|
||||
},
|
||||
];
|
||||
|
||||
function normalizePathForUi(filePath: string): string {
|
||||
return path.posix.normalize(filePath.replaceAll('\\', '/'));
|
||||
}
|
||||
|
||||
function getPathBasename(filePath: string): string {
|
||||
const normalizedPath = normalizePathForUi(filePath);
|
||||
const basename = path.posix.basename(normalizedPath);
|
||||
return basename === '.' ? filePath : basename;
|
||||
}
|
||||
|
||||
async function patchTargetsProjectSkills(
|
||||
patch: InboxPatch,
|
||||
config: Config,
|
||||
): Promise<boolean> {
|
||||
const entryTargetsProjectSkills = await Promise.all(
|
||||
patch.entries.map((entry) =>
|
||||
isProjectSkillPatchTarget(entry.targetPath, config),
|
||||
),
|
||||
);
|
||||
return entryTargetsProjectSkills.some(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a bracketed origin tag from a skill file path,
|
||||
* matching the existing [Built-in] convention in SkillsList.
|
||||
*/
|
||||
function getSkillOriginTag(filePath: string): string {
|
||||
const normalizedPath = normalizePathForUi(filePath);
|
||||
|
||||
if (normalizedPath.includes('/bundle/')) {
|
||||
return 'Built-in';
|
||||
}
|
||||
if (normalizedPath.includes('/extensions/')) {
|
||||
return 'Extension';
|
||||
}
|
||||
if (normalizedPath.includes('/.gemini/skills/')) {
|
||||
const homeDirs = [process.env['HOME'], process.env['USERPROFILE']]
|
||||
.filter((homeDir): homeDir is string => Boolean(homeDir))
|
||||
.map(normalizePathForUi);
|
||||
if (
|
||||
homeDirs.some((homeDir) =>
|
||||
normalizedPath.startsWith(`${homeDir}/.gemini/skills/`),
|
||||
)
|
||||
) {
|
||||
return 'Global';
|
||||
}
|
||||
return 'Workspace';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a unified diff string representing a new file.
|
||||
*/
|
||||
function newFileDiff(filename: string, content: string): string {
|
||||
const lines = content.split('\n');
|
||||
const hunkLines = lines.map((l) => `+${l}`).join('\n');
|
||||
return [
|
||||
`--- /dev/null`,
|
||||
`+++ ${filename}`,
|
||||
`@@ -0,0 +1,${lines.length} @@`,
|
||||
hunkLines,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function formatDate(isoString: string): string {
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
@@ -75,29 +185,57 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
onReloadSkills,
|
||||
}) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const { stdout } = useStdout();
|
||||
const terminalWidth = stdout?.columns ?? 80;
|
||||
const isTrustedFolder = config.isTrustedFolder();
|
||||
const [phase, setPhase] = useState<Phase>('list');
|
||||
const [skills, setSkills] = useState<InboxSkill[]>([]);
|
||||
const [items, setItems] = useState<InboxItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedSkill, setSelectedSkill] = useState<InboxSkill | null>(null);
|
||||
const [selectedItem, setSelectedItem] = useState<InboxItem | null>(null);
|
||||
const [feedback, setFeedback] = useState<{
|
||||
text: string;
|
||||
isError: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// Load inbox skills on mount
|
||||
// Load inbox skills and patches on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await listInboxSkills(config);
|
||||
const [skills, patches] = await Promise.all([
|
||||
listInboxSkills(config),
|
||||
listInboxPatches(config),
|
||||
]);
|
||||
const patchItems = await Promise.all(
|
||||
patches.map(async (patch): Promise<InboxItem> => {
|
||||
let targetsProjectSkills = false;
|
||||
try {
|
||||
targetsProjectSkills = await patchTargetsProjectSkills(
|
||||
patch,
|
||||
config,
|
||||
);
|
||||
} catch {
|
||||
targetsProjectSkills = false;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'patch',
|
||||
patch,
|
||||
targetsProjectSkills,
|
||||
};
|
||||
}),
|
||||
);
|
||||
if (!cancelled) {
|
||||
setSkills(result);
|
||||
const combined: InboxItem[] = [
|
||||
...skills.map((skill): InboxItem => ({ type: 'skill', skill })),
|
||||
...patchItems,
|
||||
];
|
||||
setItems(combined);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setSkills([]);
|
||||
setItems([]);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
@@ -107,18 +245,56 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
const skillItems: Array<SelectionListItem<InboxSkill>> = useMemo(
|
||||
() =>
|
||||
skills.map((skill) => ({
|
||||
key: skill.dirName,
|
||||
value: skill,
|
||||
})),
|
||||
[skills],
|
||||
const getItemKey = useCallback(
|
||||
(item: InboxItem): string =>
|
||||
item.type === 'skill'
|
||||
? `skill:${item.skill.dirName}`
|
||||
: item.type === 'patch'
|
||||
? `patch:${item.patch.fileName}`
|
||||
: `header:${item.label}`,
|
||||
[],
|
||||
);
|
||||
|
||||
const listItems: Array<SelectionListItem<InboxItem>> = useMemo(() => {
|
||||
const skills = items.filter((i) => i.type === 'skill');
|
||||
const patches = items.filter((i) => i.type === 'patch');
|
||||
const result: Array<SelectionListItem<InboxItem>> = [];
|
||||
|
||||
// Only show section headers when both types are present
|
||||
const showHeaders = skills.length > 0 && patches.length > 0;
|
||||
|
||||
if (showHeaders) {
|
||||
const header: InboxItem = { type: 'header', label: 'New Skills' };
|
||||
result.push({
|
||||
key: 'header:new-skills',
|
||||
value: header,
|
||||
disabled: true,
|
||||
hideNumber: true,
|
||||
});
|
||||
}
|
||||
for (const item of skills) {
|
||||
result.push({ key: getItemKey(item), value: item });
|
||||
}
|
||||
|
||||
if (showHeaders) {
|
||||
const header: InboxItem = { type: 'header', label: 'Skill Updates' };
|
||||
result.push({
|
||||
key: 'header:skill-updates',
|
||||
value: header,
|
||||
disabled: true,
|
||||
hideNumber: true,
|
||||
});
|
||||
}
|
||||
for (const item of patches) {
|
||||
result.push({ key: getItemKey(item), value: item });
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [items, getItemKey]);
|
||||
|
||||
const destinationItems: Array<SelectionListItem<DestinationChoice>> = useMemo(
|
||||
() =>
|
||||
DESTINATION_CHOICES.map((choice) => {
|
||||
SKILL_DESTINATION_CHOICES.map((choice) => {
|
||||
if (choice.destination === 'project' && !isTrustedFolder) {
|
||||
return {
|
||||
key: choice.destination,
|
||||
@@ -139,15 +315,103 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
[isTrustedFolder],
|
||||
);
|
||||
|
||||
const handleSelectSkill = useCallback((skill: InboxSkill) => {
|
||||
setSelectedSkill(skill);
|
||||
const selectedPatchTargetsProjectSkills = useMemo(() => {
|
||||
if (!selectedItem || selectedItem.type !== 'patch') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return selectedItem.targetsProjectSkills;
|
||||
}, [selectedItem]);
|
||||
|
||||
const patchActionItems: Array<SelectionListItem<PatchAction>> = useMemo(
|
||||
() =>
|
||||
PATCH_ACTION_CHOICES.map((choice) => {
|
||||
if (
|
||||
choice.action === 'apply' &&
|
||||
selectedPatchTargetsProjectSkills &&
|
||||
!isTrustedFolder
|
||||
) {
|
||||
return {
|
||||
key: choice.action,
|
||||
value: {
|
||||
...choice,
|
||||
description:
|
||||
'.gemini/skills — unavailable until this workspace is trusted',
|
||||
},
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: choice.action,
|
||||
value: choice,
|
||||
};
|
||||
}),
|
||||
[isTrustedFolder, selectedPatchTargetsProjectSkills],
|
||||
);
|
||||
|
||||
const skillPreviewItems: Array<SelectionListItem<SkillPreviewAction>> =
|
||||
useMemo(
|
||||
() =>
|
||||
SKILL_PREVIEW_CHOICES.map((choice) => ({
|
||||
key: choice.action,
|
||||
value: choice,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSelectItem = useCallback((item: InboxItem) => {
|
||||
setSelectedItem(item);
|
||||
setFeedback(null);
|
||||
setPhase('action');
|
||||
setPhase(item.type === 'skill' ? 'skill-preview' : 'patch-preview');
|
||||
}, []);
|
||||
|
||||
const removeItem = useCallback(
|
||||
(item: InboxItem) => {
|
||||
setItems((prev) =>
|
||||
prev.filter((i) => getItemKey(i) !== getItemKey(item)),
|
||||
);
|
||||
},
|
||||
[getItemKey],
|
||||
);
|
||||
|
||||
const handleSkillPreviewAction = useCallback(
|
||||
(choice: SkillPreviewAction) => {
|
||||
if (!selectedItem || selectedItem.type !== 'skill') return;
|
||||
|
||||
if (choice.action === 'move') {
|
||||
setFeedback(null);
|
||||
setPhase('skill-action');
|
||||
return;
|
||||
}
|
||||
|
||||
// Dismiss
|
||||
setFeedback(null);
|
||||
const skill = selectedItem.skill;
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await dismissInboxSkill(config, skill.dirName);
|
||||
setFeedback({ text: result.message, isError: !result.success });
|
||||
if (result.success) {
|
||||
removeItem(selectedItem);
|
||||
setSelectedItem(null);
|
||||
setPhase('list');
|
||||
}
|
||||
} catch (error) {
|
||||
setFeedback({
|
||||
text: `Failed to dismiss skill: ${getErrorMessage(error)}`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
})();
|
||||
},
|
||||
[config, selectedItem, removeItem],
|
||||
);
|
||||
|
||||
const handleSelectDestination = useCallback(
|
||||
(choice: DestinationChoice) => {
|
||||
if (!selectedSkill) return;
|
||||
if (!selectedItem || selectedItem.type !== 'skill') return;
|
||||
const skill = selectedItem.skill;
|
||||
|
||||
if (choice.destination === 'project' && !config.isTrustedFolder()) {
|
||||
setFeedback({
|
||||
@@ -161,16 +425,11 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
let result: { success: boolean; message: string };
|
||||
if (choice.destination === 'dismiss') {
|
||||
result = await dismissInboxSkill(config, selectedSkill.dirName);
|
||||
} else {
|
||||
result = await moveInboxSkill(
|
||||
config,
|
||||
selectedSkill.dirName,
|
||||
choice.destination,
|
||||
);
|
||||
}
|
||||
const result = await moveInboxSkill(
|
||||
config,
|
||||
skill.dirName,
|
||||
choice.destination,
|
||||
);
|
||||
|
||||
setFeedback({ text: result.message, isError: !result.success });
|
||||
|
||||
@@ -178,17 +437,10 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the skill from the local list.
|
||||
setSkills((prev) =>
|
||||
prev.filter((skill) => skill.dirName !== selectedSkill.dirName),
|
||||
);
|
||||
setSelectedSkill(null);
|
||||
removeItem(selectedItem);
|
||||
setSelectedItem(null);
|
||||
setPhase('list');
|
||||
|
||||
if (choice.destination === 'dismiss') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onReloadSkills();
|
||||
} catch (error) {
|
||||
@@ -197,11 +449,68 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setFeedback({
|
||||
text: `Failed to install skill: ${getErrorMessage(error)}`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
})();
|
||||
},
|
||||
[config, selectedItem, onReloadSkills, removeItem],
|
||||
);
|
||||
|
||||
const handleSelectPatchAction = useCallback(
|
||||
(choice: PatchAction) => {
|
||||
if (!selectedItem || selectedItem.type !== 'patch') return;
|
||||
const patch = selectedItem.patch;
|
||||
|
||||
if (
|
||||
choice.action === 'apply' &&
|
||||
!config.isTrustedFolder() &&
|
||||
selectedItem.targetsProjectSkills
|
||||
) {
|
||||
setFeedback({
|
||||
text: 'Project skill patches are unavailable until this workspace is trusted.',
|
||||
isError: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(null);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
let result: { success: boolean; message: string };
|
||||
if (choice.action === 'apply') {
|
||||
result = await applyInboxPatch(config, patch.fileName);
|
||||
} else {
|
||||
result = await dismissInboxPatch(config, patch.fileName);
|
||||
}
|
||||
|
||||
setFeedback({ text: result.message, isError: !result.success });
|
||||
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
removeItem(selectedItem);
|
||||
setSelectedItem(null);
|
||||
setPhase('list');
|
||||
|
||||
if (choice.action === 'apply') {
|
||||
try {
|
||||
await onReloadSkills();
|
||||
} catch (error) {
|
||||
setFeedback({
|
||||
text: `${result.message} Failed to reload skills: ${getErrorMessage(error)}`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const operation =
|
||||
choice.destination === 'dismiss'
|
||||
? 'dismiss skill'
|
||||
: 'install skill';
|
||||
choice.action === 'apply' ? 'apply patch' : 'dismiss patch';
|
||||
setFeedback({
|
||||
text: `Failed to ${operation}: ${getErrorMessage(error)}`,
|
||||
isError: true,
|
||||
@@ -209,15 +518,18 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
}
|
||||
})();
|
||||
},
|
||||
[config, selectedSkill, onReloadSkills],
|
||||
[config, selectedItem, onReloadSkills, removeItem],
|
||||
);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
if (phase === 'action') {
|
||||
if (phase === 'skill-action') {
|
||||
setPhase('skill-preview');
|
||||
setFeedback(null);
|
||||
} else if (phase !== 'list') {
|
||||
setPhase('list');
|
||||
setSelectedSkill(null);
|
||||
setSelectedItem(null);
|
||||
setFeedback(null);
|
||||
} else {
|
||||
onClose();
|
||||
@@ -243,7 +555,7 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (skills.length === 0 && !feedback) {
|
||||
if (items.length === 0 && !feedback) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
@@ -252,17 +564,18 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
<Text bold>Skill Inbox</Text>
|
||||
<Text bold>Memory Inbox</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
No extracted skills in inbox.
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>No items in inbox.</Text>
|
||||
</Box>
|
||||
<DialogFooter primaryAction="Esc to close" cancelAction="" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Border + paddingX account for 6 chars of width
|
||||
const contentWidth = terminalWidth - 6;
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
@@ -272,41 +585,87 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
paddingY={1}
|
||||
width="100%"
|
||||
>
|
||||
{phase === 'list' ? (
|
||||
{phase === 'list' && (
|
||||
<>
|
||||
<Text bold>
|
||||
Skill Inbox ({skills.length} skill{skills.length !== 1 ? 's' : ''})
|
||||
Memory Inbox ({items.length} item{items.length !== 1 ? 's' : ''})
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Skills extracted from past sessions. Select one to move or dismiss.
|
||||
Extracted from past sessions. Select one to review.
|
||||
</Text>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<BaseSelectionList<InboxSkill>
|
||||
items={skillItems}
|
||||
onSelect={handleSelectSkill}
|
||||
<BaseSelectionList<InboxItem>
|
||||
items={listItems}
|
||||
onSelect={handleSelectItem}
|
||||
isFocused={true}
|
||||
showNumbers={true}
|
||||
showNumbers={false}
|
||||
showScrollArrows={true}
|
||||
maxItemsToShow={8}
|
||||
renderItem={(item, { titleColor }) => (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Text color={titleColor} bold>
|
||||
{item.value.name}
|
||||
</Text>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{item.value.description}
|
||||
</Text>
|
||||
{item.value.extractedAt && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' · '}
|
||||
{formatDate(item.value.extractedAt)}
|
||||
renderItem={(item, { titleColor }) => {
|
||||
if (item.value.type === 'header') {
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary} bold>
|
||||
{item.value.label}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (item.value.type === 'skill') {
|
||||
const skill = item.value.skill;
|
||||
return (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Text color={titleColor} bold>
|
||||
{skill.name}
|
||||
</Text>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{skill.description}
|
||||
</Text>
|
||||
{skill.extractedAt && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' · '}
|
||||
{formatDate(skill.extractedAt)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const patch = item.value.patch;
|
||||
const fileNames = patch.entries.map((e) =>
|
||||
getPathBasename(e.targetPath),
|
||||
);
|
||||
const origin = getSkillOriginTag(
|
||||
patch.entries[0]?.targetPath ?? '',
|
||||
);
|
||||
return (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Box flexDirection="row">
|
||||
<Text color={titleColor} bold>
|
||||
{patch.name}
|
||||
</Text>
|
||||
{origin && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{` [${origin}]`}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary}>
|
||||
{fileNames.join(', ')}
|
||||
</Text>
|
||||
{patch.extractedAt && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' · '}
|
||||
{formatDate(patch.extractedAt)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -328,9 +687,73 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
cancelAction="Esc to close"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{phase === 'skill-preview' && selectedItem?.type === 'skill' && (
|
||||
<>
|
||||
<Text bold>Move "{selectedSkill?.name}"</Text>
|
||||
<Text bold>{selectedItem.skill.name}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Review new skill before installing.
|
||||
</Text>
|
||||
|
||||
{selectedItem.skill.content && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.secondary} bold>
|
||||
SKILL.md
|
||||
</Text>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiff(
|
||||
'SKILL.md',
|
||||
selectedItem.skill.content,
|
||||
)}
|
||||
filename="SKILL.md"
|
||||
terminalWidth={contentWidth}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<BaseSelectionList<SkillPreviewAction>
|
||||
items={skillPreviewItems}
|
||||
onSelect={handleSkillPreviewAction}
|
||||
isFocused={true}
|
||||
showNumbers={true}
|
||||
renderItem={(item, { titleColor }) => (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Text color={titleColor} bold>
|
||||
{item.value.label}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{item.value.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{feedback && (
|
||||
<Box marginTop={1}>
|
||||
<Text
|
||||
color={
|
||||
feedback.isError ? theme.status.error : theme.status.success
|
||||
}
|
||||
>
|
||||
{feedback.isError ? '✗ ' : '✓ '}
|
||||
{feedback.text}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<DialogFooter
|
||||
primaryAction="Enter to confirm"
|
||||
cancelAction="Esc to go back"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'skill-action' && selectedItem?.type === 'skill' && (
|
||||
<>
|
||||
<Text bold>Move "{selectedItem.skill.name}"</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Choose where to install this skill.
|
||||
</Text>
|
||||
@@ -373,6 +796,81 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'patch-preview' && selectedItem?.type === 'patch' && (
|
||||
<>
|
||||
<Text bold>{selectedItem.patch.name}</Text>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary}>
|
||||
Review changes before applying.
|
||||
</Text>
|
||||
{(() => {
|
||||
const origin = getSkillOriginTag(
|
||||
selectedItem.patch.entries[0]?.targetPath ?? '',
|
||||
);
|
||||
return origin ? (
|
||||
<Text color={theme.text.secondary}>{` [${origin}]`}</Text>
|
||||
) : null;
|
||||
})()}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{selectedItem.patch.entries.map((entry, index) => (
|
||||
<Box
|
||||
key={`${selectedItem.patch.fileName}:${entry.targetPath}:${index}`}
|
||||
flexDirection="column"
|
||||
marginBottom={1}
|
||||
>
|
||||
<Text color={theme.text.secondary} bold>
|
||||
{entry.targetPath}
|
||||
</Text>
|
||||
<DiffRenderer
|
||||
diffContent={entry.diffContent}
|
||||
filename={entry.targetPath}
|
||||
terminalWidth={contentWidth}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<BaseSelectionList<PatchAction>
|
||||
items={patchActionItems}
|
||||
onSelect={handleSelectPatchAction}
|
||||
isFocused={true}
|
||||
showNumbers={true}
|
||||
renderItem={(item, { titleColor }) => (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Text color={titleColor} bold>
|
||||
{item.value.label}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{item.value.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{feedback && (
|
||||
<Box marginTop={1}>
|
||||
<Text
|
||||
color={
|
||||
feedback.isError ? theme.status.error : theme.status.success
|
||||
}
|
||||
>
|
||||
{feedback.isError ? '✗ ' : '✓ '}
|
||||
{feedback.text}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<DialogFooter
|
||||
primaryAction="Enter to confirm"
|
||||
cancelAction="Esc to go back"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<Footer /> > displays "Limit reached" message when remaining is 0 1`] = `
|
||||
" workspace (/directory) sandbox /model /stats
|
||||
" workspace (/directory) sandbox /model quota
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro limit reached
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `
|
||||
" workspace (/directory) sandbox /model /stats
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85%
|
||||
" workspace (/directory) sandbox /model quota
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85% used
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -39,7 +39,7 @@ exports[`<Footer /> > footer configuration filtering (golden snapshots) > render
|
||||
`;
|
||||
|
||||
exports[`<Footer /> > hides the usage indicator when usage is not near limit 1`] = `
|
||||
" workspace (/directory) sandbox /model /stats
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15%
|
||||
" workspace (/directory) sandbox /model quota
|
||||
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15% used
|
||||
"
|
||||
`;
|
||||
|
||||
+9
-9
@@ -50,7 +50,7 @@
|
||||
<text x="72" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs"> quota</text>
|
||||
<text x="891" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Remaining usage on daily limit (not shown when unavailable)</text>
|
||||
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Percentage of daily limit used (not shown when unavailable)</text>
|
||||
<text x="891" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="274" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
|
||||
@@ -132,10 +132,10 @@
|
||||
<text x="0" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="27" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="631" fill="#afafaf" textLength="198" lengthAdjust="spacingAndGlyphs">workspace (/directory)</text>
|
||||
<text x="297" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
|
||||
<text x="405" y="631" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
|
||||
<text x="513" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
|
||||
<text x="693" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/stats</text>
|
||||
<text x="288" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
|
||||
<text x="396" y="631" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
|
||||
<text x="504" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
|
||||
<text x="684" y="631" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">quota</text>
|
||||
<rect x="801" y="629" width="36" height="17" fill="#001a00" />
|
||||
<text x="801" y="631" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">diff</text>
|
||||
<rect x="837" y="629" width="18" height="17" fill="#001a00" />
|
||||
@@ -144,10 +144,10 @@
|
||||
<text x="0" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="27" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
|
||||
<text x="297" y="648" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
|
||||
<text x="405" y="648" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
|
||||
<text x="513" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
|
||||
<text x="693" y="648" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">97%</text>
|
||||
<text x="288" y="648" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
|
||||
<text x="396" y="648" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
|
||||
<text x="504" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
|
||||
<text x="684" y="648" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs">42% used</text>
|
||||
<rect x="801" y="646" width="27" height="17" fill="#001a00" />
|
||||
<text x="801" y="648" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">+12</text>
|
||||
<rect x="828" y="646" width="9" height="17" fill="#001a00" />
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
+9
-9
@@ -59,7 +59,7 @@
|
||||
<text x="72" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs"> quota</text>
|
||||
<text x="891" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Remaining usage on daily limit (not shown when unavailable)</text>
|
||||
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Percentage of daily limit used (not shown when unavailable)</text>
|
||||
<text x="891" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="274" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
|
||||
@@ -133,10 +133,10 @@
|
||||
<text x="27" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<rect x="45" y="629" width="198" height="17" fill="#001a00" />
|
||||
<text x="45" y="631" fill="#ffffff" textLength="198" lengthAdjust="spacingAndGlyphs">workspace (/directory)</text>
|
||||
<text x="324" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
|
||||
<text x="459" y="631" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
|
||||
<text x="594" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
|
||||
<text x="801" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/stats</text>
|
||||
<text x="315" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
|
||||
<text x="450" y="631" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
|
||||
<text x="585" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
|
||||
<text x="783" y="631" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">quota</text>
|
||||
<text x="864" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
@@ -144,10 +144,10 @@
|
||||
<rect x="45" y="646" width="126" height="17" fill="#001a00" />
|
||||
<text x="45" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
|
||||
<rect x="171" y="646" width="72" height="17" fill="#001a00" />
|
||||
<text x="324" y="648" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
|
||||
<text x="459" y="648" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
|
||||
<text x="594" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
|
||||
<text x="801" y="648" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">97%</text>
|
||||
<text x="315" y="648" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
|
||||
<text x="450" y="648" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
|
||||
<text x="585" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
|
||||
<text x="783" y="648" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs">42% used</text>
|
||||
<text x="864" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
+8
-8
@@ -50,7 +50,7 @@
|
||||
<text x="72" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs"> quota</text>
|
||||
<text x="891" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Remaining usage on daily limit (not shown when unavailable)</text>
|
||||
<text x="45" y="257" fill="#afafaf" textLength="540" lengthAdjust="spacingAndGlyphs"> Percentage of daily limit used (not shown when unavailable)</text>
|
||||
<text x="891" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="274" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
|
||||
@@ -131,13 +131,13 @@
|
||||
<text x="27" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="631" fill="#afafaf" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
|
||||
<text x="207" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
|
||||
<text x="279" y="631" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
|
||||
<text x="351" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
|
||||
<text x="432" y="631" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
|
||||
<text x="522" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
|
||||
<text x="594" y="631" fill="#afafaf" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
|
||||
<text x="756" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
|
||||
<text x="828" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">97%</text>
|
||||
<text x="270" y="631" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
|
||||
<text x="342" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
|
||||
<text x="405" y="631" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
|
||||
<text x="495" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
|
||||
<text x="558" y="631" fill="#afafaf" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
|
||||
<text x="720" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
|
||||
<text x="783" y="631" fill="#afafaf" textLength="72" lengthAdjust="spacingAndGlyphs">42% used</text>
|
||||
<text x="864" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
@@ -16,7 +16,7 @@ exports[`<FooterConfigDialog /> > highlights the active item in the preview 1`]
|
||||
│ [✓] model-name │
|
||||
│ Current model identifier │
|
||||
│ [✓] quota │
|
||||
│ Remaining usage on daily limit (not shown when unavailable) │
|
||||
│ Percentage of daily limit used (not shown when unavailable) │
|
||||
│ [ ] context-used │
|
||||
│ Percentage of context window used │
|
||||
│ [ ] memory-usage │
|
||||
@@ -38,8 +38,8 @@ exports[`<FooterConfigDialog /> > highlights the active item in the preview 1`]
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Preview: │ │
|
||||
│ │ workspace (/directory) branch sandbox /model /stats diff │ │
|
||||
│ │ ~/project/path main docker gemini-2.5-pro 97% +12 -4 │ │
|
||||
│ │ workspace (/directory) branch sandbox /model quota diff │ │
|
||||
│ │ ~/project/path main docker gemini-2.5-pro 42% used +12 -4 │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
@@ -61,7 +61,7 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 1`] =
|
||||
│ [✓] model-name │
|
||||
│ Current model identifier │
|
||||
│ [✓] quota │
|
||||
│ Remaining usage on daily limit (not shown when unavailable) │
|
||||
│ Percentage of daily limit used (not shown when unavailable) │
|
||||
│ [ ] context-used │
|
||||
│ Percentage of context window used │
|
||||
│ [ ] memory-usage │
|
||||
@@ -83,8 +83,8 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 1`] =
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Preview: │ │
|
||||
│ │ workspace (/directory) branch sandbox /model /stats │ │
|
||||
│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
|
||||
│ │ workspace (/directory) branch sandbox /model quota │ │
|
||||
│ │ ~/project/path main docker gemini-2.5-pro 42% used │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
@@ -107,7 +107,7 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 2`] =
|
||||
│ [✓] model-name │
|
||||
│ Current model identifier │
|
||||
│ [✓] quota │
|
||||
│ Remaining usage on daily limit (not shown when unavailable) │
|
||||
│ Percentage of daily limit used (not shown when unavailable) │
|
||||
│ [ ] context-used │
|
||||
│ Percentage of context window used │
|
||||
│ [ ] memory-usage │
|
||||
@@ -129,8 +129,8 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 2`] =
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Preview: │ │
|
||||
│ │ workspace (/directory) branch sandbox /model /stats │ │
|
||||
│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
|
||||
│ │ workspace (/directory) branch sandbox /model quota │ │
|
||||
│ │ ~/project/path main docker gemini-2.5-pro 42% used │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
@@ -152,7 +152,7 @@ exports[`<FooterConfigDialog /> > updates the preview when Show footer labels is
|
||||
│ [✓] model-name │
|
||||
│ Current model identifier │
|
||||
│ [✓] quota │
|
||||
│ Remaining usage on daily limit (not shown when unavailable) │
|
||||
│ Percentage of daily limit used (not shown when unavailable) │
|
||||
│ [ ] context-used │
|
||||
│ Percentage of context window used │
|
||||
│ [ ] memory-usage │
|
||||
@@ -174,7 +174,7 @@ exports[`<FooterConfigDialog /> > updates the preview when Show footer labels is
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Preview: │ │
|
||||
│ │ ~/project/path · main · docker · gemini-2.5-pro · 97% │ │
|
||||
│ │ ~/project/path · main · docker · gemini-2.5-pro · 42% used │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
|
||||
@@ -22,8 +22,7 @@ import { theme } from '../../semantic-colors.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import { isShellTool } from './ToolShared.js';
|
||||
import {
|
||||
shouldHideToolCall,
|
||||
CoreToolCallStatus,
|
||||
isVisibleInToolGroup,
|
||||
Kind,
|
||||
EDIT_DISPLAY_NAME,
|
||||
GLOB_DISPLAY_NAME,
|
||||
@@ -36,6 +35,7 @@ import {
|
||||
READ_MANY_FILES_DISPLAY_NAME,
|
||||
isFileDiff,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { buildToolVisibilityContextFromDisplay } from '../../utils/historyUtils.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
@@ -102,7 +102,6 @@ interface ToolGroupMessageProps {
|
||||
borderTop?: boolean;
|
||||
borderBottom?: boolean;
|
||||
isExpandable?: boolean;
|
||||
isToolGroupBoundary?: boolean;
|
||||
}
|
||||
|
||||
// Main component renders the border and maps the tools using ToolMessage
|
||||
@@ -116,7 +115,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
borderTop: borderTopOverride,
|
||||
borderBottom: borderBottomOverride,
|
||||
isExpandable,
|
||||
isToolGroupBoundary,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full';
|
||||
@@ -125,40 +123,13 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
// Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations).
|
||||
const visibleToolCalls = useMemo(
|
||||
() =>
|
||||
allToolCalls.filter((t) => {
|
||||
// Hide internal errors unless full verbosity
|
||||
if (
|
||||
isLowErrorVerbosity &&
|
||||
t.status === CoreToolCallStatus.Error &&
|
||||
!t.isClientInitiated
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Standard hiding logic (e.g. Plan Mode internal edits)
|
||||
if (
|
||||
shouldHideToolCall({
|
||||
displayName: t.name,
|
||||
status: t.status,
|
||||
approvalMode: t.approvalMode,
|
||||
hasResultDisplay: !!t.resultDisplay,
|
||||
parentCallId: t.parentCallId,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We HIDE tools that are still in pre-execution states (Confirming, Pending)
|
||||
// from the History log. They live in the Global Queue or wait for their turn.
|
||||
// Only show tools that are actually running or finished.
|
||||
const displayStatus = mapCoreStatusToDisplayStatus(t.status);
|
||||
|
||||
// We hide Confirming tools from the history log because they are
|
||||
// currently being rendered in the interactive ToolConfirmationQueue.
|
||||
// We show everything else, including Pending (waiting to run) and
|
||||
// Canceled (rejected by user), to ensure the history is complete
|
||||
// and to avoid tools "vanishing" after approval.
|
||||
return displayStatus !== ToolCallStatus.Confirming;
|
||||
}),
|
||||
allToolCalls.filter((t) =>
|
||||
// Use the unified visibility utility
|
||||
isVisibleInToolGroup(
|
||||
buildToolVisibilityContextFromDisplay(t),
|
||||
isLowErrorVerbosity ? 'low' : 'full',
|
||||
),
|
||||
),
|
||||
[allToolCalls, isLowErrorVerbosity],
|
||||
);
|
||||
|
||||
@@ -275,11 +246,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
(showClosingBorder ? 1 : 0);
|
||||
} else if (isTopicToolCall) {
|
||||
// Topic Message Spacing Breakdown:
|
||||
// 1. Top Margin (1): Present unless it's the very first item following a boundary.
|
||||
// 1. Top Margin (1): Always present for spacing.
|
||||
// 2. Topic Content (1).
|
||||
// 3. Bottom Margin (1): Always present around TopicMessage for breathing room.
|
||||
const hasTopMargin = !(isFirst && isToolGroupBoundary);
|
||||
height += (hasTopMargin ? 1 : 0) + 1 + 1;
|
||||
// 4. Closing Border (1): Added if transition logic (showClosingBorder) requires it.
|
||||
height += 1 + 1 + 1 + (showClosingBorder ? 1 : 0);
|
||||
} else if (isCompact) {
|
||||
// Compact Tool: Always renders as a single dense line.
|
||||
height += 1;
|
||||
@@ -300,12 +271,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
}
|
||||
}
|
||||
return height;
|
||||
}, [
|
||||
groupedTools,
|
||||
isCompactModeEnabled,
|
||||
borderTopOverride,
|
||||
isToolGroupBoundary,
|
||||
]);
|
||||
}, [groupedTools, isCompactModeEnabled, borderTopOverride]);
|
||||
|
||||
let countToolCallsWithResults = 0;
|
||||
for (const tool of visibleToolCalls) {
|
||||
@@ -473,10 +439,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
{isCompact ? (
|
||||
<DenseToolMessage {...commonProps} />
|
||||
) : isTopicToolCall ? (
|
||||
<Box
|
||||
marginTop={isFirst && isToolGroupBoundary ? 0 : 1}
|
||||
marginBottom={1}
|
||||
>
|
||||
<Box marginTop={1} marginBottom={1}>
|
||||
<TopicMessage {...commonProps} />
|
||||
</Box>
|
||||
) : isShellToolCall ? (
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { QuotaStats } from '../types.js';
|
||||
import type { UserTierId } from '@google/gemini-cli-core';
|
||||
import type {
|
||||
ProQuotaDialogRequest,
|
||||
ValidationDialogRequest,
|
||||
OverageMenuDialogRequest,
|
||||
EmptyWalletDialogRequest,
|
||||
} from './UIStateContext.js';
|
||||
|
||||
export interface QuotaState {
|
||||
userTier?: UserTierId;
|
||||
stats?: QuotaStats;
|
||||
proQuotaRequest?: ProQuotaDialogRequest | null;
|
||||
validationRequest?: ValidationDialogRequest | null;
|
||||
overageMenuRequest?: OverageMenuDialogRequest | null;
|
||||
emptyWalletRequest?: EmptyWalletDialogRequest | null;
|
||||
}
|
||||
|
||||
export const QuotaContext = createContext<QuotaState | null>(null);
|
||||
|
||||
export const useQuotaState = () => {
|
||||
const context = useContext(QuotaContext);
|
||||
if (!context) {
|
||||
throw new Error('useQuotaState must be used within a QuotaProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -41,6 +41,24 @@ interface ScrollContextType {
|
||||
|
||||
const ScrollContext = createContext<ScrollContextType | null>(null);
|
||||
|
||||
/**
|
||||
* The minimum fractional scroll delta to track.
|
||||
*/
|
||||
const SCROLL_STATIC_FRICTION = 0.001;
|
||||
|
||||
/**
|
||||
* Calculates a scroll top value clamped between 0 and the maximum possible
|
||||
* scroll position for the given container dimensions.
|
||||
*/
|
||||
const getClampedScrollTop = (
|
||||
scrollTop: number,
|
||||
scrollHeight: number,
|
||||
innerHeight: number,
|
||||
) => {
|
||||
const maxScroll = Math.max(0, scrollHeight - innerHeight);
|
||||
return Math.max(0, Math.min(scrollTop, maxScroll));
|
||||
};
|
||||
|
||||
const findScrollableCandidates = (
|
||||
mouseEvent: MouseEvent,
|
||||
scrollables: Map<string, ScrollableEntry>,
|
||||
@@ -90,6 +108,8 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
trueScrollRef.current.delete(id);
|
||||
pendingFlushRef.current.delete(id);
|
||||
}, []);
|
||||
|
||||
const scrollablesRef = useRef(scrollables);
|
||||
@@ -97,7 +117,10 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
scrollablesRef.current = scrollables;
|
||||
}, [scrollables]);
|
||||
|
||||
const pendingScrollsRef = useRef(new Map<string, number>());
|
||||
const trueScrollRef = useRef(
|
||||
new Map<string, { floatValue: number; expectedScrollTop: number }>(),
|
||||
);
|
||||
const pendingFlushRef = useRef(new Set<string>());
|
||||
const flushScheduledRef = useRef(false);
|
||||
|
||||
const dragStateRef = useRef<{
|
||||
@@ -115,13 +138,45 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
flushScheduledRef.current = true;
|
||||
setTimeout(() => {
|
||||
flushScheduledRef.current = false;
|
||||
for (const [id, delta] of pendingScrollsRef.current.entries()) {
|
||||
const ids = Array.from(pendingFlushRef.current);
|
||||
pendingFlushRef.current.clear();
|
||||
|
||||
for (const id of ids) {
|
||||
const entry = scrollablesRef.current.get(id);
|
||||
if (entry) {
|
||||
entry.scrollBy(delta);
|
||||
const trueScroll = trueScrollRef.current.get(id);
|
||||
|
||||
if (entry && trueScroll) {
|
||||
const { scrollTop, scrollHeight, innerHeight } =
|
||||
entry.getScrollState();
|
||||
|
||||
// Re-verify it hasn't become stale before flushing
|
||||
if (trueScroll.expectedScrollTop !== scrollTop) {
|
||||
trueScrollRef.current.set(id, {
|
||||
floatValue: scrollTop,
|
||||
expectedScrollTop: scrollTop,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const clampedFloat = getClampedScrollTop(
|
||||
trueScroll.floatValue,
|
||||
scrollHeight,
|
||||
innerHeight,
|
||||
);
|
||||
const roundedTarget = Math.round(clampedFloat);
|
||||
|
||||
const deltaToApply = roundedTarget - scrollTop;
|
||||
|
||||
if (deltaToApply !== 0) {
|
||||
entry.scrollBy(deltaToApply);
|
||||
trueScroll.expectedScrollTop = roundedTarget;
|
||||
}
|
||||
|
||||
trueScroll.floatValue = clampedFloat;
|
||||
} else {
|
||||
trueScrollRef.current.delete(id);
|
||||
}
|
||||
}
|
||||
pendingScrollsRef.current.clear();
|
||||
}, 0);
|
||||
}
|
||||
}, []);
|
||||
@@ -129,6 +184,7 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
const scrollMomentumRef = useRef({
|
||||
count: 0,
|
||||
lastTime: 0,
|
||||
lastDirection: null as 'up' | 'down' | null,
|
||||
});
|
||||
|
||||
const handleScroll = (direction: 'up' | 'down', mouseEvent: MouseEvent) => {
|
||||
@@ -137,8 +193,11 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
|
||||
if (!terminalCapabilityManager.isGhosttyTerminal()) {
|
||||
const timeSinceLastScroll = now - scrollMomentumRef.current.lastTime;
|
||||
const isSameDirection =
|
||||
scrollMomentumRef.current.lastDirection === direction;
|
||||
|
||||
// 50ms threshold to consider scrolls consecutive
|
||||
if (timeSinceLastScroll < 50) {
|
||||
if (timeSinceLastScroll < 50 && isSameDirection) {
|
||||
scrollMomentumRef.current.count += 1;
|
||||
// Accelerate up to 3x, starting after 5 consecutive scrolls.
|
||||
// Each consecutive scroll increases the multiplier by 0.1.
|
||||
@@ -151,6 +210,7 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
}
|
||||
}
|
||||
scrollMomentumRef.current.lastTime = now;
|
||||
scrollMomentumRef.current.lastDirection = direction;
|
||||
|
||||
const delta = (direction === 'up' ? -1 : 1) * multiplier;
|
||||
const candidates = findScrollableCandidates(
|
||||
@@ -161,23 +221,33 @@ export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
for (const candidate of candidates) {
|
||||
const { scrollTop, scrollHeight, innerHeight } =
|
||||
candidate.getScrollState();
|
||||
const pendingDelta = pendingScrollsRef.current.get(candidate.id) || 0;
|
||||
const effectiveScrollTop = scrollTop + pendingDelta;
|
||||
|
||||
// Epsilon to handle floating point inaccuracies.
|
||||
const canScrollUp = effectiveScrollTop > 0.001;
|
||||
const canScrollDown =
|
||||
effectiveScrollTop < scrollHeight - innerHeight - 0.001;
|
||||
const totalDelta = Math.round(pendingDelta + delta);
|
||||
|
||||
if (direction === 'up' && canScrollUp) {
|
||||
pendingScrollsRef.current.set(candidate.id, totalDelta);
|
||||
scheduleFlush();
|
||||
return true;
|
||||
let trueScroll = trueScrollRef.current.get(candidate.id);
|
||||
if (!trueScroll || trueScroll.expectedScrollTop !== scrollTop) {
|
||||
trueScroll = { floatValue: scrollTop, expectedScrollTop: scrollTop };
|
||||
}
|
||||
|
||||
if (direction === 'down' && canScrollDown) {
|
||||
pendingScrollsRef.current.set(candidate.id, totalDelta);
|
||||
const maxScroll = Math.max(0, scrollHeight - innerHeight);
|
||||
const canScrollUp = trueScroll.floatValue > SCROLL_STATIC_FRICTION;
|
||||
const canScrollDown =
|
||||
trueScroll.floatValue < maxScroll - SCROLL_STATIC_FRICTION;
|
||||
|
||||
if (
|
||||
(direction === 'up' && canScrollUp) ||
|
||||
(direction === 'down' && canScrollDown)
|
||||
) {
|
||||
const clampedFloat = getClampedScrollTop(
|
||||
trueScroll.floatValue + delta,
|
||||
scrollHeight,
|
||||
innerHeight,
|
||||
);
|
||||
|
||||
trueScrollRef.current.set(candidate.id, {
|
||||
floatValue: clampedFloat,
|
||||
expectedScrollTop: trueScroll.expectedScrollTop,
|
||||
});
|
||||
|
||||
pendingFlushRef.current.add(candidate.id);
|
||||
scheduleFlush();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
HistoryItem,
|
||||
ThoughtSummary,
|
||||
ConfirmationRequest,
|
||||
QuotaStats,
|
||||
LoopDetectionConfirmationRequest,
|
||||
HistoryItemWithoutId,
|
||||
StreamingState,
|
||||
@@ -21,7 +20,6 @@ import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import type {
|
||||
IdeContext,
|
||||
ApprovalMode,
|
||||
UserTierId,
|
||||
IdeInfo,
|
||||
AuthType,
|
||||
FallbackIntent,
|
||||
@@ -86,16 +84,6 @@ import { type RestartReason } from '../hooks/useIdeTrustListener.js';
|
||||
import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js';
|
||||
import type { BackgroundTask } from '../hooks/useExecutionLifecycle.js';
|
||||
|
||||
export interface QuotaState {
|
||||
userTier: UserTierId | undefined;
|
||||
stats: QuotaStats | undefined;
|
||||
proQuotaRequest: ProQuotaDialogRequest | null;
|
||||
validationRequest: ValidationDialogRequest | null;
|
||||
// G1 AI Credits overage flow
|
||||
overageMenuRequest: OverageMenuDialogRequest | null;
|
||||
emptyWalletRequest: EmptyWalletDialogRequest | null;
|
||||
}
|
||||
|
||||
export interface AccountSuspensionInfo {
|
||||
message: string;
|
||||
appealUrl?: string;
|
||||
@@ -169,8 +157,6 @@ export interface UIState {
|
||||
queueErrorMessage: string | null;
|
||||
showApprovalModeIndicator: ApprovalMode;
|
||||
allowPlanMode: boolean;
|
||||
// Quota-related state
|
||||
quota: QuotaState;
|
||||
currentModel: string;
|
||||
contextFileNames: string[];
|
||||
errorCount: number;
|
||||
|
||||
@@ -533,7 +533,7 @@ async function readLocalFiles(
|
||||
let invocation: AnyToolInvocation | undefined = undefined;
|
||||
try {
|
||||
invocation = readManyFilesTool.build(toolArgs);
|
||||
const result = await invocation.execute(signal);
|
||||
const result = await invocation.execute({ abortSignal: signal });
|
||||
const display: IndividualToolCallDisplay = {
|
||||
callId: `client-read-${userMessageTimestamp}`,
|
||||
name: readManyFilesTool.displayName,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
MessageSenderType,
|
||||
debugLogger,
|
||||
geminiPartsToContentParts,
|
||||
displayContentToString,
|
||||
parseThought,
|
||||
CoreToolCallStatus,
|
||||
type ApprovalMode,
|
||||
@@ -197,6 +198,7 @@ export const useAgentStream = ({
|
||||
name: displayName,
|
||||
originalRequestName: event.name,
|
||||
description: desc,
|
||||
display: event.display,
|
||||
status: CoreToolCallStatus.Scheduled,
|
||||
isClientInitiated: false,
|
||||
renderOutputAsMarkdown: isOutputMarkdown,
|
||||
@@ -222,10 +224,9 @@ export const useAgentStream = ({
|
||||
else if (evtStatus === 'success')
|
||||
status = CoreToolCallStatus.Success;
|
||||
|
||||
const display = event.display?.result;
|
||||
const liveOutput =
|
||||
event.displayContent?.[0]?.type === 'text'
|
||||
? event.displayContent[0].text
|
||||
: tc.resultDisplay;
|
||||
displayContentToString(display) ?? tc.resultDisplay;
|
||||
const progressMessage =
|
||||
legacyState?.progressMessage ?? tc.progressMessage;
|
||||
const progress = legacyState?.progress ?? tc.progress;
|
||||
@@ -237,6 +238,9 @@ export const useAgentStream = ({
|
||||
return {
|
||||
...tc,
|
||||
status,
|
||||
display: event.display
|
||||
? { ...tc.display, ...event.display }
|
||||
: tc.display,
|
||||
resultDisplay: liveOutput,
|
||||
progressMessage,
|
||||
progress,
|
||||
@@ -255,16 +259,18 @@ export const useAgentStream = ({
|
||||
|
||||
const legacyState = event._meta?.legacyState;
|
||||
const outputFile = legacyState?.outputFile;
|
||||
const display = event.display?.result;
|
||||
const resultDisplay =
|
||||
event.displayContent?.[0]?.type === 'text'
|
||||
? event.displayContent[0].text
|
||||
: tc.resultDisplay;
|
||||
displayContentToString(display) ?? tc.resultDisplay;
|
||||
|
||||
return {
|
||||
...tc,
|
||||
status: event.isError
|
||||
? CoreToolCallStatus.Error
|
||||
: CoreToolCallStatus.Success,
|
||||
display: event.display
|
||||
? { ...tc.display, ...event.display }
|
||||
: tc.display,
|
||||
resultDisplay,
|
||||
outputFile,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useQuotaState } from '../contexts/QuotaContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { CoreToolCallStatus, ApprovalMode } from '@google/gemini-cli-core';
|
||||
import { type HistoryItemToolGroup, StreamingState } from '../types.js';
|
||||
@@ -18,6 +19,7 @@ import { theme } from '../semantic-colors.js';
|
||||
*/
|
||||
export const useComposerStatus = () => {
|
||||
const uiState = useUIState();
|
||||
const quotaState = useQuotaState();
|
||||
const settings = useSettings();
|
||||
|
||||
const hasPendingToolConfirmation = useMemo(
|
||||
@@ -40,8 +42,8 @@ export const useComposerStatus = () => {
|
||||
Boolean(uiState.authConsentRequest) ||
|
||||
(uiState.confirmUpdateExtensionRequests?.length ?? 0) > 0 ||
|
||||
Boolean(uiState.loopDetectionConfirmationRequest) ||
|
||||
Boolean(uiState.quota.proQuotaRequest) ||
|
||||
Boolean(uiState.quota.validationRequest) ||
|
||||
Boolean(quotaState.proQuotaRequest) ||
|
||||
Boolean(quotaState.validationRequest) ||
|
||||
Boolean(uiState.customDialog);
|
||||
|
||||
const isInteractiveShellWaiting = Boolean(
|
||||
|
||||
@@ -133,6 +133,7 @@ describe('useExecutionLifecycle', () => {
|
||||
mockConfig = {
|
||||
getTargetDir: () => '/test/dir',
|
||||
getEnableInteractiveShell: () => false,
|
||||
getSessionId: () => 'test-session-id',
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalHeight: 20,
|
||||
terminalWidth: 80,
|
||||
@@ -246,11 +247,32 @@ describe('useExecutionLifecycle', () => {
|
||||
expect.any(Function),
|
||||
expect.any(Object),
|
||||
false,
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
sessionId: 'test-session-id',
|
||||
}),
|
||||
);
|
||||
expect(onExecMock).toHaveBeenCalledWith(expect.any(Promise));
|
||||
});
|
||||
|
||||
it('should pass the config sessionId into shell execution config', async () => {
|
||||
const { result } = await renderProcessorHook();
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleShellCommand('top', new AbortController().signal);
|
||||
});
|
||||
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'/test/dir',
|
||||
expect.any(Function),
|
||||
expect.any(Object),
|
||||
false,
|
||||
expect.objectContaining({
|
||||
sessionId: 'test-session-id',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle successful execution and update history correctly', async () => {
|
||||
const { result } = await renderProcessorHook();
|
||||
|
||||
|
||||
@@ -409,6 +409,7 @@ export const useExecutionLifecycle = (
|
||||
const activeTheme = themeManager.getActiveTheme();
|
||||
const shellExecutionConfig = {
|
||||
...config.getShellExecutionConfig(),
|
||||
sessionId: config.getSessionId(),
|
||||
terminalWidth,
|
||||
terminalHeight,
|
||||
defaultFg: activeTheme.colors.Foreground,
|
||||
|
||||
@@ -39,7 +39,8 @@ import {
|
||||
isBackgroundExecutionData,
|
||||
Kind,
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
shouldHideToolCall,
|
||||
isRenderedInHistory,
|
||||
buildToolVisibilityContext,
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
UPDATE_TOPIC_DISPLAY_NAME,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -647,29 +648,8 @@ export const useGeminiStream = (
|
||||
toolCalls.every((tc) => pushedToolCallIds.has(tc.request.callId));
|
||||
|
||||
const isToolVisible = (tc: TrackedToolCall) => {
|
||||
const displayName = tc.tool?.displayName ?? tc.request.name;
|
||||
|
||||
let hasResultDisplay = false;
|
||||
if (
|
||||
tc.status === CoreToolCallStatus.Success ||
|
||||
tc.status === CoreToolCallStatus.Error ||
|
||||
tc.status === CoreToolCallStatus.Cancelled
|
||||
) {
|
||||
hasResultDisplay = !!tc.response?.resultDisplay;
|
||||
} else if (tc.status === CoreToolCallStatus.Executing) {
|
||||
hasResultDisplay = !!tc.liveOutput;
|
||||
}
|
||||
|
||||
// AskUser tools and Plan Mode write/edit are handled by this logic
|
||||
if (
|
||||
shouldHideToolCall({
|
||||
displayName,
|
||||
status: tc.status,
|
||||
approvalMode: tc.approvalMode,
|
||||
hasResultDisplay,
|
||||
parentCallId: tc.request.parentCallId,
|
||||
})
|
||||
) {
|
||||
if (!isRenderedInHistory(buildToolVisibilityContext(tc))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -637,7 +637,8 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
[Command.PASTE_CLIPBOARD]: 'Paste from the clipboard.',
|
||||
|
||||
// App Controls
|
||||
[Command.SHOW_ERROR_DETAILS]: 'Toggle detailed error information.',
|
||||
[Command.SHOW_ERROR_DETAILS]:
|
||||
'Toggle the debug console for detailed error information.',
|
||||
[Command.SHOW_FULL_TODOS]: 'Toggle the full TODO list.',
|
||||
[Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.',
|
||||
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type ThoughtSummary,
|
||||
type SerializableConfirmationDetails,
|
||||
type ToolResultDisplay,
|
||||
type ToolDisplay,
|
||||
type RetrieveUserQuotaResponse,
|
||||
type SkillDefinition,
|
||||
type AgentDefinition,
|
||||
@@ -121,6 +122,7 @@ export interface IndividualToolCallDisplay {
|
||||
name: string;
|
||||
args?: Record<string, unknown>;
|
||||
description: string;
|
||||
display?: ToolDisplay;
|
||||
resultDisplay: ToolResultDisplay | undefined;
|
||||
status: CoreToolCallStatus;
|
||||
// True when the tool was initiated directly by the user (slash/@/shell flows).
|
||||
|
||||
@@ -4,12 +4,18 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||
import {
|
||||
CoreToolCallStatus,
|
||||
belongsInConfirmationQueue,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
type HistoryItemWithoutId,
|
||||
type IndividualToolCallDisplay,
|
||||
} from '../types.js';
|
||||
import { getAllToolCalls } from './historyUtils.js';
|
||||
import {
|
||||
getAllToolCalls,
|
||||
buildToolVisibilityContextFromDisplay,
|
||||
} from './historyUtils.js';
|
||||
|
||||
export interface ConfirmingToolState {
|
||||
tool: IndividualToolCallDisplay;
|
||||
@@ -33,14 +39,18 @@ export function getConfirmingToolState(
|
||||
return null;
|
||||
}
|
||||
|
||||
const actionablePendingTools = allPendingTools.filter((tool) =>
|
||||
belongsInConfirmationQueue(buildToolVisibilityContextFromDisplay(tool)),
|
||||
);
|
||||
|
||||
const head = confirmingTools[0];
|
||||
const headIndexInFullList = allPendingTools.findIndex(
|
||||
const headIndexInFullList = actionablePendingTools.findIndex(
|
||||
(tool) => tool.callId === head.callId,
|
||||
);
|
||||
|
||||
return {
|
||||
tool: head,
|
||||
index: headIndexInFullList + 1,
|
||||
total: allPendingTools.length,
|
||||
total: actionablePendingTools.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type ToolVisibilityContext } from '@google/gemini-cli-core';
|
||||
import { CoreToolCallStatus } from '../types.js';
|
||||
import type {
|
||||
HistoryItem,
|
||||
@@ -12,6 +13,23 @@ import type {
|
||||
IndividualToolCallDisplay,
|
||||
} from '../types.js';
|
||||
|
||||
/**
|
||||
* Maps an IndividualToolCallDisplay from the CLI to a ToolVisibilityContext for core logic.
|
||||
*/
|
||||
export function buildToolVisibilityContextFromDisplay(
|
||||
tool: IndividualToolCallDisplay,
|
||||
): ToolVisibilityContext {
|
||||
return {
|
||||
name: tool.originalRequestName ?? tool.name,
|
||||
displayName: tool.name, // In CLI, 'name' is usually the resolved display name
|
||||
status: tool.status,
|
||||
hasResult: !!tool.resultDisplay,
|
||||
approvalMode: tool.approvalMode,
|
||||
isClientInitiated: tool.isClientInitiated,
|
||||
parentCallId: tool.parentCallId,
|
||||
};
|
||||
}
|
||||
|
||||
export function getLastTurnToolCallIds(
|
||||
history: HistoryItem[],
|
||||
pendingHistoryItems: HistoryItemWithoutId[],
|
||||
|
||||
@@ -266,6 +266,29 @@ describe('textUtils', () => {
|
||||
// 0xA0 is non-breaking space, should be preserved
|
||||
expect(stripUnsafeCharacters('hello\xA0world')).toBe('hello\xA0world');
|
||||
});
|
||||
|
||||
it('should not lose text after DCS (0x90) — regression for data loss', () => {
|
||||
// 0x90 (DCS) starts a Device Control String that stripVTControlCharacters
|
||||
// treats as an unterminated sequence, swallowing all subsequent text.
|
||||
// Stripping C1 chars before VT processing prevents this data loss.
|
||||
expect(stripUnsafeCharacters('important\x90data after DCS')).toBe(
|
||||
'importantdata after DCS',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fully strip 8-bit CSI (0x9B) sequences', () => {
|
||||
// 0x9B (CSI) is equivalent to ESC[. stripAnsi should handle the
|
||||
// whole sequence including parameters.
|
||||
expect(stripUnsafeCharacters('keep\x9B42mthis text')).toBe(
|
||||
'keepthis text',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not lose text when multiple C1 chars precede valid content', () => {
|
||||
expect(stripUnsafeCharacters('start\x90\x9B\x85middle\x80end')).toBe(
|
||||
'startmiddleend',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ANSI escape sequence stripping', () => {
|
||||
|
||||
@@ -98,8 +98,16 @@ export function cpSlice(str: string, start: number, end?: number): string {
|
||||
/**
|
||||
* Strip characters that can break terminal rendering.
|
||||
*
|
||||
* Uses Node.js built-in stripVTControlCharacters to handle VT sequences,
|
||||
* then filters remaining control characters that can disrupt display.
|
||||
* This is a strict sanitization function intended for general display
|
||||
* contexts. It strips all C1 control characters (0x80-0x9F) and VT
|
||||
* control sequences. For list display contexts where a more lenient
|
||||
* approach is needed (preserving C1 characters and only stripping ANSI
|
||||
* codes and newlines/tabs), use a separate function instead.
|
||||
*
|
||||
* Processing order:
|
||||
* 1. stripAnsi removes ANSI escape sequences (including 8-bit CSI 0x9B)
|
||||
* 2. Regex strips C0, C1, BiDi, and zero-width control characters
|
||||
* 3. stripVTControlCharacters removes any remaining VT sequences
|
||||
*
|
||||
* Characters stripped:
|
||||
* - ANSI escape sequences (via strip-ansi)
|
||||
@@ -119,18 +127,20 @@ export function cpSlice(str: string, start: number, end?: number): string {
|
||||
*/
|
||||
export function stripUnsafeCharacters(str: string): string {
|
||||
const strippedAnsi = stripAnsi(str);
|
||||
const strippedVT = stripVTControlCharacters(strippedAnsi);
|
||||
|
||||
// Use a regex to strip remaining unsafe control characters
|
||||
// C0: 0x00-0x1F except 0x09 (TAB), 0x0A (LF), 0x0D (CR)
|
||||
// C1: 0x80-0x9F
|
||||
// BiDi: U+200E (LRM), U+200F (RLM), U+202A-U+202E, U+2066-U+2069
|
||||
// Zero-width: U+200B (ZWSP), U+FEFF (BOM)
|
||||
return strippedVT.replace(
|
||||
// Strip C0, C1, and other unsafe characters via regex first.
|
||||
// This is more efficient than multiple replaces and crucially removes C1
|
||||
// characters (e.g., 0x90 DCS) before they can be misinterpreted by
|
||||
// stripVTControlCharacters, which could otherwise cause data loss.
|
||||
const strippedWithRegex = strippedAnsi.replace(
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/[\x00-\x08\x0B\x0C\x0E-\x1F\x80-\x9F\u200E\u200F\u202A-\u202E\u2066-\u2069\u200B\uFEFF]/g,
|
||||
'',
|
||||
);
|
||||
|
||||
// Finally, use stripVTControlCharacters for any remaining VT sequences
|
||||
// that the regex might not cover.
|
||||
return stripVTControlCharacters(strippedWithRegex);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,10 +24,24 @@ export function registerCleanup(fn: (() => void) | (() => Promise<void>)) {
|
||||
cleanupFunctions.push(fn);
|
||||
}
|
||||
|
||||
export function removeCleanup(fn: (() => void) | (() => Promise<void>)) {
|
||||
const index = cleanupFunctions.indexOf(fn);
|
||||
if (index !== -1) {
|
||||
cleanupFunctions.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSyncCleanup(fn: () => void) {
|
||||
syncCleanupFunctions.push(fn);
|
||||
}
|
||||
|
||||
export function removeSyncCleanup(fn: () => void) {
|
||||
const index = syncCleanupFunctions.indexOf(fn);
|
||||
if (index !== -1) {
|
||||
syncCleanupFunctions.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the internal cleanup state for testing purposes.
|
||||
* This allows tests to run in isolation without vi.resetModules().
|
||||
|
||||
@@ -294,7 +294,7 @@ describe('validateNonInterActiveAuth', () => {
|
||||
expect(processExitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('succeeds if effectiveAuthType matches enforcedAuthType', async () => {
|
||||
it('succeeds if effectiveAuthType matches enforcedType', async () => {
|
||||
mockSettings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
|
||||
process.env['GEMINI_API_KEY'] = 'fake-key';
|
||||
const nonInteractiveConfig = createLocalMockConfig({});
|
||||
@@ -308,7 +308,7 @@ describe('validateNonInterActiveAuth', () => {
|
||||
expect(debugLoggerErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exits if configuredAuthType does not match enforcedAuthType', async () => {
|
||||
it('exits if configuredAuthType does not match enforcedType', async () => {
|
||||
mockSettings.merged.security.auth.enforcedType = AuthType.LOGIN_WITH_GOOGLE;
|
||||
const nonInteractiveConfig = createLocalMockConfig({
|
||||
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
|
||||
@@ -334,7 +334,7 @@ describe('validateNonInterActiveAuth', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('exits if auth from env var does not match enforcedAuthType', async () => {
|
||||
it('exits if auth from env var does not match enforcedType', async () => {
|
||||
mockSettings.merged.security.auth.enforcedType = AuthType.LOGIN_WITH_GOOGLE;
|
||||
process.env['GEMINI_API_KEY'] = 'fake-key';
|
||||
const nonInteractiveConfig = createLocalMockConfig({
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"ignore": "^7.0.0",
|
||||
"ipaddr.js": "^1.9.1",
|
||||
"isbinaryfile": "^5.0.7",
|
||||
"js-yaml": "^4.1.1",
|
||||
"json-stable-stringify": "^1.3.0",
|
||||
"marked": "^15.0.12",
|
||||
@@ -91,13 +92,13 @@
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@github/keytar": "^7.10.6",
|
||||
"@lydell/node-pty": "1.1.0",
|
||||
"@lydell/node-pty-darwin-arm64": "1.1.0",
|
||||
"@lydell/node-pty-darwin-x64": "1.1.0",
|
||||
"@lydell/node-pty-linux-x64": "1.1.0",
|
||||
"@lydell/node-pty-win32-arm64": "1.1.0",
|
||||
"@lydell/node-pty-win32-x64": "1.1.0",
|
||||
"keytar": "^7.9.0",
|
||||
"node-pty": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
geminiPartsToContentParts,
|
||||
contentPartsToGeminiParts,
|
||||
toolResultDisplayToContentParts,
|
||||
buildToolResponseData,
|
||||
} from './content-utils.js';
|
||||
import type { Part } from '@google/genai';
|
||||
@@ -200,27 +199,6 @@ describe('contentPartsToGeminiParts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('toolResultDisplayToContentParts', () => {
|
||||
it('returns undefined for undefined', () => {
|
||||
expect(toolResultDisplayToContentParts(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for null', () => {
|
||||
expect(toolResultDisplayToContentParts(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles string resultDisplay as-is', () => {
|
||||
const result = toolResultDisplayToContentParts('File written');
|
||||
expect(result).toEqual([{ type: 'text', text: 'File written' }]);
|
||||
});
|
||||
|
||||
it('stringifies object resultDisplay', () => {
|
||||
const display = { type: 'FileDiff', oldPath: 'a.ts', newPath: 'b.ts' };
|
||||
const result = toolResultDisplayToContentParts(display);
|
||||
expect(result).toEqual([{ type: 'text', text: JSON.stringify(display) }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildToolResponseData', () => {
|
||||
it('preserves outputFile and contentLength', () => {
|
||||
const result = buildToolResponseData({
|
||||
|
||||
@@ -101,24 +101,6 @@ export function contentPartsToGeminiParts(content: ContentPart[]): Part[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ToolCallResponseInfo.resultDisplay value into ContentPart[].
|
||||
* Handles string, object-valued (FileDiff, SubagentProgress, etc.),
|
||||
* and undefined resultDisplay consistently.
|
||||
*/
|
||||
export function toolResultDisplayToContentParts(
|
||||
resultDisplay: unknown,
|
||||
): ContentPart[] | undefined {
|
||||
if (resultDisplay === undefined || resultDisplay === null) {
|
||||
return undefined;
|
||||
}
|
||||
const text =
|
||||
typeof resultDisplay === 'string'
|
||||
? resultDisplay
|
||||
: JSON.stringify(resultDisplay);
|
||||
return [{ type: 'text', text }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the data record for a tool_response AgentEvent, preserving
|
||||
* all available metadata from the ToolCallResponseInfo.
|
||||
|
||||
@@ -155,9 +155,10 @@ describe('translateEvent', () => {
|
||||
expect(resp.content).toEqual([
|
||||
{ type: 'text', text: 'Permission denied to write' },
|
||||
]);
|
||||
expect(resp.displayContent).toEqual([
|
||||
{ type: 'text', text: 'Permission denied' },
|
||||
]);
|
||||
expect(resp.display?.result).toEqual({
|
||||
type: 'text',
|
||||
text: 'Permission denied',
|
||||
});
|
||||
expect(resp.data).toEqual({ errorType: 'permission_denied' });
|
||||
});
|
||||
|
||||
@@ -200,9 +201,12 @@ describe('translateEvent', () => {
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
const resp = result[0] as AgentEvent<'tool_response'>;
|
||||
expect(resp.displayContent).toEqual([
|
||||
{ type: 'text', text: JSON.stringify(objectDisplay) },
|
||||
]);
|
||||
expect(resp.display?.result).toEqual({
|
||||
type: 'diff',
|
||||
path: '/tmp/test.txt',
|
||||
beforeText: 'a',
|
||||
afterText: 'b',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes through string resultDisplay as-is', () => {
|
||||
@@ -220,9 +224,10 @@ describe('translateEvent', () => {
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
const resp = result[0] as AgentEvent<'tool_response'>;
|
||||
expect(resp.displayContent).toEqual([
|
||||
{ type: 'text', text: 'Command output text' },
|
||||
]);
|
||||
expect(resp.display?.result).toEqual({
|
||||
type: 'text',
|
||||
text: 'Command output text',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves outputFile and contentLength in data', () => {
|
||||
|
||||
@@ -25,12 +25,13 @@ import type {
|
||||
ErrorData,
|
||||
Usage,
|
||||
AgentEventType,
|
||||
ToolDisplay,
|
||||
} from './types.js';
|
||||
import {
|
||||
geminiPartsToContentParts,
|
||||
toolResultDisplayToContentParts,
|
||||
buildToolResponseData,
|
||||
} from './content-utils.js';
|
||||
import { toolResultDisplayToDisplayContent } from './tool-display-utils.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Translation State
|
||||
@@ -241,10 +242,14 @@ export function translateEvent(
|
||||
|
||||
case GeminiEventType.ToolCallResponse: {
|
||||
ensureStreamStart(state, out);
|
||||
const displayContent = toolResultDisplayToContentParts(
|
||||
event.value.resultDisplay,
|
||||
);
|
||||
const data = buildToolResponseData(event.value);
|
||||
const display: ToolDisplay | undefined = event.value.resultDisplay
|
||||
? {
|
||||
result: toolResultDisplayToDisplayContent(
|
||||
event.value.resultDisplay,
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
out.push(
|
||||
makeEvent('tool_response', state, {
|
||||
requestId: event.value.callId,
|
||||
@@ -253,7 +258,7 @@ export function translateEvent(
|
||||
? [{ type: 'text', text: event.value.error.message }]
|
||||
: geminiPartsToContentParts(event.value.responseParts),
|
||||
isError: event.value.error !== undefined,
|
||||
...(displayContent ? { displayContent } : {}),
|
||||
...(display ? { display } : {}),
|
||||
...(data ? { data } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -489,9 +489,10 @@ describe('LegacyAgentSession', () => {
|
||||
expect(toolResp?.content).toEqual([
|
||||
{ type: 'text', text: 'Permission denied' },
|
||||
]);
|
||||
expect(toolResp?.displayContent).toEqual([
|
||||
{ type: 'text', text: 'Error display' },
|
||||
]);
|
||||
expect(toolResp?.display?.result).toEqual({
|
||||
type: 'text',
|
||||
text: 'Error display',
|
||||
});
|
||||
});
|
||||
|
||||
it('stops on STOP_EXECUTION tool error', async () => {
|
||||
|
||||
@@ -23,8 +23,8 @@ import {
|
||||
buildToolResponseData,
|
||||
contentPartsToGeminiParts,
|
||||
geminiPartsToContentParts,
|
||||
toolResultDisplayToContentParts,
|
||||
} from './content-utils.js';
|
||||
import { populateToolDisplay } from './tool-display-utils.js';
|
||||
import { AgentSession } from './agent-session.js';
|
||||
import {
|
||||
createTranslationState,
|
||||
@@ -262,9 +262,12 @@ export class LegacyAgentProtocol implements AgentProtocol {
|
||||
const content: ContentPart[] = response.error
|
||||
? [{ type: 'text', text: response.error.message }]
|
||||
: geminiPartsToContentParts(response.responseParts);
|
||||
const displayContent = toolResultDisplayToContentParts(
|
||||
response.resultDisplay,
|
||||
);
|
||||
const display = populateToolDisplay({
|
||||
name: request.name,
|
||||
invocation: 'invocation' in tc ? tc.invocation : undefined,
|
||||
resultDisplay: response.resultDisplay,
|
||||
displayName: 'tool' in tc ? tc.tool?.displayName : undefined,
|
||||
});
|
||||
const data = buildToolResponseData(response);
|
||||
|
||||
this._emit([
|
||||
@@ -273,7 +276,7 @@ export class LegacyAgentProtocol implements AgentProtocol {
|
||||
name: request.name,
|
||||
content,
|
||||
isError: response.error !== undefined,
|
||||
...(displayContent ? { displayContent } : {}),
|
||||
...(display ? { display } : {}),
|
||||
...(data ? { data } : {}),
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
ToolInvocation,
|
||||
ToolResult,
|
||||
ToolResultDisplay,
|
||||
} from '../tools/tools.js';
|
||||
import type { DisplayContent } from './types.js';
|
||||
import {
|
||||
populateToolDisplay,
|
||||
renderDisplayDiff,
|
||||
displayContentToString,
|
||||
} from './tool-display-utils.js';
|
||||
|
||||
describe('tool-display-utils', () => {
|
||||
describe('populateToolDisplay', () => {
|
||||
it('uses displayName if provided', () => {
|
||||
const mockInvocation = {
|
||||
getDescription: () => 'Doing something...',
|
||||
} as unknown as ToolInvocation<object, ToolResult>;
|
||||
|
||||
const display = populateToolDisplay({
|
||||
name: 'raw-name',
|
||||
invocation: mockInvocation,
|
||||
displayName: 'Custom Display Name',
|
||||
});
|
||||
expect(display.name).toBe('Custom Display Name');
|
||||
expect(display.description).toBe('Doing something...');
|
||||
});
|
||||
|
||||
it('falls back to raw name if no displayName provided', () => {
|
||||
const mockInvocation = {
|
||||
getDescription: () => 'Doing something...',
|
||||
} as unknown as ToolInvocation<object, ToolResult>;
|
||||
|
||||
const display = populateToolDisplay({
|
||||
name: 'raw-name',
|
||||
invocation: mockInvocation,
|
||||
});
|
||||
expect(display.name).toBe('raw-name');
|
||||
});
|
||||
|
||||
it('populates result from resultDisplay', () => {
|
||||
const display = populateToolDisplay({
|
||||
name: 'test',
|
||||
resultDisplay: 'hello world',
|
||||
});
|
||||
expect(display.result).toEqual({ type: 'text', text: 'hello world' });
|
||||
});
|
||||
|
||||
it('translates FileDiff to DisplayDiff', () => {
|
||||
const fileDiff = {
|
||||
fileDiff: '@@ ...',
|
||||
fileName: 'test.ts',
|
||||
filePath: 'src/test.ts',
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
} as unknown as ToolResultDisplay;
|
||||
const display = populateToolDisplay({
|
||||
name: 'test',
|
||||
resultDisplay: fileDiff,
|
||||
});
|
||||
expect(display.result).toEqual({
|
||||
type: 'diff',
|
||||
path: 'src/test.ts',
|
||||
beforeText: 'old',
|
||||
afterText: 'new',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderDisplayDiff', () => {
|
||||
it('renders a universal diff', () => {
|
||||
const diff = {
|
||||
type: 'diff' as const,
|
||||
path: 'test.ts',
|
||||
beforeText: 'line 1\nline 2',
|
||||
afterText: 'line 1\nline 2 modified',
|
||||
};
|
||||
const rendered = renderDisplayDiff(diff);
|
||||
expect(rendered).toContain('--- test.ts\tOriginal');
|
||||
expect(rendered).toContain('+++ test.ts\tModified');
|
||||
expect(rendered).toContain('-line 2');
|
||||
expect(rendered).toContain('+line 2 modified');
|
||||
});
|
||||
});
|
||||
|
||||
describe('displayContentToString', () => {
|
||||
it('returns undefined for undefined input', () => {
|
||||
expect(displayContentToString(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns text for text input', () => {
|
||||
expect(displayContentToString({ type: 'text', text: 'hello' })).toBe(
|
||||
'hello',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a diff for diff input', () => {
|
||||
const diff = {
|
||||
type: 'diff' as const,
|
||||
path: 'test.ts',
|
||||
beforeText: 'old',
|
||||
afterText: 'new',
|
||||
};
|
||||
const rendered = displayContentToString(diff);
|
||||
expect(rendered).toContain('--- test.ts\tOriginal');
|
||||
expect(rendered).toContain('+++ test.ts\tModified');
|
||||
});
|
||||
|
||||
it('stringifies unknown structured objects', () => {
|
||||
const unknown = {
|
||||
type: 'something_else',
|
||||
data: 123,
|
||||
} as unknown as DisplayContent;
|
||||
expect(displayContentToString(unknown)).toBe(JSON.stringify(unknown));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as Diff from 'diff';
|
||||
import type {
|
||||
ToolInvocation,
|
||||
ToolResult,
|
||||
ToolResultDisplay,
|
||||
} from '../tools/tools.js';
|
||||
import type { ToolDisplay, DisplayContent, DisplayDiff } from './types.js';
|
||||
|
||||
/**
|
||||
* Populates a ToolDisplay object from a tool invocation and its result.
|
||||
* This serves as a centralized bridge during the migration to tool-controlled display.
|
||||
*/
|
||||
export function populateToolDisplay({
|
||||
name,
|
||||
invocation,
|
||||
resultDisplay,
|
||||
displayName,
|
||||
}: {
|
||||
name: string;
|
||||
invocation?: ToolInvocation<object, ToolResult>;
|
||||
resultDisplay?: ToolResultDisplay;
|
||||
displayName?: string;
|
||||
}): ToolDisplay {
|
||||
const display: ToolDisplay = {
|
||||
name: displayName || name,
|
||||
description: invocation?.getDescription?.(),
|
||||
};
|
||||
|
||||
if (resultDisplay) {
|
||||
display.result = toolResultDisplayToDisplayContent(resultDisplay);
|
||||
}
|
||||
|
||||
return display;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a legacy ToolResultDisplay into the new DisplayContent format.
|
||||
*/
|
||||
export function toolResultDisplayToDisplayContent(
|
||||
resultDisplay: ToolResultDisplay,
|
||||
): DisplayContent {
|
||||
if (typeof resultDisplay === 'string') {
|
||||
return { type: 'text', text: resultDisplay };
|
||||
}
|
||||
|
||||
// Handle FileDiff -> DisplayDiff
|
||||
if (
|
||||
typeof resultDisplay === 'object' &&
|
||||
resultDisplay !== null &&
|
||||
'fileDiff' in resultDisplay &&
|
||||
'newContent' in resultDisplay
|
||||
) {
|
||||
return {
|
||||
type: 'diff',
|
||||
path: resultDisplay.filePath || resultDisplay.fileName,
|
||||
beforeText: resultDisplay.originalContent ?? '',
|
||||
afterText: resultDisplay.newContent,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for other structured types (LsTool, GrepTool, etc.)
|
||||
// These will be fully migrated in Step 5.
|
||||
return {
|
||||
type: 'text',
|
||||
text: JSON.stringify(resultDisplay),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a universal diff string from a DisplayDiff object.
|
||||
*/
|
||||
export function renderDisplayDiff(diff: DisplayDiff): string {
|
||||
return Diff.createPatch(
|
||||
diff.path || 'file',
|
||||
diff.beforeText,
|
||||
diff.afterText,
|
||||
'Original',
|
||||
'Modified',
|
||||
{ context: 3 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a DisplayContent object into a string representation.
|
||||
* Useful for fallback displays or non-interactive environments.
|
||||
*/
|
||||
export function displayContentToString(
|
||||
display: DisplayContent | undefined,
|
||||
): string | undefined {
|
||||
if (!display) {
|
||||
return undefined;
|
||||
}
|
||||
if (display.type === 'text') {
|
||||
return display.text;
|
||||
}
|
||||
if (display.type === 'diff') {
|
||||
return renderDisplayDiff(display);
|
||||
}
|
||||
return JSON.stringify(display);
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export interface AgentEvents {
|
||||
/** Updates configuration about the current session/agent. */
|
||||
session_update: SessionUpdate;
|
||||
/** Message content provided by user, agent, or developer. */
|
||||
message: Message;
|
||||
message: AgentMessage;
|
||||
/** Event indicating the start of agent activity on a stream. */
|
||||
agent_start: AgentStart;
|
||||
/** Event indicating the end of agent activity on a stream. */
|
||||
@@ -170,17 +170,35 @@ export type ContentPart =
|
||||
) &
|
||||
WithMeta;
|
||||
|
||||
export interface Message {
|
||||
export interface AgentMessage {
|
||||
role: 'user' | 'agent' | 'developer';
|
||||
content: ContentPart[];
|
||||
}
|
||||
|
||||
export type DisplayText = { type: 'text'; text: string };
|
||||
export type DisplayDiff = {
|
||||
type: 'diff';
|
||||
path?: string;
|
||||
beforeText: string;
|
||||
afterText: string;
|
||||
};
|
||||
export type DisplayContent = DisplayText | DisplayDiff;
|
||||
|
||||
export interface ToolDisplay {
|
||||
name?: string;
|
||||
description?: string;
|
||||
resultSummary?: string;
|
||||
result?: DisplayContent;
|
||||
}
|
||||
|
||||
export interface ToolRequest {
|
||||
/** A unique identifier for this tool request to be correlated by the response. */
|
||||
requestId: string;
|
||||
/** The name of the tool being requested. */
|
||||
name: string;
|
||||
/** The arguments for the tool. */
|
||||
/** Tool-controlled display information. */
|
||||
display?: ToolDisplay;
|
||||
args: Record<string, unknown>;
|
||||
/** UI specific metadata */
|
||||
_meta?: {
|
||||
@@ -201,7 +219,8 @@ export interface ToolRequest {
|
||||
*/
|
||||
export interface ToolUpdate {
|
||||
requestId: string;
|
||||
displayContent?: ContentPart[];
|
||||
/** Tool-controlled display information. */
|
||||
display?: ToolDisplay;
|
||||
content?: ContentPart[];
|
||||
data?: Record<string, unknown>;
|
||||
/** UI specific metadata */
|
||||
@@ -221,8 +240,8 @@ export interface ToolUpdate {
|
||||
export interface ToolResponse {
|
||||
requestId: string;
|
||||
name: string;
|
||||
/** Content representing the tool call's outcome to be presented to the user. */
|
||||
displayContent?: ContentPart[];
|
||||
/** Tool-controlled display information. */
|
||||
display?: ToolDisplay;
|
||||
/** Multi-part content to be sent to the model. */
|
||||
content?: ContentPart[];
|
||||
/** Structured data to be sent to the model. */
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type ToolResult,
|
||||
BaseToolInvocation,
|
||||
type ToolCallConfirmationDetails,
|
||||
type ToolLiveOutput,
|
||||
type ExecuteOptions,
|
||||
} from '../tools/tools.js';
|
||||
import { type AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
@@ -185,10 +185,8 @@ class DelegateInvocation extends BaseToolInvocation<
|
||||
return invocation.shouldConfirmExecute(abortSignal);
|
||||
}
|
||||
|
||||
async execute(
|
||||
signal: AbortSignal,
|
||||
updateOutput?: (output: ToolLiveOutput) => void,
|
||||
): Promise<ToolResult> {
|
||||
async execute(options: ExecuteOptions): Promise<ToolResult> {
|
||||
const { abortSignal: signal, updateOutput } = options;
|
||||
const hintedParams = this.withUserHints(this.mappedInputs);
|
||||
const invocation = this.buildChildInvocation(hintedParams);
|
||||
|
||||
@@ -204,7 +202,10 @@ class DelegateInvocation extends BaseToolInvocation<
|
||||
},
|
||||
async ({ metadata }) => {
|
||||
metadata.input = this.params;
|
||||
const result = await invocation.execute(signal, updateOutput);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: signal,
|
||||
updateOutput,
|
||||
});
|
||||
metadata.output = result;
|
||||
return result;
|
||||
},
|
||||
|
||||
@@ -493,6 +493,42 @@ Body`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert mcp_servers with auth block in local agent (google-credentials)', () => {
|
||||
const markdown = {
|
||||
kind: 'local' as const,
|
||||
name: 'spanner-test-agent',
|
||||
description: 'An agent to test Spanner MCP with auth',
|
||||
mcp_servers: {
|
||||
spanner: {
|
||||
url: 'https://spanner.googleapis.com/mcp',
|
||||
type: 'http' as const,
|
||||
auth: {
|
||||
type: 'google-credentials' as const,
|
||||
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
|
||||
},
|
||||
timeout: 30000,
|
||||
},
|
||||
},
|
||||
system_prompt: 'You are a Spanner test agent.',
|
||||
};
|
||||
|
||||
const result = markdownToAgentDefinition(
|
||||
markdown,
|
||||
) as LocalAgentDefinition;
|
||||
expect(result.kind).toBe('local');
|
||||
expect(result.mcpServers).toBeDefined();
|
||||
expect(result.mcpServers!['spanner']).toMatchObject({
|
||||
url: 'https://spanner.googleapis.com/mcp',
|
||||
type: 'http',
|
||||
authProviderType: 'google_credentials',
|
||||
oauth: {
|
||||
enabled: true,
|
||||
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through unknown model names (e.g. auto)', () => {
|
||||
const markdown = {
|
||||
kind: 'local' as const,
|
||||
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
DEFAULT_MAX_TIME_MINUTES,
|
||||
} from './types.js';
|
||||
import type { A2AAuthConfig } from './auth-provider/types.js';
|
||||
import { MCPServerConfig } from '../config/config.js';
|
||||
import {
|
||||
MCPServerConfig,
|
||||
AuthProviderType,
|
||||
type MCPOAuthConfig,
|
||||
} from '../config/config.js';
|
||||
import { isValidToolName } from '../tools/tool-names.js';
|
||||
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
@@ -62,6 +66,22 @@ const mcpServerSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
include_tools: z.array(z.string()).optional(),
|
||||
exclude_tools: z.array(z.string()).optional(),
|
||||
auth: z
|
||||
.union([
|
||||
z.object({
|
||||
type: z.literal('google-credentials'),
|
||||
scopes: z.array(z.string()).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('oauth'),
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
scopes: z.array(z.string()).optional(),
|
||||
authorization_url: z.string().url().optional(),
|
||||
token_url: z.string().url().optional(),
|
||||
}),
|
||||
])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const localAgentSchema = z
|
||||
@@ -74,9 +94,12 @@ const localAgentSchema = z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.refine((val) => isValidToolName(val, { allowWildcards: true }), {
|
||||
message: 'Invalid tool name',
|
||||
}),
|
||||
.refine(
|
||||
(val: string) => isValidToolName(val, { allowWildcards: true }),
|
||||
{
|
||||
message: 'Invalid tool name',
|
||||
},
|
||||
),
|
||||
)
|
||||
.optional(),
|
||||
mcp_servers: z.record(mcpServerSchema).optional(),
|
||||
@@ -191,7 +214,7 @@ const remoteAgentJsonSchema = baseRemoteAgentSchema
|
||||
.extend({
|
||||
agent_card_url: z.undefined().optional(),
|
||||
agent_card_json: z.string().refine(
|
||||
(val) => {
|
||||
(val: string) => {
|
||||
try {
|
||||
JSON.parse(val);
|
||||
return true;
|
||||
@@ -511,6 +534,28 @@ export function markdownToAgentDefinition(
|
||||
const mcpServers: Record<string, MCPServerConfig> = {};
|
||||
if (markdown.mcp_servers) {
|
||||
for (const [name, config] of Object.entries(markdown.mcp_servers)) {
|
||||
let authProviderType: AuthProviderType | undefined = undefined;
|
||||
let oauth: MCPOAuthConfig | undefined = undefined;
|
||||
|
||||
if (config.auth) {
|
||||
if (config.auth.type === 'google-credentials') {
|
||||
authProviderType = AuthProviderType.GOOGLE_CREDENTIALS;
|
||||
oauth = {
|
||||
enabled: true,
|
||||
scopes: config.auth.scopes,
|
||||
};
|
||||
} else if (config.auth.type === 'oauth') {
|
||||
oauth = {
|
||||
enabled: true,
|
||||
clientId: config.auth.client_id,
|
||||
clientSecret: config.auth.client_secret,
|
||||
scopes: config.auth.scopes,
|
||||
authorizationUrl: config.auth.authorization_url,
|
||||
tokenUrl: config.auth.token_url,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
mcpServers[name] = new MCPServerConfig(
|
||||
config.command,
|
||||
config.args,
|
||||
@@ -526,6 +571,9 @@ export function markdownToAgentDefinition(
|
||||
config.description,
|
||||
config.include_tools,
|
||||
config.exclude_tools,
|
||||
undefined, // extension
|
||||
oauth,
|
||||
authProviderType,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,9 @@ describe('analyzeScreenshot', () => {
|
||||
const invocation = tool.build({
|
||||
instruction: 'Find the blue submit button',
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
// Verify screenshot was captured
|
||||
expect(browserManager.callTool).toHaveBeenCalledWith(
|
||||
@@ -165,7 +167,7 @@ describe('analyzeScreenshot', () => {
|
||||
const invocation = tool.build({
|
||||
instruction: 'Find the search bar',
|
||||
});
|
||||
await invocation.execute(new AbortController().signal);
|
||||
await invocation.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
const contentGenerator = config.getContentGenerator();
|
||||
expect(contentGenerator.generateContent).toHaveBeenCalledWith(
|
||||
@@ -194,7 +196,9 @@ describe('analyzeScreenshot', () => {
|
||||
const invocation = tool.build({
|
||||
instruction: 'Find the button',
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.llmContent).toContain('Failed to capture screenshot');
|
||||
@@ -217,7 +221,9 @@ describe('analyzeScreenshot', () => {
|
||||
const invocation = tool.build({
|
||||
instruction: 'Check the layout',
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.llmContent).toContain('Visual model returned no analysis');
|
||||
@@ -238,7 +244,9 @@ describe('analyzeScreenshot', () => {
|
||||
const invocation = tool.build({
|
||||
instruction: 'Find the red error',
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.llmContent).toContain(
|
||||
@@ -261,7 +269,9 @@ describe('analyzeScreenshot', () => {
|
||||
const invocation = tool.build({
|
||||
instruction: 'Identify the element',
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.llmContent).toContain(
|
||||
@@ -281,7 +291,9 @@ describe('analyzeScreenshot', () => {
|
||||
const invocation = tool.build({
|
||||
instruction: 'Find something',
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.llmContent).toContain('Visual analysis failed');
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Kind,
|
||||
type ToolResult,
|
||||
type ToolInvocation,
|
||||
type ExecuteOptions,
|
||||
} from '../../tools/tools.js';
|
||||
import { Environment } from '@google/genai';
|
||||
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
|
||||
@@ -80,7 +81,7 @@ class AnalyzeScreenshotInvocation extends BaseToolInvocation<
|
||||
return `Visual analysis: "${instruction}"`;
|
||||
}
|
||||
|
||||
async execute(signal: AbortSignal): Promise<ToolResult> {
|
||||
async execute({ abortSignal: signal }: ExecuteOptions): Promise<ToolResult> {
|
||||
try {
|
||||
const instruction = String(this.params['instruction'] ?? '');
|
||||
|
||||
|
||||
@@ -223,7 +223,10 @@ describe('BrowserAgentInvocation', () => {
|
||||
const controller = new AbortController();
|
||||
const updateOutput: (output: ToolLiveOutput) => void = vi.fn();
|
||||
|
||||
const result = await invocation.execute(controller.signal, updateOutput);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: controller.signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
expect(Array.isArray(result.llmContent)).toBe(true);
|
||||
expect((result.llmContent as Array<{ text: string }>)[0].text).toContain(
|
||||
@@ -242,7 +245,7 @@ describe('BrowserAgentInvocation', () => {
|
||||
const controller = new AbortController();
|
||||
// Should not throw even with no updateOutput
|
||||
await expect(
|
||||
invocation.execute(controller.signal),
|
||||
invocation.execute({ abortSignal: controller.signal }),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
@@ -256,7 +259,9 @@ describe('BrowserAgentInvocation', () => {
|
||||
);
|
||||
|
||||
const controller = new AbortController();
|
||||
const result = await invocation.execute(controller.signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(removeInputBlocker).toHaveBeenCalled();
|
||||
@@ -298,7 +303,10 @@ describe('BrowserAgentInvocation', () => {
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
await invocation.execute(new AbortController().signal, updateOutput);
|
||||
await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
const firstCall = updateOutput.mock.calls[0]?.[0] as SubagentProgress;
|
||||
expect(firstCall.isSubagentProgress).toBe(true);
|
||||
@@ -315,7 +323,10 @@ describe('BrowserAgentInvocation', () => {
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
await invocation.execute(new AbortController().signal, updateOutput);
|
||||
await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
const lastCall = updateOutput.mock.calls[
|
||||
updateOutput.mock.calls.length - 1
|
||||
@@ -334,10 +345,10 @@ describe('BrowserAgentInvocation', () => {
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const executePromise = invocation.execute(
|
||||
new AbortController().signal,
|
||||
const executePromise = invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
updateOutput,
|
||||
);
|
||||
});
|
||||
|
||||
// Allow createBrowserAgentDefinition to resolve and onActivity to be registered
|
||||
await Promise.resolve();
|
||||
@@ -377,10 +388,10 @@ describe('BrowserAgentInvocation', () => {
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const executePromise = invocation.execute(
|
||||
new AbortController().signal,
|
||||
const executePromise = invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
updateOutput,
|
||||
);
|
||||
});
|
||||
|
||||
// Allow createBrowserAgentDefinition to resolve and onActivity to be registered
|
||||
await Promise.resolve();
|
||||
@@ -424,10 +435,10 @@ describe('BrowserAgentInvocation', () => {
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const executePromise = invocation.execute(
|
||||
new AbortController().signal,
|
||||
const executePromise = invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
updateOutput,
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
@@ -475,10 +486,10 @@ describe('BrowserAgentInvocation', () => {
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const executePromise = invocation.execute(
|
||||
new AbortController().signal,
|
||||
const executePromise = invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
updateOutput,
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
@@ -519,10 +530,10 @@ describe('BrowserAgentInvocation', () => {
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const executePromise = invocation.execute(
|
||||
new AbortController().signal,
|
||||
const executePromise = invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
updateOutput,
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
@@ -564,10 +575,10 @@ describe('BrowserAgentInvocation', () => {
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const executePromise = invocation.execute(
|
||||
new AbortController().signal,
|
||||
const executePromise = invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
updateOutput,
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
@@ -604,10 +615,10 @@ describe('BrowserAgentInvocation', () => {
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const executePromise = invocation.execute(
|
||||
new AbortController().signal,
|
||||
const executePromise = invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
updateOutput,
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
@@ -647,10 +658,10 @@ describe('BrowserAgentInvocation', () => {
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const executePromise = invocation.execute(
|
||||
new AbortController().signal,
|
||||
const executePromise = invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
updateOutput,
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
@@ -703,7 +714,10 @@ describe('BrowserAgentInvocation', () => {
|
||||
mockParams,
|
||||
mockMessageBus,
|
||||
);
|
||||
await invocation.execute(new AbortController().signal, vi.fn());
|
||||
await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
updateOutput: vi.fn(),
|
||||
});
|
||||
|
||||
expect(recordBrowserAgentTaskOutcome).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
@@ -731,7 +745,10 @@ describe('BrowserAgentInvocation', () => {
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
await invocation.execute(new AbortController().signal, updateOutput);
|
||||
await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
expect(recordBrowserAgentTaskOutcome).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
@@ -751,7 +768,10 @@ describe('BrowserAgentInvocation', () => {
|
||||
mockParams,
|
||||
mockMessageBus,
|
||||
);
|
||||
await invocation.execute(new AbortController().signal, vi.fn());
|
||||
await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
updateOutput: vi.fn(),
|
||||
});
|
||||
|
||||
expect(cleanupBrowserAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -807,7 +827,7 @@ describe('BrowserAgentInvocation', () => {
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
await invocation.execute(new AbortController().signal);
|
||||
await invocation.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
// Verify list_pages was called
|
||||
expect(mockBrowserManager.callTool).toHaveBeenCalledWith(
|
||||
|
||||
@@ -22,7 +22,7 @@ import { LocalAgentExecutor } from '../local-executor.js';
|
||||
import {
|
||||
BaseToolInvocation,
|
||||
type ToolResult,
|
||||
type ToolLiveOutput,
|
||||
type ExecuteOptions,
|
||||
} from '../../tools/tools.js';
|
||||
import { ToolErrorType } from '../../tools/tool-error.js';
|
||||
import {
|
||||
@@ -107,10 +107,8 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
|
||||
* 3. Runs the agent via LocalAgentExecutor
|
||||
* 4. Cleans up browser resources
|
||||
*/
|
||||
async execute(
|
||||
signal: AbortSignal,
|
||||
updateOutput?: (output: ToolLiveOutput) => void,
|
||||
): Promise<ToolResult> {
|
||||
async execute(options: ExecuteOptions): Promise<ToolResult> {
|
||||
const { abortSignal: signal, updateOutput } = options;
|
||||
const invocationStartMs = Date.now();
|
||||
let browserManager;
|
||||
let recentActivity: SubagentActivityItem[] = [];
|
||||
|
||||
@@ -139,7 +139,7 @@ describe('mcpToolWrapper', () => {
|
||||
);
|
||||
|
||||
const invocation = tools[1].build({ uid: 'elem-123' });
|
||||
await invocation.execute(new AbortController().signal);
|
||||
await invocation.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
expect(mockBrowserManager.callTool).toHaveBeenCalledWith(
|
||||
'click',
|
||||
@@ -158,7 +158,9 @@ describe('mcpToolWrapper', () => {
|
||||
);
|
||||
|
||||
const invocation = tools[0].build({ verbose: true });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.llmContent).toBe('Tool result');
|
||||
expect(result.error).toBeUndefined();
|
||||
@@ -177,7 +179,9 @@ describe('mcpToolWrapper', () => {
|
||||
);
|
||||
|
||||
const invocation = tools[1].build({ uid: 'invalid' });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.error?.message).toBe('Element not found');
|
||||
@@ -195,7 +199,9 @@ describe('mcpToolWrapper', () => {
|
||||
);
|
||||
|
||||
const invocation = tools[0].build({});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.error?.message).toBe('Connection lost');
|
||||
@@ -212,7 +218,7 @@ describe('mcpToolWrapper', () => {
|
||||
|
||||
const clickTool = tools.find((t) => t.name === 'click')!;
|
||||
const invocation = clickTool.build({ uid: 'elem-42' });
|
||||
await invocation.execute(new AbortController().signal);
|
||||
await invocation.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
// callTool: suspend blocker + click + resume blocker
|
||||
expect(mockBrowserManager.callTool).toHaveBeenCalledTimes(3);
|
||||
@@ -257,7 +263,7 @@ describe('mcpToolWrapper', () => {
|
||||
|
||||
const snapshotTool = tools.find((t) => t.name === 'take_snapshot')!;
|
||||
const invocation = snapshotTool.build({});
|
||||
await invocation.execute(new AbortController().signal);
|
||||
await invocation.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
// callTool should only be called once for take_snapshot — no suspend/resume
|
||||
expect(mockBrowserManager.callTool).toHaveBeenCalledTimes(1);
|
||||
@@ -277,7 +283,7 @@ describe('mcpToolWrapper', () => {
|
||||
|
||||
const clickTool = tools.find((t) => t.name === 'click')!;
|
||||
const invocation = clickTool.build({ uid: 'elem-42' });
|
||||
await invocation.execute(new AbortController().signal);
|
||||
await invocation.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
// callTool should only be called once for click — no suspend/resume
|
||||
expect(mockBrowserManager.callTool).toHaveBeenCalledTimes(1);
|
||||
@@ -297,7 +303,9 @@ describe('mcpToolWrapper', () => {
|
||||
|
||||
const clickTool = tools.find((t) => t.name === 'click')!;
|
||||
const invocation = clickTool.build({ uid: 'bad-elem' });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
// Should return error, not throw
|
||||
expect(result.error).toBeDefined();
|
||||
@@ -328,7 +336,9 @@ describe('mcpToolWrapper', () => {
|
||||
|
||||
const uploadTool = tools.find((t) => t.name === 'upload_file')!;
|
||||
const invocation = uploadTool.build({ path: 'test.txt' });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.llmContent).toContain('File uploads are blocked');
|
||||
@@ -345,7 +355,9 @@ describe('mcpToolWrapper', () => {
|
||||
|
||||
const uploadTool = tools.find((t) => t.name === 'upload_file')!;
|
||||
const invocation = uploadTool.build({ path: 'test.txt' });
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.llmContent).toBe('Tool result');
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type ToolInvocation,
|
||||
type ToolCallConfirmationDetails,
|
||||
type PolicyUpdateOptions,
|
||||
type ExecuteOptions,
|
||||
} from '../../tools/tools.js';
|
||||
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
|
||||
import {
|
||||
@@ -117,7 +118,7 @@ class McpToolInvocation extends BaseToolInvocation<
|
||||
return this.shouldDisableInput && INTERACTIVE_TOOLS.has(this.toolName);
|
||||
}
|
||||
|
||||
async execute(signal: AbortSignal): Promise<ToolResult> {
|
||||
async execute({ abortSignal: signal }: ExecuteOptions): Promise<ToolResult> {
|
||||
try {
|
||||
// Hard block for file uploads if configured
|
||||
if (this.blockFileUploads && this.toolName === 'upload_file') {
|
||||
|
||||
@@ -110,8 +110,8 @@ export const CodebaseInvestigatorAgent = (
|
||||
},
|
||||
|
||||
runConfig: {
|
||||
maxTimeMinutes: 3,
|
||||
maxTurns: 10,
|
||||
maxTimeMinutes: 10,
|
||||
maxTurns: 50,
|
||||
},
|
||||
|
||||
toolConfig: {
|
||||
|
||||
@@ -114,7 +114,7 @@ export function createUnauthorizedToolError(toolName: string): string {
|
||||
export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
readonly definition: LocalAgentDefinition<TOutput>;
|
||||
|
||||
private readonly agentId: string;
|
||||
readonly agentId: string;
|
||||
private readonly toolRegistry: ToolRegistry;
|
||||
private readonly promptRegistry: PromptRegistry;
|
||||
private readonly resourceRegistry: ResourceRegistry;
|
||||
|
||||
@@ -79,6 +79,7 @@ describe('LocalSubagentInvocation', () => {
|
||||
mockExecutorInstance = {
|
||||
run: vi.fn(),
|
||||
definition: testDefinition,
|
||||
agentId: 'test-agent-id',
|
||||
} as unknown as Mocked<LocalAgentExecutor<z.ZodUnknown>>;
|
||||
|
||||
MockLocalAgentExecutor.create.mockResolvedValue(
|
||||
@@ -186,7 +187,10 @@ describe('LocalSubagentInvocation', () => {
|
||||
};
|
||||
mockExecutorInstance.run.mockResolvedValue(mockOutput);
|
||||
|
||||
const result = await invocation.execute(signal, updateOutput);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
expect(MockLocalAgentExecutor.create).toHaveBeenCalledWith(
|
||||
testDefinition,
|
||||
@@ -223,7 +227,10 @@ describe('LocalSubagentInvocation', () => {
|
||||
};
|
||||
mockExecutorInstance.run.mockResolvedValue(mockOutput);
|
||||
|
||||
const result = await invocation.execute(signal, updateOutput);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
const display = result.returnDisplay as SubagentProgress;
|
||||
expect(display.isSubagentProgress).toBe(true);
|
||||
@@ -253,7 +260,7 @@ describe('LocalSubagentInvocation', () => {
|
||||
return { result: 'Done', terminate_reason: AgentTerminateMode.GOAL };
|
||||
});
|
||||
|
||||
await invocation.execute(signal, updateOutput);
|
||||
await invocation.execute({ abortSignal: signal, updateOutput });
|
||||
|
||||
expect(updateOutput).toHaveBeenCalledTimes(4); // Initial + 2 updates + Final completion
|
||||
const lastCall = updateOutput.mock.calls[3][0] as SubagentProgress;
|
||||
@@ -292,7 +299,7 @@ describe('LocalSubagentInvocation', () => {
|
||||
return { result: 'Done', terminate_reason: AgentTerminateMode.GOAL };
|
||||
});
|
||||
|
||||
await invocation.execute(signal, updateOutput);
|
||||
await invocation.execute({ abortSignal: signal, updateOutput });
|
||||
|
||||
const calls = updateOutput.mock.calls;
|
||||
const lastCall = calls[calls.length - 1][0] as SubagentProgress;
|
||||
@@ -325,7 +332,7 @@ describe('LocalSubagentInvocation', () => {
|
||||
return { result: 'Done', terminate_reason: AgentTerminateMode.GOAL };
|
||||
});
|
||||
|
||||
await invocation.execute(signal, updateOutput);
|
||||
await invocation.execute({ abortSignal: signal, updateOutput });
|
||||
|
||||
expect(updateOutput).toHaveBeenCalledTimes(4); // Initial + 2 updates + Final completion
|
||||
const lastCall = updateOutput.mock.calls[3][0] as SubagentProgress;
|
||||
@@ -359,7 +366,7 @@ describe('LocalSubagentInvocation', () => {
|
||||
return { result: 'Done', terminate_reason: AgentTerminateMode.GOAL };
|
||||
});
|
||||
|
||||
await invocation.execute(signal, updateOutput);
|
||||
await invocation.execute({ abortSignal: signal, updateOutput });
|
||||
|
||||
expect(updateOutput).toHaveBeenCalled();
|
||||
const lastCall = updateOutput.mock.calls[
|
||||
@@ -403,7 +410,7 @@ describe('LocalSubagentInvocation', () => {
|
||||
};
|
||||
});
|
||||
|
||||
await invocation.execute(signal, updateOutput);
|
||||
await invocation.execute({ abortSignal: signal, updateOutput });
|
||||
|
||||
expect(updateOutput).toHaveBeenCalledTimes(4);
|
||||
const lastCall = updateOutput.mock.calls[3][0] as SubagentProgress;
|
||||
@@ -432,7 +439,7 @@ describe('LocalSubagentInvocation', () => {
|
||||
});
|
||||
|
||||
// Execute without the optional callback
|
||||
const result = await invocation.execute(signal);
|
||||
const result = await invocation.execute({ abortSignal: signal });
|
||||
expect(result.error).toBeUndefined();
|
||||
const display = result.returnDisplay as SubagentProgress;
|
||||
expect(display.isSubagentProgress).toBe(true);
|
||||
@@ -444,7 +451,10 @@ describe('LocalSubagentInvocation', () => {
|
||||
const error = new Error('Model failed during execution.');
|
||||
mockExecutorInstance.run.mockRejectedValue(error);
|
||||
|
||||
const result = await invocation.execute(signal, updateOutput);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.llmContent).toBe(
|
||||
@@ -465,7 +475,10 @@ describe('LocalSubagentInvocation', () => {
|
||||
const creationError = new Error('Failed to initialize tools.');
|
||||
MockLocalAgentExecutor.create.mockRejectedValue(creationError);
|
||||
|
||||
const result = await invocation.execute(signal, updateOutput);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
expect(mockExecutorInstance.run).not.toHaveBeenCalled();
|
||||
expect(result.error).toBeUndefined();
|
||||
@@ -486,10 +499,10 @@ describe('LocalSubagentInvocation', () => {
|
||||
mockExecutorInstance.run.mockRejectedValue(abortError);
|
||||
|
||||
const controller = new AbortController();
|
||||
const executePromise = invocation.execute(
|
||||
controller.signal,
|
||||
const executePromise = invocation.execute({
|
||||
abortSignal: controller.signal,
|
||||
updateOutput,
|
||||
);
|
||||
});
|
||||
controller.abort();
|
||||
await expect(executePromise).rejects.toThrow('Aborted');
|
||||
|
||||
@@ -506,9 +519,9 @@ describe('LocalSubagentInvocation', () => {
|
||||
};
|
||||
mockExecutorInstance.run.mockResolvedValue(mockOutput);
|
||||
|
||||
await expect(invocation.execute(signal, updateOutput)).rejects.toThrow(
|
||||
'Operation cancelled by user',
|
||||
);
|
||||
await expect(
|
||||
invocation.execute({ abortSignal: signal, updateOutput }),
|
||||
).rejects.toThrow('Operation cancelled by user');
|
||||
});
|
||||
|
||||
it('should publish SUBAGENT_ACTIVITY events to the MessageBus', async () => {
|
||||
@@ -528,7 +541,7 @@ describe('LocalSubagentInvocation', () => {
|
||||
return { result: 'Done', terminate_reason: AgentTerminateMode.GOAL };
|
||||
});
|
||||
|
||||
await invocation.execute(signal, updateOutput);
|
||||
await invocation.execute({ abortSignal: signal, updateOutput });
|
||||
|
||||
expect(mockMessageBus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -10,7 +10,7 @@ import { LocalAgentExecutor } from './local-executor.js';
|
||||
import {
|
||||
BaseToolInvocation,
|
||||
type ToolResult,
|
||||
type ToolLiveOutput,
|
||||
type ExecuteOptions,
|
||||
} from '../tools/tools.js';
|
||||
import {
|
||||
type LocalAgentDefinition,
|
||||
@@ -25,12 +25,14 @@ import {
|
||||
isToolActivityError,
|
||||
} from './types.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { z } from 'zod';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import {
|
||||
sanitizeThoughtContent,
|
||||
sanitizeToolArgs,
|
||||
sanitizeErrorMessage,
|
||||
} from '../utils/agent-sanitization-utils.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
const INPUT_PREVIEW_MAX_LENGTH = 50;
|
||||
const DESCRIPTION_MAX_LENGTH = 200;
|
||||
@@ -103,11 +105,10 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
* agent's thoughts, to the user interface.
|
||||
* @returns A `Promise` that resolves with the final `ToolResult`.
|
||||
*/
|
||||
async execute(
|
||||
signal: AbortSignal,
|
||||
updateOutput?: (output: ToolLiveOutput) => void,
|
||||
): Promise<ToolResult> {
|
||||
async execute(options: ExecuteOptions): Promise<ToolResult> {
|
||||
const { abortSignal: signal, updateOutput } = options;
|
||||
const recentActivity: SubagentActivityItem[] = [];
|
||||
let executor: LocalAgentExecutor<z.ZodUnknown> | undefined;
|
||||
|
||||
try {
|
||||
if (updateOutput) {
|
||||
@@ -273,7 +274,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
}
|
||||
};
|
||||
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
executor = await LocalAgentExecutor.create(
|
||||
this.definition,
|
||||
this.context,
|
||||
onActivity,
|
||||
@@ -319,11 +320,14 @@ ${output.result}`;
|
||||
return {
|
||||
llmContent: [{ text: resultContent }],
|
||||
returnDisplay: progress,
|
||||
data: { agentId: executor.agentId },
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
debugLogger.error(`Subagent '${this.definition.name}' failed:`, error);
|
||||
|
||||
const isAbort =
|
||||
(error instanceof Error && error.name === 'AbortError') ||
|
||||
errorMessage.includes('Aborted');
|
||||
@@ -369,6 +373,7 @@ ${output.result}`;
|
||||
return {
|
||||
llmContent: `Subagent '${this.definition.name}' failed. Error: ${errorMessage}`,
|
||||
returnDisplay: progress,
|
||||
data: executor ? { agentId: executor.agentId } : undefined,
|
||||
// We omit the 'error' property so that the UI renders our rich returnDisplay
|
||||
// instead of the raw error message. The llmContent still informs the agent of the failure.
|
||||
};
|
||||
|
||||
@@ -142,7 +142,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
{},
|
||||
mockMessageBus,
|
||||
);
|
||||
await invocation.execute(new AbortController().signal);
|
||||
await invocation.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
expect(mockClientManager.sendMessageStream).toHaveBeenCalledWith(
|
||||
'test-agent',
|
||||
@@ -185,7 +185,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
},
|
||||
mockMessageBus,
|
||||
);
|
||||
await invocation.execute(new AbortController().signal);
|
||||
await invocation.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
expect(mockClientManager.loadAgent).toHaveBeenCalledWith(
|
||||
'test-agent',
|
||||
@@ -230,7 +230,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
await invocation.execute(new AbortController().signal);
|
||||
await invocation.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
expect(A2AAuthProviderFactory.create).toHaveBeenCalledWith({
|
||||
authConfig: mockAuth,
|
||||
@@ -264,7 +264,9 @@ describe('RemoteAgentInvocation', () => {
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.returnDisplay).toMatchObject({ state: 'error' });
|
||||
expect((result.returnDisplay as SubagentProgress).result).toContain(
|
||||
@@ -293,7 +295,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
},
|
||||
mockMessageBus,
|
||||
);
|
||||
await invocation.execute(new AbortController().signal);
|
||||
await invocation.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
expect(mockClientManager.loadAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -325,7 +327,9 @@ describe('RemoteAgentInvocation', () => {
|
||||
);
|
||||
|
||||
// Execute first time
|
||||
const result1 = await invocation1.execute(new AbortController().signal);
|
||||
const result1 = await invocation1.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
expect(result1.returnDisplay).toMatchObject({
|
||||
result: 'Response 1',
|
||||
});
|
||||
@@ -357,7 +361,9 @@ describe('RemoteAgentInvocation', () => {
|
||||
},
|
||||
mockMessageBus,
|
||||
);
|
||||
const result2 = await invocation2.execute(new AbortController().signal);
|
||||
const result2 = await invocation2.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
expect((result2.returnDisplay as SubagentProgress).result).toBe(
|
||||
'Response 2',
|
||||
);
|
||||
@@ -390,7 +396,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
},
|
||||
mockMessageBus,
|
||||
);
|
||||
await invocation3.execute(new AbortController().signal);
|
||||
await invocation3.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
// Fourth call: Should start new task (taskId undefined)
|
||||
mockClientManager.sendMessageStream.mockImplementationOnce(
|
||||
@@ -412,7 +418,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
},
|
||||
mockMessageBus,
|
||||
);
|
||||
await invocation4.execute(new AbortController().signal);
|
||||
await invocation4.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
expect(mockClientManager.sendMessageStream).toHaveBeenLastCalledWith(
|
||||
'test-agent',
|
||||
@@ -447,7 +453,10 @@ describe('RemoteAgentInvocation', () => {
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
await invocation.execute(new AbortController().signal, updateOutput);
|
||||
await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
expect(updateOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -495,7 +504,9 @@ describe('RemoteAgentInvocation', () => {
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
const result = await invocation.execute(controller.signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
expect(result.returnDisplay).toMatchObject({ state: 'error' });
|
||||
});
|
||||
@@ -517,7 +528,9 @@ describe('RemoteAgentInvocation', () => {
|
||||
},
|
||||
mockMessageBus,
|
||||
);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.returnDisplay).toMatchObject({
|
||||
state: 'error',
|
||||
@@ -550,7 +563,9 @@ describe('RemoteAgentInvocation', () => {
|
||||
},
|
||||
mockMessageBus,
|
||||
);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
// Just check that text is present, exact formatting depends on helper
|
||||
expect((result.returnDisplay as SubagentProgress).result).toContain(
|
||||
@@ -593,10 +608,10 @@ describe('RemoteAgentInvocation', () => {
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
const result = await invocation.execute(
|
||||
new AbortController().signal,
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
updateOutput,
|
||||
);
|
||||
});
|
||||
|
||||
expect(updateOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -670,7 +685,10 @@ describe('RemoteAgentInvocation', () => {
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
await invocation.execute(new AbortController().signal, updateOutput);
|
||||
await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
expect(updateOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -738,7 +756,9 @@ describe('RemoteAgentInvocation', () => {
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.returnDisplay).toMatchObject({ state: 'error' });
|
||||
expect((result.returnDisplay as SubagentProgress).result).toContain(
|
||||
@@ -758,7 +778,9 @@ describe('RemoteAgentInvocation', () => {
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.returnDisplay).toMatchObject({ state: 'error' });
|
||||
expect((result.returnDisplay as SubagentProgress).result).toContain(
|
||||
@@ -787,7 +809,9 @@ describe('RemoteAgentInvocation', () => {
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.returnDisplay).toMatchObject({ state: 'error' });
|
||||
// Should contain both the partial output and the error message
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type ToolConfirmationOutcome,
|
||||
type ToolResult,
|
||||
type ToolCallConfirmationDetails,
|
||||
type ExecuteOptions,
|
||||
} from '../tools/tools.js';
|
||||
import {
|
||||
DEFAULT_QUERY_STRING,
|
||||
@@ -28,7 +29,6 @@ import type {
|
||||
import { extractIdsFromResponse, A2AResultReassembler } from './a2aUtils.js';
|
||||
import type { AuthenticationHandler } from '@a2a-js/sdk/client';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
||||
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
|
||||
import { A2AAgentError } from './a2a-errors.js';
|
||||
|
||||
@@ -126,10 +126,8 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
};
|
||||
}
|
||||
|
||||
async execute(
|
||||
_signal: AbortSignal,
|
||||
updateOutput?: (output: string | AnsiOutput | SubagentProgress) => void,
|
||||
): Promise<ToolResult> {
|
||||
async execute(options: ExecuteOptions): Promise<ToolResult> {
|
||||
const { abortSignal: _signal, updateOutput } = options;
|
||||
// 1. Ensure the agent is loaded (cached by manager)
|
||||
// We assume the user has provided an access token via some mechanism (TODO),
|
||||
// or we rely on ADC.
|
||||
|
||||
@@ -170,6 +170,43 @@ function buildSystemPrompt(skillsDir: string): string {
|
||||
'Naming: kebab-case (e.g., fix-lint-errors, run-migrations).',
|
||||
'',
|
||||
'============================================================',
|
||||
'UPDATING EXISTING SKILLS (PATCHES)',
|
||||
'============================================================',
|
||||
'',
|
||||
'You can ONLY write files inside your skills directory. However, existing skills',
|
||||
'may live outside it (global or workspace locations).',
|
||||
'',
|
||||
'NEVER patch builtin or extension skills. They are managed externally and',
|
||||
'overwritten on updates. Patches targeting these paths will be rejected.',
|
||||
'',
|
||||
'To propose an update to an existing skill that lives OUTSIDE your directory:',
|
||||
'',
|
||||
'1. Read the original file(s) using read_file (paths are listed in "Existing Skills").',
|
||||
'2. Write a unified diff patch file to:',
|
||||
` ${skillsDir}/<skill-name>.patch`,
|
||||
'',
|
||||
'Patch format (strict unified diff):',
|
||||
'',
|
||||
' --- /absolute/path/to/original/SKILL.md',
|
||||
' +++ /absolute/path/to/original/SKILL.md',
|
||||
' @@ -<start>,<count> +<start>,<count> @@',
|
||||
' <context line>',
|
||||
' -<removed line>',
|
||||
' +<added line>',
|
||||
' <context line>',
|
||||
'',
|
||||
'Rules for patches:',
|
||||
'- Use the EXACT absolute file path in BOTH --- and +++ headers (NO a/ or b/ prefixes).',
|
||||
'- Include 3 lines of context around each change (standard unified diff).',
|
||||
'- A single .patch file can contain hunks for multiple files in the same skill.',
|
||||
'- For new files, use `/dev/null` as the --- source.',
|
||||
'- Line counts in @@ headers MUST be accurate.',
|
||||
'- Do NOT create a patch if you can create or update a skill in your own directory instead.',
|
||||
'- Patches will be validated by parsing and dry-run applying them. Invalid patches are discarded.',
|
||||
'',
|
||||
'The same quality bar applies: only propose updates backed by evidence from sessions.',
|
||||
'',
|
||||
'============================================================',
|
||||
'QUALITY RULES (STRICT)',
|
||||
'============================================================',
|
||||
'',
|
||||
@@ -192,7 +229,8 @@ function buildSystemPrompt(skillsDir: string): string {
|
||||
'5. For promising patterns, use read_file on the session file paths to inspect the full',
|
||||
' conversation. Confirm the workflow was actually repeated and validated.',
|
||||
'6. For each confirmed skill, verify it meets ALL criteria (repeatable, procedural, high-leverage).',
|
||||
'7. Write new SKILL.md files or update existing ones using write_file.',
|
||||
'7. Write new SKILL.md files or update existing ones in your directory using write_file.',
|
||||
' For skills that live OUTSIDE your directory, write a .patch file instead (see UPDATING EXISTING SKILLS).',
|
||||
'8. Write COMPLETE files — never partially update a SKILL.md.',
|
||||
'',
|
||||
'IMPORTANT: Do NOT read every session. Only read sessions whose summaries suggest a',
|
||||
|
||||
@@ -41,7 +41,7 @@ const DEFAULT_ACTIONS: ModelPolicyActionMap = {
|
||||
unknown: 'prompt',
|
||||
};
|
||||
|
||||
const SILENT_ACTIONS: ModelPolicyActionMap = {
|
||||
export const SILENT_ACTIONS: ModelPolicyActionMap = {
|
||||
terminal: 'silent',
|
||||
transient: 'silent',
|
||||
not_found: 'silent',
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
buildFallbackPolicyContext,
|
||||
applyModelSelection,
|
||||
} from './policyHelpers.js';
|
||||
import { createDefaultPolicy } from './policyCatalog.js';
|
||||
import { createDefaultPolicy, SILENT_ACTIONS } from './policyCatalog.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { AuthType } from '../core/contentGenerator.js';
|
||||
import { ModelConfigService } from '../services/modelConfigService.js';
|
||||
import { DEFAULT_MODEL_CONFIGS } from '../config/defaultModelConfigs.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
|
||||
const createMockConfig = (overrides: Partial<Config> = {}): Config => {
|
||||
const config = {
|
||||
@@ -164,6 +165,18 @@ describe('policyHelpers', () => {
|
||||
expect(chain[0]?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL);
|
||||
expect(chain[1]?.model).toBe('gemini-3-flash-preview');
|
||||
});
|
||||
|
||||
it('applies SILENT_ACTIONS when ApprovalMode is PLAN', () => {
|
||||
const config = createMockConfig({
|
||||
getApprovalMode: () => ApprovalMode.PLAN,
|
||||
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
|
||||
});
|
||||
const chain = resolvePolicyChain(config);
|
||||
|
||||
expect(chain).toHaveLength(2);
|
||||
expect(chain[0]?.actions).toEqual(SILENT_ACTIONS);
|
||||
expect(chain[1]?.actions).toEqual(SILENT_ACTIONS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePolicyChain behavior is identical between dynamic and legacy implementations', () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
createSingleModelChain,
|
||||
getModelPolicyChain,
|
||||
getFlashLitePolicyChain,
|
||||
SILENT_ACTIONS,
|
||||
} from './policyCatalog.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
} from '../config/models.js';
|
||||
import type { ModelSelectionResult } from './modelAvailabilityService.js';
|
||||
import type { ModelConfigKey } from '../services/modelConfigService.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
|
||||
/**
|
||||
* Resolves the active policy chain for the given config, ensuring the
|
||||
@@ -43,7 +45,7 @@ export function resolvePolicyChain(
|
||||
preferredModel ?? config.getActiveModel?.() ?? config.getModel();
|
||||
const configuredModel = config.getModel();
|
||||
|
||||
let chain;
|
||||
let chain: ModelPolicyChain | undefined;
|
||||
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
|
||||
const useGemini31FlashLite =
|
||||
config.getGemini31FlashLiteLaunchedSync?.() ?? false;
|
||||
@@ -103,45 +105,55 @@ export function resolvePolicyChain(
|
||||
// No matching modelChains found, default to single model chain
|
||||
chain = createSingleModelChain(modelFromConfig);
|
||||
}
|
||||
return applyDynamicSlicing(chain, resolvedModel, wrapsAround);
|
||||
}
|
||||
|
||||
// --- LEGACY PATH ---
|
||||
|
||||
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
|
||||
chain = getFlashLitePolicyChain();
|
||||
} else if (
|
||||
isGemini3Model(resolvedModel, config) ||
|
||||
isAutoPreferred ||
|
||||
isAutoConfigured
|
||||
) {
|
||||
if (hasAccessToPreview) {
|
||||
const previewEnabled =
|
||||
isGemini3Model(resolvedModel, config) ||
|
||||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
|
||||
chain = getModelPolicyChain({
|
||||
previewEnabled,
|
||||
userTier: config.getUserTier(),
|
||||
useGemini31,
|
||||
useGemini31FlashLite,
|
||||
useCustomToolModel,
|
||||
});
|
||||
} else {
|
||||
// User requested Gemini 3 but has no access. Proactively downgrade
|
||||
// to the stable Gemini 2.5 chain.
|
||||
chain = getModelPolicyChain({
|
||||
previewEnabled: false,
|
||||
userTier: config.getUserTier(),
|
||||
useGemini31,
|
||||
useGemini31FlashLite,
|
||||
useCustomToolModel,
|
||||
});
|
||||
}
|
||||
chain = applyDynamicSlicing(chain, resolvedModel, wrapsAround);
|
||||
} else {
|
||||
chain = createSingleModelChain(modelFromConfig);
|
||||
// --- LEGACY PATH ---
|
||||
|
||||
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
|
||||
chain = getFlashLitePolicyChain();
|
||||
} else if (
|
||||
isGemini3Model(resolvedModel, config) ||
|
||||
isAutoPreferred ||
|
||||
isAutoConfigured
|
||||
) {
|
||||
if (hasAccessToPreview) {
|
||||
const previewEnabled =
|
||||
isGemini3Model(resolvedModel, config) ||
|
||||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
|
||||
chain = getModelPolicyChain({
|
||||
previewEnabled,
|
||||
userTier: config.getUserTier(),
|
||||
useGemini31,
|
||||
useGemini31FlashLite,
|
||||
useCustomToolModel,
|
||||
});
|
||||
} else {
|
||||
// User requested Gemini 3 but has no access. Proactively downgrade
|
||||
// to the stable Gemini 2.5 chain.
|
||||
chain = getModelPolicyChain({
|
||||
previewEnabled: false,
|
||||
userTier: config.getUserTier(),
|
||||
useGemini31,
|
||||
useGemini31FlashLite,
|
||||
useCustomToolModel,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
chain = createSingleModelChain(modelFromConfig);
|
||||
}
|
||||
chain = applyDynamicSlicing(chain, resolvedModel, wrapsAround);
|
||||
}
|
||||
return applyDynamicSlicing(chain, resolvedModel, wrapsAround);
|
||||
|
||||
// Apply Unified Silent Injection for Plan Mode with defensive checks
|
||||
if (config?.getApprovalMode?.() === ApprovalMode.PLAN) {
|
||||
return chain.map((policy) => ({
|
||||
...policy,
|
||||
actions: { ...SILENT_ACTIONS },
|
||||
}));
|
||||
}
|
||||
|
||||
return chain;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
addMemory,
|
||||
dismissInboxSkill,
|
||||
listInboxSkills,
|
||||
listInboxPatches,
|
||||
applyInboxPatch,
|
||||
dismissInboxPatch,
|
||||
listMemoryFiles,
|
||||
moveInboxSkill,
|
||||
refreshMemory,
|
||||
@@ -528,4 +531,709 @@ describe('memory commands', () => {
|
||||
expect(result.message).toBe('Invalid skill name.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listInboxPatches', () => {
|
||||
let tmpDir: string;
|
||||
let skillsDir: string;
|
||||
let memoryTempDir: string;
|
||||
let patchConfig: Config;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'patch-list-test-'));
|
||||
skillsDir = path.join(tmpDir, 'skills-memory');
|
||||
memoryTempDir = path.join(tmpDir, 'memory-temp');
|
||||
await fs.mkdir(skillsDir, { recursive: true });
|
||||
await fs.mkdir(memoryTempDir, { recursive: true });
|
||||
|
||||
patchConfig = {
|
||||
storage: {
|
||||
getProjectSkillsMemoryDir: () => skillsDir,
|
||||
getProjectMemoryTempDir: () => memoryTempDir,
|
||||
},
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should return empty array when no patches exist', async () => {
|
||||
const result = await listInboxPatches(patchConfig);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when directory does not exist', async () => {
|
||||
const badConfig = {
|
||||
storage: {
|
||||
getProjectSkillsMemoryDir: () => path.join(tmpDir, 'nonexistent-dir'),
|
||||
getProjectMemoryTempDir: () => memoryTempDir,
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
const result = await listInboxPatches(badConfig);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return parsed patch entries', async () => {
|
||||
const targetFile = path.join(tmpDir, 'target.md');
|
||||
const patchContent = [
|
||||
`--- ${targetFile}`,
|
||||
`+++ ${targetFile}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' line1',
|
||||
' line2',
|
||||
'+line2.5',
|
||||
' line3',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(skillsDir, 'update-skill.patch'),
|
||||
patchContent,
|
||||
);
|
||||
|
||||
const result = await listInboxPatches(patchConfig);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].fileName).toBe('update-skill.patch');
|
||||
expect(result[0].name).toBe('update-skill');
|
||||
expect(result[0].entries).toHaveLength(1);
|
||||
expect(result[0].entries[0].targetPath).toBe(targetFile);
|
||||
expect(result[0].entries[0].diffContent).toContain('+line2.5');
|
||||
});
|
||||
|
||||
it('should use each patch file mtime for extractedAt', async () => {
|
||||
const firstTarget = path.join(tmpDir, 'first.md');
|
||||
const secondTarget = path.join(tmpDir, 'second.md');
|
||||
const firstTimestamp = new Date('2025-01-15T10:00:00.000Z');
|
||||
const secondTimestamp = new Date('2025-01-16T12:00:00.000Z');
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(memoryTempDir, '.extraction-state.json'),
|
||||
JSON.stringify({
|
||||
runs: [
|
||||
{
|
||||
runAt: '2025-02-01T00:00:00Z',
|
||||
sessionIds: ['later-run'],
|
||||
skillsCreated: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(skillsDir, 'first.patch'),
|
||||
[
|
||||
`--- ${firstTarget}`,
|
||||
`+++ ${firstTarget}`,
|
||||
'@@ -1,1 +1,1 @@',
|
||||
'-before',
|
||||
'+after',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(skillsDir, 'second.patch'),
|
||||
[
|
||||
`--- ${secondTarget}`,
|
||||
`+++ ${secondTarget}`,
|
||||
'@@ -1,1 +1,1 @@',
|
||||
'-before',
|
||||
'+after',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
await fs.utimes(
|
||||
path.join(skillsDir, 'first.patch'),
|
||||
firstTimestamp,
|
||||
firstTimestamp,
|
||||
);
|
||||
await fs.utimes(
|
||||
path.join(skillsDir, 'second.patch'),
|
||||
secondTimestamp,
|
||||
secondTimestamp,
|
||||
);
|
||||
|
||||
const result = await listInboxPatches(patchConfig);
|
||||
const firstPatch = result.find(
|
||||
(patch) => patch.fileName === 'first.patch',
|
||||
);
|
||||
const secondPatch = result.find(
|
||||
(patch) => patch.fileName === 'second.patch',
|
||||
);
|
||||
|
||||
expect(firstPatch?.extractedAt).toBe(firstTimestamp.toISOString());
|
||||
expect(secondPatch?.extractedAt).toBe(secondTimestamp.toISOString());
|
||||
});
|
||||
|
||||
it('should skip patches with no hunks', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(skillsDir, 'empty.patch'),
|
||||
'not a valid patch',
|
||||
);
|
||||
|
||||
const result = await listInboxPatches(patchConfig);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyInboxPatch', () => {
|
||||
let tmpDir: string;
|
||||
let skillsDir: string;
|
||||
let memoryTempDir: string;
|
||||
let globalSkillsDir: string;
|
||||
let projectSkillsDir: string;
|
||||
let applyConfig: Config;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'patch-apply-test-'));
|
||||
skillsDir = path.join(tmpDir, 'skills-memory');
|
||||
memoryTempDir = path.join(tmpDir, 'memory-temp');
|
||||
globalSkillsDir = path.join(tmpDir, 'global-skills');
|
||||
projectSkillsDir = path.join(tmpDir, 'project-skills');
|
||||
await fs.mkdir(skillsDir, { recursive: true });
|
||||
await fs.mkdir(memoryTempDir, { recursive: true });
|
||||
await fs.mkdir(globalSkillsDir, { recursive: true });
|
||||
await fs.mkdir(projectSkillsDir, { recursive: true });
|
||||
|
||||
applyConfig = {
|
||||
storage: {
|
||||
getProjectSkillsMemoryDir: () => skillsDir,
|
||||
getProjectMemoryTempDir: () => memoryTempDir,
|
||||
getProjectSkillsDir: () => projectSkillsDir,
|
||||
},
|
||||
isTrustedFolder: () => true,
|
||||
} as unknown as Config;
|
||||
|
||||
vi.mocked(Storage.getUserSkillsDir).mockReturnValue(globalSkillsDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should apply a valid patch and delete it', async () => {
|
||||
const targetFile = path.join(projectSkillsDir, 'target.md');
|
||||
await fs.writeFile(targetFile, 'line1\nline2\nline3\n');
|
||||
|
||||
const patchContent = [
|
||||
`--- ${targetFile}`,
|
||||
`+++ ${targetFile}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' line1',
|
||||
' line2',
|
||||
'+line2.5',
|
||||
' line3',
|
||||
'',
|
||||
].join('\n');
|
||||
const patchPath = path.join(skillsDir, 'good.patch');
|
||||
await fs.writeFile(patchPath, patchContent);
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, 'good.patch');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Applied patch to 1 file');
|
||||
|
||||
// Verify target was modified
|
||||
const modified = await fs.readFile(targetFile, 'utf-8');
|
||||
expect(modified).toContain('line2.5');
|
||||
|
||||
// Verify patch was deleted
|
||||
await expect(fs.access(patchPath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should apply a multi-file patch', async () => {
|
||||
const file1 = path.join(globalSkillsDir, 'file1.md');
|
||||
const file2 = path.join(projectSkillsDir, 'file2.md');
|
||||
await fs.writeFile(file1, 'aaa\nbbb\nccc\n');
|
||||
await fs.writeFile(file2, 'xxx\nyyy\nzzz\n');
|
||||
|
||||
const patchContent = [
|
||||
`--- ${file1}`,
|
||||
`+++ ${file1}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' aaa',
|
||||
' bbb',
|
||||
'+bbb2',
|
||||
' ccc',
|
||||
`--- ${file2}`,
|
||||
`+++ ${file2}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' xxx',
|
||||
' yyy',
|
||||
'+yyy2',
|
||||
' zzz',
|
||||
'',
|
||||
].join('\n');
|
||||
await fs.writeFile(path.join(skillsDir, 'multi.patch'), patchContent);
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, 'multi.patch');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('2 files');
|
||||
|
||||
expect(await fs.readFile(file1, 'utf-8')).toContain('bbb2');
|
||||
expect(await fs.readFile(file2, 'utf-8')).toContain('yyy2');
|
||||
});
|
||||
|
||||
it('should apply repeated file blocks against the cumulative patched content', async () => {
|
||||
const targetFile = path.join(projectSkillsDir, 'target.md');
|
||||
await fs.writeFile(targetFile, 'alpha\nbeta\ngamma\ndelta\n');
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(skillsDir, 'multi-section.patch'),
|
||||
[
|
||||
`--- ${targetFile}`,
|
||||
`+++ ${targetFile}`,
|
||||
'@@ -1,4 +1,5 @@',
|
||||
' alpha',
|
||||
' beta',
|
||||
'+beta2',
|
||||
' gamma',
|
||||
' delta',
|
||||
`--- ${targetFile}`,
|
||||
`+++ ${targetFile}`,
|
||||
'@@ -2,4 +2,5 @@',
|
||||
' beta',
|
||||
' beta2',
|
||||
' gamma',
|
||||
'+gamma2',
|
||||
' delta',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, 'multi-section.patch');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Applied patch to 1 file');
|
||||
expect(await fs.readFile(targetFile, 'utf-8')).toBe(
|
||||
'alpha\nbeta\nbeta2\ngamma\ngamma2\ndelta\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject /dev/null patches that target an existing skill file', async () => {
|
||||
const targetFile = path.join(projectSkillsDir, 'existing-skill.md');
|
||||
await fs.writeFile(targetFile, 'original content\n');
|
||||
|
||||
const patchPath = path.join(skillsDir, 'bad-new-file.patch');
|
||||
await fs.writeFile(
|
||||
patchPath,
|
||||
[
|
||||
'--- /dev/null',
|
||||
`+++ ${targetFile}`,
|
||||
'@@ -0,0 +1 @@',
|
||||
'+replacement content',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, 'bad-new-file.patch');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('target already exists');
|
||||
expect(await fs.readFile(targetFile, 'utf-8')).toBe('original content\n');
|
||||
await expect(fs.access(patchPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should fail when patch does not exist', async () => {
|
||||
const result = await applyInboxPatch(applyConfig, 'missing.patch');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('not found');
|
||||
});
|
||||
|
||||
it('should reject invalid patch file names', async () => {
|
||||
const outsidePatch = path.join(tmpDir, 'outside.patch');
|
||||
await fs.writeFile(outsidePatch, 'outside patch content');
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, '../outside.patch');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('Invalid patch file name.');
|
||||
await expect(fs.access(outsidePatch)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should fail when target file does not exist', async () => {
|
||||
const missingFile = path.join(projectSkillsDir, 'missing-target.md');
|
||||
const patchContent = [
|
||||
`--- ${missingFile}`,
|
||||
`+++ ${missingFile}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' a',
|
||||
' b',
|
||||
'+c',
|
||||
' d',
|
||||
'',
|
||||
].join('\n');
|
||||
await fs.writeFile(
|
||||
path.join(skillsDir, 'bad-target.patch'),
|
||||
patchContent,
|
||||
);
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, 'bad-target.patch');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Target file not found');
|
||||
});
|
||||
|
||||
it('should reject targets outside the global and workspace skill roots', async () => {
|
||||
const outsideFile = path.join(tmpDir, 'outside.md');
|
||||
await fs.writeFile(outsideFile, 'line1\nline2\nline3\n');
|
||||
|
||||
const patchContent = [
|
||||
`--- ${outsideFile}`,
|
||||
`+++ ${outsideFile}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' line1',
|
||||
' line2',
|
||||
'+line2.5',
|
||||
' line3',
|
||||
'',
|
||||
].join('\n');
|
||||
const patchPath = path.join(skillsDir, 'outside.patch');
|
||||
await fs.writeFile(patchPath, patchContent);
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, 'outside.patch');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain(
|
||||
'outside the global/workspace skill directories',
|
||||
);
|
||||
expect(await fs.readFile(outsideFile, 'utf-8')).not.toContain('line2.5');
|
||||
await expect(fs.access(patchPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject targets that escape the skill root through a symlinked parent', async () => {
|
||||
const outsideDir = path.join(tmpDir, 'outside-dir');
|
||||
const linkDir = path.join(projectSkillsDir, 'linked');
|
||||
await fs.mkdir(outsideDir, { recursive: true });
|
||||
await fs.symlink(
|
||||
outsideDir,
|
||||
linkDir,
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
|
||||
const outsideFile = path.join(outsideDir, 'escaped.md');
|
||||
await fs.writeFile(outsideFile, 'line1\nline2\nline3\n');
|
||||
|
||||
const patchPath = path.join(skillsDir, 'symlink.patch');
|
||||
await fs.writeFile(
|
||||
patchPath,
|
||||
[
|
||||
`--- ${path.join(linkDir, 'escaped.md')}`,
|
||||
`+++ ${path.join(linkDir, 'escaped.md')}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' line1',
|
||||
' line2',
|
||||
'+line2.5',
|
||||
' line3',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, 'symlink.patch');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain(
|
||||
'outside the global/workspace skill directories',
|
||||
);
|
||||
expect(await fs.readFile(outsideFile, 'utf-8')).not.toContain('line2.5');
|
||||
await expect(fs.access(patchPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject patches that contain no hunks', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(skillsDir, 'empty.patch'),
|
||||
[
|
||||
`--- ${path.join(projectSkillsDir, 'target.md')}`,
|
||||
`+++ ${path.join(projectSkillsDir, 'target.md')}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, 'empty.patch');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('contains no valid hunks');
|
||||
});
|
||||
|
||||
it('should reject project-scope patches when the workspace is untrusted', async () => {
|
||||
const targetFile = path.join(projectSkillsDir, 'target.md');
|
||||
await fs.writeFile(targetFile, 'line1\nline2\nline3\n');
|
||||
|
||||
const patchPath = path.join(skillsDir, 'workspace.patch');
|
||||
await fs.writeFile(
|
||||
patchPath,
|
||||
[
|
||||
`--- ${targetFile}`,
|
||||
`+++ ${targetFile}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' line1',
|
||||
' line2',
|
||||
'+line2.5',
|
||||
' line3',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const untrustedConfig = {
|
||||
storage: applyConfig.storage,
|
||||
isTrustedFolder: () => false,
|
||||
} as Config;
|
||||
const result = await applyInboxPatch(untrustedConfig, 'workspace.patch');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain(
|
||||
'Project skill patches are unavailable until this workspace is trusted.',
|
||||
);
|
||||
expect(await fs.readFile(targetFile, 'utf-8')).toBe(
|
||||
'line1\nline2\nline3\n',
|
||||
);
|
||||
await expect(fs.access(patchPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject project-scope patches through a symlinked project skills root when the workspace is untrusted', async () => {
|
||||
const realProjectSkillsDir = path.join(tmpDir, 'project-skills-real');
|
||||
const symlinkedProjectSkillsDir = path.join(
|
||||
tmpDir,
|
||||
'project-skills-link',
|
||||
);
|
||||
await fs.mkdir(realProjectSkillsDir, { recursive: true });
|
||||
await fs.symlink(
|
||||
realProjectSkillsDir,
|
||||
symlinkedProjectSkillsDir,
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
projectSkillsDir = symlinkedProjectSkillsDir;
|
||||
|
||||
const targetFile = path.join(realProjectSkillsDir, 'target.md');
|
||||
await fs.writeFile(targetFile, 'line1\nline2\nline3\n');
|
||||
|
||||
const patchPath = path.join(skillsDir, 'workspace-symlink.patch');
|
||||
await fs.writeFile(
|
||||
patchPath,
|
||||
[
|
||||
`--- ${targetFile}`,
|
||||
`+++ ${targetFile}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' line1',
|
||||
' line2',
|
||||
'+line2.5',
|
||||
' line3',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const untrustedConfig = {
|
||||
storage: applyConfig.storage,
|
||||
isTrustedFolder: () => false,
|
||||
} as Config;
|
||||
const result = await applyInboxPatch(
|
||||
untrustedConfig,
|
||||
'workspace-symlink.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain(
|
||||
'Project skill patches are unavailable until this workspace is trusted.',
|
||||
);
|
||||
expect(await fs.readFile(targetFile, 'utf-8')).toBe(
|
||||
'line1\nline2\nline3\n',
|
||||
);
|
||||
await expect(fs.access(patchPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject patches with mismatched diff headers', async () => {
|
||||
const sourceFile = path.join(projectSkillsDir, 'source.md');
|
||||
const targetFile = path.join(projectSkillsDir, 'target.md');
|
||||
await fs.writeFile(sourceFile, 'aaa\nbbb\nccc\n');
|
||||
await fs.writeFile(targetFile, 'xxx\nyyy\nzzz\n');
|
||||
|
||||
const patchPath = path.join(skillsDir, 'mismatched-headers.patch');
|
||||
await fs.writeFile(
|
||||
patchPath,
|
||||
[
|
||||
`--- ${sourceFile}`,
|
||||
`+++ ${targetFile}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' xxx',
|
||||
' yyy',
|
||||
'+yyy2',
|
||||
' zzz',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxPatch(
|
||||
applyConfig,
|
||||
'mismatched-headers.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('invalid diff headers');
|
||||
expect(await fs.readFile(sourceFile, 'utf-8')).toBe('aaa\nbbb\nccc\n');
|
||||
expect(await fs.readFile(targetFile, 'utf-8')).toBe('xxx\nyyy\nzzz\n');
|
||||
await expect(fs.access(patchPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should strip git-style a/ and b/ prefixes and apply successfully', async () => {
|
||||
const targetFile = path.join(projectSkillsDir, 'prefixed.md');
|
||||
await fs.writeFile(targetFile, 'line1\nline2\nline3\n');
|
||||
|
||||
const patchPath = path.join(skillsDir, 'git-prefix.patch');
|
||||
await fs.writeFile(
|
||||
patchPath,
|
||||
[
|
||||
`--- a/${targetFile}`,
|
||||
`+++ b/${targetFile}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' line1',
|
||||
' line2',
|
||||
'+line2.5',
|
||||
' line3',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, 'git-prefix.patch');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Applied patch to 1 file');
|
||||
expect(await fs.readFile(targetFile, 'utf-8')).toBe(
|
||||
'line1\nline2\nline2.5\nline3\n',
|
||||
);
|
||||
await expect(fs.access(patchPath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should not write any files if one patch in a multi-file set fails', async () => {
|
||||
const file1 = path.join(projectSkillsDir, 'file1.md');
|
||||
await fs.writeFile(file1, 'aaa\nbbb\nccc\n');
|
||||
const missingFile = path.join(projectSkillsDir, 'missing.md');
|
||||
|
||||
const patchContent = [
|
||||
`--- ${file1}`,
|
||||
`+++ ${file1}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' aaa',
|
||||
' bbb',
|
||||
'+bbb2',
|
||||
' ccc',
|
||||
`--- ${missingFile}`,
|
||||
`+++ ${missingFile}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' x',
|
||||
' y',
|
||||
'+z',
|
||||
' w',
|
||||
'',
|
||||
].join('\n');
|
||||
await fs.writeFile(path.join(skillsDir, 'partial.patch'), patchContent);
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, 'partial.patch');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
// Verify file1 was NOT modified (dry-run failed)
|
||||
const content = await fs.readFile(file1, 'utf-8');
|
||||
expect(content).not.toContain('bbb2');
|
||||
});
|
||||
|
||||
it('should roll back earlier file updates if a later commit step fails', async () => {
|
||||
const file1 = path.join(projectSkillsDir, 'file1.md');
|
||||
await fs.writeFile(file1, 'aaa\nbbb\nccc\n');
|
||||
|
||||
const conflictPath = path.join(projectSkillsDir, 'conflict');
|
||||
const nestedNewFile = path.join(conflictPath, 'nested.md');
|
||||
|
||||
const patchPath = path.join(skillsDir, 'rollback.patch');
|
||||
await fs.writeFile(
|
||||
patchPath,
|
||||
[
|
||||
`--- ${file1}`,
|
||||
`+++ ${file1}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' aaa',
|
||||
' bbb',
|
||||
'+bbb2',
|
||||
' ccc',
|
||||
'--- /dev/null',
|
||||
`+++ ${conflictPath}`,
|
||||
'@@ -0,0 +1 @@',
|
||||
'+new file content',
|
||||
'--- /dev/null',
|
||||
`+++ ${nestedNewFile}`,
|
||||
'@@ -0,0 +1 @@',
|
||||
'+nested new file content',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, 'rollback.patch');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('could not be applied atomically');
|
||||
expect(await fs.readFile(file1, 'utf-8')).toBe('aaa\nbbb\nccc\n');
|
||||
expect((await fs.stat(conflictPath)).isDirectory()).toBe(true);
|
||||
await expect(fs.access(nestedNewFile)).rejects.toThrow();
|
||||
await expect(fs.access(patchPath)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dismissInboxPatch', () => {
|
||||
let tmpDir: string;
|
||||
let skillsDir: string;
|
||||
let dismissPatchConfig: Config;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'patch-dismiss-test-'));
|
||||
skillsDir = path.join(tmpDir, 'skills-memory');
|
||||
await fs.mkdir(skillsDir, { recursive: true });
|
||||
|
||||
dismissPatchConfig = {
|
||||
storage: {
|
||||
getProjectSkillsMemoryDir: () => skillsDir,
|
||||
},
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should delete the patch file and return success', async () => {
|
||||
const patchPath = path.join(skillsDir, 'old.patch');
|
||||
await fs.writeFile(patchPath, 'some patch content');
|
||||
|
||||
const result = await dismissInboxPatch(dismissPatchConfig, 'old.patch');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Dismissed');
|
||||
await expect(fs.access(patchPath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should return error when patch does not exist', async () => {
|
||||
const result = await dismissInboxPatch(
|
||||
dismissPatchConfig,
|
||||
'nonexistent.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('not found');
|
||||
});
|
||||
|
||||
it('should reject invalid patch file names', async () => {
|
||||
const outsidePatch = path.join(tmpDir, 'outside.patch');
|
||||
await fs.writeFile(outsidePatch, 'outside patch content');
|
||||
|
||||
const result = await dismissInboxPatch(
|
||||
dismissPatchConfig,
|
||||
'../outside.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('Invalid patch file name.');
|
||||
await expect(fs.access(outsidePatch)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,12 +4,22 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as Diff from 'diff';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { flattenMemory } from '../config/memory.js';
|
||||
import { loadSkillFromFile, loadSkillsFromDir } from '../skills/skillLoader.js';
|
||||
import {
|
||||
type AppliedSkillPatchTarget,
|
||||
applyParsedSkillPatches,
|
||||
hasParsedPatchHunks,
|
||||
isProjectSkillPatchTarget,
|
||||
validateParsedSkillPatchHeaders,
|
||||
} from '../services/memoryPatchUtils.js';
|
||||
import { readExtractionState } from '../services/memoryService.js';
|
||||
import { refreshServerHierarchicalMemory } from '../utils/memoryDiscovery.js';
|
||||
import type { MessageActionReturn, ToolActionReturn } from './types.js';
|
||||
@@ -111,6 +121,8 @@ export interface InboxSkill {
|
||||
name: string;
|
||||
/** Skill description from SKILL.md frontmatter. */
|
||||
description: string;
|
||||
/** Raw SKILL.md content for preview. */
|
||||
content: string;
|
||||
/** When the skill was extracted (ISO string), if known. */
|
||||
extractedAt?: string;
|
||||
}
|
||||
@@ -153,10 +165,18 @@ export async function listInboxSkills(config: Config): Promise<InboxSkill[]> {
|
||||
const skillDef = await loadSkillFromFile(skillPath);
|
||||
if (!skillDef) continue;
|
||||
|
||||
let content = '';
|
||||
try {
|
||||
content = await fs.readFile(skillPath, 'utf-8');
|
||||
} catch {
|
||||
// Best-effort — preview will be empty
|
||||
}
|
||||
|
||||
skills.push({
|
||||
dirName: dir.name,
|
||||
name: skillDef.name,
|
||||
description: skillDef.description,
|
||||
content,
|
||||
extractedAt: skillDateMap.get(dir.name),
|
||||
});
|
||||
}
|
||||
@@ -176,6 +196,16 @@ function isValidInboxSkillDirName(dirName: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isValidInboxPatchFileName(fileName: string): boolean {
|
||||
return (
|
||||
fileName.length > 0 &&
|
||||
fileName !== '.' &&
|
||||
fileName !== '..' &&
|
||||
!fileName.includes('/') &&
|
||||
!fileName.includes('\\')
|
||||
);
|
||||
}
|
||||
|
||||
async function getSkillNameForConflictCheck(
|
||||
skillDir: string,
|
||||
fallbackName: string,
|
||||
@@ -283,3 +313,448 @@ export async function dismissInboxSkill(
|
||||
message: `Dismissed "${dirName}" from inbox.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A parsed patch entry from a unified diff, representing changes to a single file.
|
||||
*/
|
||||
export interface InboxPatchEntry {
|
||||
/** Absolute path to the target file (or '/dev/null' for new files). */
|
||||
targetPath: string;
|
||||
/** The unified diff text for this single file. */
|
||||
diffContent: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a .patch file found in the extraction inbox.
|
||||
*/
|
||||
export interface InboxPatch {
|
||||
/** The .patch filename (e.g. "update-docs-writer.patch"). */
|
||||
fileName: string;
|
||||
/** Display name (filename without .patch extension). */
|
||||
name: string;
|
||||
/** Per-file entries parsed from the patch. */
|
||||
entries: InboxPatchEntry[];
|
||||
/** When the patch was extracted (ISO string), if known. */
|
||||
extractedAt?: string;
|
||||
}
|
||||
|
||||
interface StagedInboxPatchTarget {
|
||||
targetPath: string;
|
||||
tempPath: string;
|
||||
original: string;
|
||||
isNewFile: boolean;
|
||||
mode?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstructs a unified diff string for a single ParsedDiff entry.
|
||||
*/
|
||||
function formatParsedDiff(parsed: Diff.StructuredPatch): string {
|
||||
const lines: string[] = [];
|
||||
if (parsed.oldFileName) {
|
||||
lines.push(`--- ${parsed.oldFileName}`);
|
||||
}
|
||||
if (parsed.newFileName) {
|
||||
lines.push(`+++ ${parsed.newFileName}`);
|
||||
}
|
||||
for (const hunk of parsed.hunks) {
|
||||
lines.push(
|
||||
`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,
|
||||
);
|
||||
for (const line of hunk.lines) {
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function patchTargetsProjectSkills(
|
||||
targetPaths: string[],
|
||||
config: Config,
|
||||
) {
|
||||
for (const targetPath of targetPaths) {
|
||||
if (await isProjectSkillPatchTarget(targetPath, config)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function getPatchExtractedAt(
|
||||
patchPath: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const stats = await fs.stat(patchPath);
|
||||
return stats.mtime.toISOString();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function findNearestExistingDirectory(
|
||||
startPath: string,
|
||||
): Promise<string> {
|
||||
let currentPath = path.resolve(startPath);
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const stats = await fs.stat(currentPath);
|
||||
if (stats.isDirectory()) {
|
||||
return currentPath;
|
||||
}
|
||||
} catch {
|
||||
// Keep walking upward until we find an existing directory.
|
||||
}
|
||||
|
||||
const parentPath = path.dirname(currentPath);
|
||||
if (parentPath === currentPath) {
|
||||
return currentPath;
|
||||
}
|
||||
currentPath = parentPath;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeExclusiveFile(
|
||||
filePath: string,
|
||||
content: string,
|
||||
mode?: number,
|
||||
): Promise<void> {
|
||||
const handle = await fs.open(filePath, 'wx');
|
||||
try {
|
||||
await handle.writeFile(content, 'utf-8');
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
if (mode !== undefined) {
|
||||
await fs.chmod(filePath, mode);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupStagedInboxPatchTargets(
|
||||
stagedTargets: StagedInboxPatchTarget[],
|
||||
): Promise<void> {
|
||||
await Promise.allSettled(
|
||||
stagedTargets.map(async ({ tempPath }) => {
|
||||
try {
|
||||
await fs.unlink(tempPath);
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function restoreCommittedInboxPatchTarget(
|
||||
stagedTarget: StagedInboxPatchTarget,
|
||||
): Promise<void> {
|
||||
if (stagedTarget.isNewFile) {
|
||||
try {
|
||||
await fs.unlink(stagedTarget.targetPath);
|
||||
} catch {
|
||||
// Best-effort rollback.
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const restoreDir = await findNearestExistingDirectory(
|
||||
path.dirname(stagedTarget.targetPath),
|
||||
);
|
||||
const restorePath = path.join(
|
||||
restoreDir,
|
||||
`.${path.basename(stagedTarget.targetPath)}.${randomUUID()}.rollback`,
|
||||
);
|
||||
|
||||
await writeExclusiveFile(
|
||||
restorePath,
|
||||
stagedTarget.original,
|
||||
stagedTarget.mode,
|
||||
);
|
||||
await fs.rename(restorePath, stagedTarget.targetPath);
|
||||
}
|
||||
|
||||
async function stageInboxPatchTargets(
|
||||
targets: AppliedSkillPatchTarget[],
|
||||
): Promise<StagedInboxPatchTarget[]> {
|
||||
const stagedTargets: StagedInboxPatchTarget[] = [];
|
||||
|
||||
try {
|
||||
for (const target of targets) {
|
||||
let mode: number | undefined;
|
||||
if (!target.isNewFile) {
|
||||
await fs.access(target.targetPath, fsConstants.W_OK);
|
||||
mode = (await fs.stat(target.targetPath)).mode;
|
||||
}
|
||||
|
||||
const tempDir = await findNearestExistingDirectory(
|
||||
path.dirname(target.targetPath),
|
||||
);
|
||||
const tempPath = path.join(
|
||||
tempDir,
|
||||
`.${path.basename(target.targetPath)}.${randomUUID()}.patch-tmp`,
|
||||
);
|
||||
|
||||
await writeExclusiveFile(tempPath, target.patched, mode);
|
||||
stagedTargets.push({
|
||||
targetPath: target.targetPath,
|
||||
tempPath,
|
||||
original: target.original,
|
||||
isNewFile: target.isNewFile,
|
||||
mode,
|
||||
});
|
||||
}
|
||||
|
||||
for (const target of stagedTargets) {
|
||||
if (!target.isNewFile) {
|
||||
continue;
|
||||
}
|
||||
await fs.mkdir(path.dirname(target.targetPath), { recursive: true });
|
||||
}
|
||||
|
||||
return stagedTargets;
|
||||
} catch (error) {
|
||||
await cleanupStagedInboxPatchTargets(stagedTargets);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the skill extraction inbox for .patch files and returns
|
||||
* structured data for each valid patch.
|
||||
*/
|
||||
export async function listInboxPatches(config: Config): Promise<InboxPatch[]> {
|
||||
const skillsDir = config.storage.getProjectSkillsMemoryDir();
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(skillsDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const patchFiles = entries.filter((e) => e.endsWith('.patch'));
|
||||
if (patchFiles.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const patches: InboxPatch[] = [];
|
||||
for (const patchFile of patchFiles) {
|
||||
const patchPath = path.join(skillsDir, patchFile);
|
||||
try {
|
||||
const content = await fs.readFile(patchPath, 'utf-8');
|
||||
const parsed = Diff.parsePatch(content);
|
||||
if (!hasParsedPatchHunks(parsed)) continue;
|
||||
|
||||
const patchEntries: InboxPatchEntry[] = parsed.map((p) => ({
|
||||
targetPath: p.newFileName ?? p.oldFileName ?? '',
|
||||
diffContent: formatParsedDiff(p),
|
||||
}));
|
||||
|
||||
patches.push({
|
||||
fileName: patchFile,
|
||||
name: patchFile.replace(/\.patch$/, ''),
|
||||
entries: patchEntries,
|
||||
extractedAt: await getPatchExtractedAt(patchPath),
|
||||
});
|
||||
} catch {
|
||||
// Skip unreadable patch files
|
||||
}
|
||||
}
|
||||
|
||||
return patches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a .patch file from the inbox by reading each target file,
|
||||
* applying the diff, and writing the result. Deletes the patch on success.
|
||||
*/
|
||||
export async function applyInboxPatch(
|
||||
config: Config,
|
||||
fileName: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
if (!isValidInboxPatchFileName(fileName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Invalid patch file name.',
|
||||
};
|
||||
}
|
||||
|
||||
const skillsDir = config.storage.getProjectSkillsMemoryDir();
|
||||
const patchPath = path.join(skillsDir, fileName);
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(patchPath, 'utf-8');
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch "${fileName}" not found in inbox.`,
|
||||
};
|
||||
}
|
||||
|
||||
let parsed: Diff.StructuredPatch[];
|
||||
try {
|
||||
parsed = Diff.parsePatch(content);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to parse patch "${fileName}": ${getErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
if (!hasParsedPatchHunks(parsed)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch "${fileName}" contains no valid hunks.`,
|
||||
};
|
||||
}
|
||||
|
||||
const validatedHeaders = validateParsedSkillPatchHeaders(parsed);
|
||||
if (!validatedHeaders.success) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
validatedHeaders.reason === 'missingTargetPath'
|
||||
? `Patch "${fileName}" is missing a target file path.`
|
||||
: `Patch "${fileName}" has invalid diff headers.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
!config.isTrustedFolder() &&
|
||||
(await patchTargetsProjectSkills(
|
||||
validatedHeaders.patches.map((patch) => patch.targetPath),
|
||||
config,
|
||||
))
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
'Project skill patches are unavailable until this workspace is trusted.',
|
||||
};
|
||||
}
|
||||
|
||||
// Dry-run first: verify all patches apply cleanly before writing anything.
|
||||
// Repeated file blocks are validated against the progressively patched content.
|
||||
const applied = await applyParsedSkillPatches(parsed, config);
|
||||
if (!applied.success) {
|
||||
switch (applied.reason) {
|
||||
case 'missingTargetPath':
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch "${fileName}" is missing a target file path.`,
|
||||
};
|
||||
case 'invalidPatchHeaders':
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch "${fileName}" has invalid diff headers.`,
|
||||
};
|
||||
case 'outsideAllowedRoots':
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch "${fileName}" targets a file outside the global/workspace skill directories: ${applied.targetPath}`,
|
||||
};
|
||||
case 'newFileAlreadyExists':
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch "${fileName}" declares a new file, but the target already exists: ${applied.targetPath}`,
|
||||
};
|
||||
case 'targetNotFound':
|
||||
return {
|
||||
success: false,
|
||||
message: `Target file not found: ${applied.targetPath}`,
|
||||
};
|
||||
case 'doesNotApply':
|
||||
return {
|
||||
success: false,
|
||||
message: applied.isNewFile
|
||||
? `Patch "${fileName}" failed to apply for new file ${applied.targetPath}.`
|
||||
: `Patch does not apply cleanly to ${applied.targetPath}.`,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch "${fileName}" could not be applied.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let stagedTargets: StagedInboxPatchTarget[];
|
||||
try {
|
||||
stagedTargets = await stageInboxPatchTargets(applied.results);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch "${fileName}" could not be staged: ${getErrorMessage(error)}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const committedTargets: StagedInboxPatchTarget[] = [];
|
||||
try {
|
||||
for (const stagedTarget of stagedTargets) {
|
||||
await fs.rename(stagedTarget.tempPath, stagedTarget.targetPath);
|
||||
committedTargets.push(stagedTarget);
|
||||
}
|
||||
} catch (error) {
|
||||
for (const committedTarget of committedTargets.reverse()) {
|
||||
try {
|
||||
await restoreCommittedInboxPatchTarget(committedTarget);
|
||||
} catch {
|
||||
// Best-effort rollback. We still report the commit failure below.
|
||||
}
|
||||
}
|
||||
await cleanupStagedInboxPatchTargets(
|
||||
stagedTargets.filter((target) => !committedTargets.includes(target)),
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch "${fileName}" could not be applied atomically: ${getErrorMessage(error)}.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Remove the patch file
|
||||
await fs.unlink(patchPath);
|
||||
|
||||
const fileCount = applied.results.length;
|
||||
return {
|
||||
success: true,
|
||||
message: `Applied patch to ${fileCount} file${fileCount !== 1 ? 's' : ''}.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a .patch file from the extraction inbox.
|
||||
*/
|
||||
export async function dismissInboxPatch(
|
||||
config: Config,
|
||||
fileName: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
if (!isValidInboxPatchFileName(fileName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Invalid patch file name.',
|
||||
};
|
||||
}
|
||||
|
||||
const skillsDir = config.storage.getProjectSkillsMemoryDir();
|
||||
const patchPath = path.join(skillsDir, fileName);
|
||||
|
||||
try {
|
||||
await fs.access(patchPath);
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch "${fileName}" not found in inbox.`,
|
||||
};
|
||||
}
|
||||
|
||||
await fs.unlink(patchPath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Dismissed "${fileName}" from inbox.`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -304,6 +304,53 @@ describe('Server Config (config.ts)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('setShellExecutionConfig', () => {
|
||||
it('should preserve existing shell execution fields that are not being updated', () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
command: 'windows-native',
|
||||
networkAccess: false,
|
||||
},
|
||||
shellBackgroundCompletionBehavior: 'notify',
|
||||
});
|
||||
|
||||
expect(config.getShellExecutionConfig()).toEqual(
|
||||
expect.objectContaining({
|
||||
sandboxConfig: expect.objectContaining({
|
||||
enabled: true,
|
||||
command: 'windows-native',
|
||||
networkAccess: false,
|
||||
}),
|
||||
backgroundCompletionBehavior: 'notify',
|
||||
}),
|
||||
);
|
||||
|
||||
config.setShellExecutionConfig({
|
||||
terminalWidth: 123,
|
||||
terminalHeight: 45,
|
||||
showColor: true,
|
||||
pager: 'cat',
|
||||
sanitizationConfig: config.sanitizationConfig,
|
||||
sandboxManager: config.sandboxManager,
|
||||
});
|
||||
|
||||
expect(config.getShellExecutionConfig()).toEqual(
|
||||
expect.objectContaining({
|
||||
terminalWidth: 123,
|
||||
terminalHeight: 45,
|
||||
sandboxConfig: expect.objectContaining({
|
||||
enabled: true,
|
||||
command: 'windows-native',
|
||||
networkAccess: false,
|
||||
}),
|
||||
backgroundCompletionBehavior: 'notify',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks if necessary
|
||||
vi.clearAllMocks();
|
||||
@@ -2959,6 +3006,78 @@ describe('Config Quota & Preview Model Access', () => {
|
||||
// Never set => stays null (unknown); getter returns true so UI shows preview
|
||||
expect(config.getHasAccessToPreviewModel()).toBe(true);
|
||||
});
|
||||
it('should derive quota from remainingFraction when remainingAmount is missing', async () => {
|
||||
mockCodeAssistServer.retrieveUserQuota.mockResolvedValue({
|
||||
buckets: [
|
||||
{
|
||||
modelId: 'gemini-3-flash-preview',
|
||||
remainingFraction: 0.96,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
config.setModel('gemini-3-flash-preview');
|
||||
mockCoreEvents.emitQuotaChanged.mockClear();
|
||||
await config.refreshUserQuota();
|
||||
|
||||
// Normalized: limit=100, remaining=96
|
||||
expect(mockCoreEvents.emitQuotaChanged).toHaveBeenCalledWith(
|
||||
96,
|
||||
100,
|
||||
undefined,
|
||||
);
|
||||
expect(config.getQuotaRemaining()).toBe(96);
|
||||
expect(config.getQuotaLimit()).toBe(100);
|
||||
});
|
||||
|
||||
it('should store quota from remainingFraction when remainingFraction is 0', async () => {
|
||||
mockCodeAssistServer.retrieveUserQuota.mockResolvedValue({
|
||||
buckets: [
|
||||
{
|
||||
modelId: 'gemini-3-pro-preview',
|
||||
remainingFraction: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
config.setModel('gemini-3-pro-preview');
|
||||
mockCoreEvents.emitQuotaChanged.mockClear();
|
||||
await config.refreshUserQuota();
|
||||
|
||||
// remaining=0, limit=100 but limit>0 check still passes
|
||||
// however remaining=0 means 0% remaining = 100% used
|
||||
expect(config.getQuotaRemaining()).toBe(0);
|
||||
expect(config.getQuotaLimit()).toBe(100);
|
||||
});
|
||||
|
||||
it('should emit QuotaChanged when model is switched via setModel', async () => {
|
||||
mockCodeAssistServer.retrieveUserQuota.mockResolvedValue({
|
||||
buckets: [
|
||||
{
|
||||
modelId: 'gemini-2.5-pro',
|
||||
remainingAmount: '10',
|
||||
remainingFraction: 0.2,
|
||||
},
|
||||
{
|
||||
modelId: 'gemini-2.5-flash',
|
||||
remainingAmount: '80',
|
||||
remainingFraction: 0.8,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
config.setModel('auto-gemini-2.5');
|
||||
await config.refreshUserQuota();
|
||||
mockCoreEvents.emitQuotaChanged.mockClear();
|
||||
|
||||
// Switch to a specific model — should re-emit quota for that model
|
||||
config.setModel('gemini-2.5-pro');
|
||||
expect(mockCoreEvents.emitQuotaChanged).toHaveBeenCalledWith(
|
||||
10,
|
||||
50,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshUserQuotaIfStale', () => {
|
||||
|
||||
@@ -699,6 +699,7 @@ export interface ConfigParameters {
|
||||
experimentalJitContext?: boolean;
|
||||
autoDistillation?: boolean;
|
||||
experimentalMemoryManager?: boolean;
|
||||
experimentalContextManagementConfig?: string;
|
||||
experimentalAgentHistoryTruncation?: boolean;
|
||||
experimentalAgentHistoryTruncationThreshold?: number;
|
||||
experimentalAgentHistoryRetainedMessages?: number;
|
||||
@@ -832,18 +833,16 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private lastEmittedQuotaLimit: number | undefined;
|
||||
|
||||
private emitQuotaChangedEvent(): void {
|
||||
const pooled = this.getPooledQuota();
|
||||
const remaining = this.getQuotaRemaining();
|
||||
const limit = this.getQuotaLimit();
|
||||
const resetTime = this.getQuotaResetTime();
|
||||
if (
|
||||
this.lastEmittedQuotaRemaining !== pooled.remaining ||
|
||||
this.lastEmittedQuotaLimit !== pooled.limit
|
||||
this.lastEmittedQuotaRemaining !== remaining ||
|
||||
this.lastEmittedQuotaLimit !== limit
|
||||
) {
|
||||
this.lastEmittedQuotaRemaining = pooled.remaining;
|
||||
this.lastEmittedQuotaLimit = pooled.limit;
|
||||
coreEvents.emitQuotaChanged(
|
||||
pooled.remaining,
|
||||
pooled.limit,
|
||||
pooled.resetTime,
|
||||
);
|
||||
this.lastEmittedQuotaRemaining = remaining;
|
||||
this.lastEmittedQuotaLimit = limit;
|
||||
coreEvents.emitQuotaChanged(remaining, limit, resetTime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -941,6 +940,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly adminSkillsEnabled: boolean;
|
||||
private readonly experimentalJitContext: boolean;
|
||||
private readonly experimentalMemoryManager: boolean;
|
||||
private readonly experimentalContextManagementConfig?: string;
|
||||
private readonly memoryBoundaryMarkers: readonly string[];
|
||||
private readonly topicUpdateNarration: boolean;
|
||||
private readonly disableLLMCorrection: boolean;
|
||||
@@ -1152,6 +1152,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
this.experimentalJitContext = params.experimentalJitContext ?? false;
|
||||
this.experimentalMemoryManager = params.experimentalMemoryManager ?? false;
|
||||
this.experimentalContextManagementConfig =
|
||||
params.experimentalContextManagementConfig;
|
||||
this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git'];
|
||||
this.contextManagement = {
|
||||
enabled: params.contextManagement?.enabled ?? false,
|
||||
@@ -1819,6 +1821,9 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
// When the user explicitly sets a model, that becomes the active model.
|
||||
this._activeModel = newModel;
|
||||
coreEvents.emitModelChanged(newModel);
|
||||
this.lastEmittedQuotaRemaining = undefined;
|
||||
this.lastEmittedQuotaLimit = undefined;
|
||||
this.emitQuotaChangedEvent();
|
||||
}
|
||||
if (this.onModelChange && !isTemporary) {
|
||||
this.onModelChange(newModel);
|
||||
@@ -2112,24 +2117,31 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.lastQuotaFetchTime = Date.now();
|
||||
|
||||
for (const bucket of quota.buckets) {
|
||||
if (
|
||||
bucket.modelId &&
|
||||
bucket.remainingAmount &&
|
||||
bucket.remainingFraction != null
|
||||
) {
|
||||
const remaining = parseInt(bucket.remainingAmount, 10);
|
||||
const limit =
|
||||
if (!bucket.modelId || bucket.remainingFraction == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let remaining: number;
|
||||
let limit: number;
|
||||
|
||||
if (bucket.remainingAmount) {
|
||||
remaining = parseInt(bucket.remainingAmount, 10);
|
||||
limit =
|
||||
bucket.remainingFraction > 0
|
||||
? Math.round(remaining / bucket.remainingFraction)
|
||||
: (this.modelQuotas.get(bucket.modelId)?.limit ?? 0);
|
||||
} else {
|
||||
// Server only sent remainingFraction — use a normalized scale.
|
||||
limit = 100;
|
||||
remaining = Math.round(bucket.remainingFraction * limit);
|
||||
}
|
||||
|
||||
if (!isNaN(remaining) && Number.isFinite(limit) && limit > 0) {
|
||||
this.modelQuotas.set(bucket.modelId, {
|
||||
remaining,
|
||||
limit,
|
||||
resetTime: bucket.resetTime,
|
||||
});
|
||||
}
|
||||
if (!isNaN(remaining) && Number.isFinite(limit) && limit > 0) {
|
||||
this.modelQuotas.set(bucket.modelId, {
|
||||
remaining,
|
||||
limit,
|
||||
resetTime: bucket.resetTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.emitQuotaChangedEvent();
|
||||
@@ -2426,6 +2438,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.experimentalMemoryManager;
|
||||
}
|
||||
|
||||
getExperimentalContextManagementConfig(): string | undefined {
|
||||
return this.experimentalContextManagementConfig;
|
||||
}
|
||||
|
||||
getContextManagementConfig(): ContextManagementConfig {
|
||||
return this.contextManagement;
|
||||
}
|
||||
@@ -3334,6 +3350,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
setShellExecutionConfig(config: ShellExecutionConfig): void {
|
||||
this.shellExecutionConfig = {
|
||||
...this.shellExecutionConfig,
|
||||
terminalWidth:
|
||||
config.terminalWidth ?? this.shellExecutionConfig.terminalWidth,
|
||||
terminalHeight:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user