mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-01 20:51:00 -07:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e4f07887d9 | |||
| 1454d80f49 | |||
| c6d3849d0c | |||
| 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+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**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -98,6 +98,7 @@ export function createMockConfig(
|
||||
getMcpServers: vi.fn().mockReturnValue({}),
|
||||
}),
|
||||
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
|
||||
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
|
||||
getGitService: vi.fn(),
|
||||
validatePathAccess: vi.fn().mockReturnValue(undefined),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({
|
||||
|
||||
@@ -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')}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2966,6 +2966,11 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
|
||||
description: 'Protocol for OTLP exporters.',
|
||||
enum: ['grpc', 'http'],
|
||||
},
|
||||
traces: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether detailed traces with large attributes are captured.',
|
||||
},
|
||||
logPrompts: {
|
||||
type: 'boolean',
|
||||
description: 'Whether prompts are logged in telemetry payloads.',
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -239,6 +241,7 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
getCoreTools: vi.fn(() => []),
|
||||
getTelemetryEnabled: vi.fn(() => false),
|
||||
getTelemetryLogPromptsEnabled: vi.fn(() => false),
|
||||
getTelemetryTracesEnabled: () => false,
|
||||
getFileFilteringRespectGitIgnore: vi.fn(() => true),
|
||||
getOutputFormat: vi.fn(() => 'text'),
|
||||
getUsageStatisticsEnabled: vi.fn(() => false),
|
||||
@@ -323,6 +326,7 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
getCoreTools: vi.fn(() => []),
|
||||
getTelemetryEnabled: vi.fn(() => false),
|
||||
getTelemetryLogPromptsEnabled: vi.fn(() => false),
|
||||
getTelemetryTracesEnabled: () => false,
|
||||
getFileFilteringRespectGitIgnore: vi.fn(() => true),
|
||||
getOutputFormat: vi.fn(() => 'text'),
|
||||
getUsageStatisticsEnabled: vi.fn(() => false),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -87,6 +87,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getAccessibility: vi.fn().mockReturnValue({}),
|
||||
getTelemetryEnabled: vi.fn().mockReturnValue(false),
|
||||
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
|
||||
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
|
||||
getTelemetryOtlpEndpoint: vi.fn().mockReturnValue(''),
|
||||
getTelemetryOtlpProtocol: vi.fn().mockReturnValue('grpc'),
|
||||
getTelemetryTarget: vi.fn().mockReturnValue(''),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -308,6 +308,8 @@ describe('useGeminiStream', () => {
|
||||
sandbox: false,
|
||||
targetDir: '/test/dir',
|
||||
debugMode: false,
|
||||
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
|
||||
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
|
||||
question: undefined,
|
||||
coreTools: [],
|
||||
toolDiscoveryCommand: undefined,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1579,6 +1559,8 @@ export const useGeminiStream = (
|
||||
operation: options?.isContinuation
|
||||
? GeminiCliOperation.SystemPrompt
|
||||
: GeminiCliOperation.UserPrompt,
|
||||
logPrompts: config.getTelemetryLogPromptsEnabled(),
|
||||
tracesEnabled: config.getTelemetryTracesEnabled(),
|
||||
sessionId: config.getSessionId(),
|
||||
},
|
||||
async ({ metadata: spanMetadata }) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -196,6 +194,7 @@ class DelegateInvocation extends BaseToolInvocation<
|
||||
{
|
||||
operation: GeminiCliOperation.AgentCall,
|
||||
logPrompts: this.context.config.getTelemetryLogPromptsEnabled(),
|
||||
tracesEnabled: this.context.config.getTelemetryTracesEnabled(),
|
||||
sessionId: this.context.config.getSessionId(),
|
||||
attributes: {
|
||||
[GEN_AI_AGENT_NAME]: this.definition.name,
|
||||
@@ -204,7 +203,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;
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -202,6 +202,7 @@ export interface PlanSettings {
|
||||
|
||||
export interface TelemetrySettings {
|
||||
enabled?: boolean;
|
||||
traces?: boolean;
|
||||
target?: TelemetryTarget;
|
||||
otlpEndpoint?: string;
|
||||
otlpProtocol?: 'grpc' | 'http';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1052,6 +1051,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.accessibility = params.accessibility ?? {};
|
||||
this.telemetrySettings = {
|
||||
enabled: params.telemetry?.enabled ?? false,
|
||||
traces: params.telemetry?.traces ?? false,
|
||||
target: params.telemetry?.target ?? DEFAULT_TELEMETRY_TARGET,
|
||||
otlpEndpoint: params.telemetry?.otlpEndpoint ?? DEFAULT_OTLP_ENDPOINT,
|
||||
otlpProtocol: params.telemetry?.otlpProtocol,
|
||||
@@ -1819,6 +1819,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 +2115,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();
|
||||
@@ -2652,6 +2662,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.telemetrySettings.enabled ?? false;
|
||||
}
|
||||
|
||||
getTelemetryTracesEnabled(): boolean {
|
||||
return this.telemetrySettings.traces ?? false;
|
||||
}
|
||||
|
||||
getTelemetryLogPromptsEnabled(): boolean {
|
||||
return this.telemetrySettings.logPrompts ?? true;
|
||||
}
|
||||
@@ -3334,6 +3348,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
setShellExecutionConfig(config: ShellExecutionConfig): void {
|
||||
this.shellExecutionConfig = {
|
||||
...this.shellExecutionConfig,
|
||||
terminalWidth:
|
||||
config.terminalWidth ?? this.shellExecutionConfig.terminalWidth,
|
||||
terminalHeight:
|
||||
|
||||
@@ -762,6 +762,11 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
|
||||
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
|
||||
|
||||
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
|
||||
1. Stop and remind yourself of the original task description.
|
||||
2. List your current assumptions and identify which ones might be wrong.
|
||||
3. Propose a different architectural approach rather than continuing to patch the current one.
|
||||
|
||||
## New Applications
|
||||
|
||||
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
|
||||
@@ -908,6 +913,11 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
|
||||
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
|
||||
|
||||
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
|
||||
1. Stop and remind yourself of the original task description.
|
||||
2. List your current assumptions and identify which ones might be wrong.
|
||||
3. Propose a different architectural approach rather than continuing to patch the current one.
|
||||
|
||||
## New Applications
|
||||
|
||||
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
|
||||
@@ -1036,6 +1046,11 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
|
||||
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
|
||||
|
||||
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
|
||||
1. Stop and remind yourself of the original task description.
|
||||
2. List your current assumptions and identify which ones might be wrong.
|
||||
3. Propose a different architectural approach rather than continuing to patch the current one.
|
||||
|
||||
## New Applications
|
||||
|
||||
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
|
||||
@@ -1688,6 +1703,11 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
|
||||
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
|
||||
|
||||
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
|
||||
1. Stop and remind yourself of the original task description.
|
||||
2. List your current assumptions and identify which ones might be wrong.
|
||||
3. Propose a different architectural approach rather than continuing to patch the current one.
|
||||
|
||||
## New Applications
|
||||
|
||||
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
|
||||
@@ -1847,6 +1867,11 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
|
||||
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
|
||||
|
||||
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
|
||||
1. Stop and remind yourself of the original task description.
|
||||
2. List your current assumptions and identify which ones might be wrong.
|
||||
3. Propose a different architectural approach rather than continuing to patch the current one.
|
||||
|
||||
## New Applications
|
||||
|
||||
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
|
||||
@@ -2010,6 +2035,11 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
|
||||
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
|
||||
|
||||
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
|
||||
1. Stop and remind yourself of the original task description.
|
||||
2. List your current assumptions and identify which ones might be wrong.
|
||||
3. Propose a different architectural approach rather than continuing to patch the current one.
|
||||
|
||||
## New Applications
|
||||
|
||||
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
|
||||
@@ -2173,6 +2203,11 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
|
||||
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
|
||||
|
||||
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
|
||||
1. Stop and remind yourself of the original task description.
|
||||
2. List your current assumptions and identify which ones might be wrong.
|
||||
3. Propose a different architectural approach rather than continuing to patch the current one.
|
||||
|
||||
## New Applications
|
||||
|
||||
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
|
||||
@@ -2332,6 +2367,11 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
|
||||
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
|
||||
|
||||
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
|
||||
1. Stop and remind yourself of the original task description.
|
||||
2. List your current assumptions and identify which ones might be wrong.
|
||||
3. Propose a different architectural approach rather than continuing to patch the current one.
|
||||
|
||||
## New Applications
|
||||
|
||||
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
|
||||
@@ -2493,6 +2533,11 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
|
||||
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
|
||||
|
||||
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
|
||||
1. Stop and remind yourself of the original task description.
|
||||
2. List your current assumptions and identify which ones might be wrong.
|
||||
3. Propose a different architectural approach rather than continuing to patch the current one.
|
||||
|
||||
## New Applications
|
||||
|
||||
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
|
||||
@@ -2613,6 +2658,11 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
|
||||
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
|
||||
|
||||
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
|
||||
1. Stop and remind yourself of the original task description.
|
||||
2. List your current assumptions and identify which ones might be wrong.
|
||||
3. Propose a different architectural approach rather than continuing to patch the current one.
|
||||
|
||||
## New Applications
|
||||
|
||||
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
|
||||
@@ -2771,6 +2821,11 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
|
||||
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
|
||||
|
||||
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
|
||||
1. Stop and remind yourself of the original task description.
|
||||
2. List your current assumptions and identify which ones might be wrong.
|
||||
3. Propose a different architectural approach rather than continuing to patch the current one.
|
||||
|
||||
## New Applications
|
||||
|
||||
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
|
||||
@@ -3057,6 +3112,11 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
|
||||
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
|
||||
|
||||
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
|
||||
1. Stop and remind yourself of the original task description.
|
||||
2. List your current assumptions and identify which ones might be wrong.
|
||||
3. Propose a different architectural approach rather than continuing to patch the current one.
|
||||
|
||||
## New Applications
|
||||
|
||||
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
|
||||
@@ -3473,6 +3533,11 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
|
||||
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
|
||||
|
||||
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
|
||||
1. Stop and remind yourself of the original task description.
|
||||
2. List your current assumptions and identify which ones might be wrong.
|
||||
3. Propose a different architectural approach rather than continuing to patch the current one.
|
||||
|
||||
## New Applications
|
||||
|
||||
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
|
||||
@@ -3632,6 +3697,11 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
|
||||
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
|
||||
|
||||
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
|
||||
1. Stop and remind yourself of the original task description.
|
||||
2. List your current assumptions and identify which ones might be wrong.
|
||||
3. Propose a different architectural approach rather than continuing to patch the current one.
|
||||
|
||||
## New Applications
|
||||
|
||||
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
|
||||
@@ -3905,6 +3975,11 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
|
||||
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
|
||||
|
||||
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
|
||||
1. Stop and remind yourself of the original task description.
|
||||
2. List your current assumptions and identify which ones might be wrong.
|
||||
3. Propose a different architectural approach rather than continuing to patch the current one.
|
||||
|
||||
## New Applications
|
||||
|
||||
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
|
||||
@@ -4064,6 +4139,11 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
|
||||
**Validation is the only path to finality.** Never assume success or settle for unverified changes. Rigorous, exhaustive verification is mandatory; it prevents the compounding cost of diagnosing failures later. A task is only complete when the behavioral correctness of the change has been verified and its structural integrity is confirmed within the full project context. Prioritize comprehensive validation above all else, utilizing redirection and focused analysis to manage high-output tasks without sacrificing depth. Never sacrifice validation rigor for the sake of brevity or to minimize tool-call overhead; partial or isolated checks are insufficient when more comprehensive validation is possible.
|
||||
|
||||
**Strategic Re-evaluation:** If you have attempted to fix a failing implementation more than 3 times without success, you must:
|
||||
1. Stop and remind yourself of the original task description.
|
||||
2. List your current assumptions and identify which ones might be wrong.
|
||||
3. Propose a different architectural approach rather than continuing to patch the current one.
|
||||
|
||||
## New Applications
|
||||
|
||||
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, interactive feedback, and platform-appropriate design.
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
BaseToolInvocation,
|
||||
type ToolResult,
|
||||
type AnyDeclarativeTool,
|
||||
type ToolLiveOutput,
|
||||
type ExecuteOptions,
|
||||
} from '../tools/tools.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type { HookSystem } from '../hooks/hookSystem.js';
|
||||
@@ -46,11 +46,7 @@ class MockBackgroundableInvocation extends BaseToolInvocation<
|
||||
getDescription() {
|
||||
return 'mock-pid';
|
||||
}
|
||||
async execute(
|
||||
_signal: AbortSignal,
|
||||
_updateOutput?: (output: ToolLiveOutput) => void,
|
||||
options?: { setExecutionIdCallback?: (executionId: number) => void },
|
||||
) {
|
||||
async execute(options: ExecuteOptions) {
|
||||
options?.setExecutionIdCallback?.(4242);
|
||||
return {
|
||||
llmContent: 'pid',
|
||||
|
||||
@@ -71,7 +71,7 @@ export async function executeToolWithHooks(
|
||||
signal: AbortSignal,
|
||||
tool: AnyDeclarativeTool,
|
||||
liveOutputCallback?: (outputChunk: ToolLiveOutput) => void,
|
||||
options?: ExecuteOptions,
|
||||
options?: Omit<ExecuteOptions, 'abortSignal' | 'updateOutput'>,
|
||||
config?: Config,
|
||||
originalRequestName?: string,
|
||||
skipBeforeHook?: boolean,
|
||||
@@ -154,11 +154,11 @@ export async function executeToolWithHooks(
|
||||
|
||||
// Execute the actual tool. Tools that support backgrounding can optionally
|
||||
// surface an execution ID via the callback.
|
||||
const toolResult: ToolResult = await invocation.execute(
|
||||
signal,
|
||||
liveOutputCallback,
|
||||
options,
|
||||
);
|
||||
const toolResult: ToolResult = await invocation.execute({
|
||||
...options,
|
||||
abortSignal: signal,
|
||||
updateOutput: liveOutputCallback,
|
||||
});
|
||||
|
||||
// Append notification if parameters were modified
|
||||
if (inputWasModified) {
|
||||
|
||||
@@ -153,6 +153,7 @@ describe('GeminiChat', () => {
|
||||
promptId: 'test-session-id',
|
||||
getSessionId: () => 'test-session-id',
|
||||
getTelemetryLogPromptsEnabled: () => true,
|
||||
getTelemetryTracesEnabled: () => false,
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getDebugMode: () => false,
|
||||
getContentGeneratorConfig: vi.fn().mockImplementation(() => ({
|
||||
|
||||
@@ -1050,6 +1050,10 @@ export class GeminiChat {
|
||||
result: call.response?.responseParts || null,
|
||||
status: call.status,
|
||||
timestamp: new Date().toISOString(),
|
||||
agentId:
|
||||
typeof call.response?.data?.['agentId'] === 'string'
|
||||
? call.response.data['agentId']
|
||||
: undefined,
|
||||
resultDisplay,
|
||||
description:
|
||||
'invocation' in call ? call.invocation?.getDescription() : undefined,
|
||||
|
||||
@@ -96,6 +96,7 @@ describe('GeminiChat Network Retries', () => {
|
||||
promptId: 'test-session-id',
|
||||
getSessionId: () => 'test-session-id',
|
||||
getTelemetryLogPromptsEnabled: () => true,
|
||||
getTelemetryTracesEnabled: () => false,
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getDebugMode: () => false,
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user