Compare commits

...

36 Commits

Author SHA1 Message Date
Spencer e4f07887d9 fix(telemetry): update trace.test.ts to expect conversation id in span attributes 2026-04-13 21:27:15 +00:00
Spencer 1454d80f49 fix(telemetry): address review comments on truncation and trace span 2026-04-13 20:18:39 +00:00
Spencer c6d3849d0c fix(telemetry): implement bounded structural truncation and decouple traces 2026-04-13 20:18:11 +00:00
Jacob Richman 82e8d67a78 Stop showing scrollbar unless we are in terminalBuffer mode (#25320) 2026-04-13 19:26:45 +00:00
Michael Bleigh 95944ec5af feat(agent): implement tool-controlled display protocol (Steps 2-3) (#25134) 2026-04-13 19:09:02 +00:00
Anjaligarhwal ea36ccb567 fix(core): replace custom binary detection with isbinaryfile to correctly handle UTF-8 (U+FFFD) (#25297) 2026-04-13 18:58:18 +00:00
Dev Randalpura a05c5ed56a feat(ui): added enhancements to scroll momentum (#24447) 2026-04-13 18:48:55 +00:00
Emily Hedlund 0d6d5d90b9 refactor(core): extract and centralize sandbox path utilities (#25305)
Co-authored-by: David Pierce <davidapierce@google.com>
2026-04-13 18:43:13 +00:00
Adib234 b91d177bde feat(cli): extract QuotaContext and resolve infinite render loop (#24959) 2026-04-13 18:32:18 +00:00
Sandy Tao 36dca862cc fix(release): prefix git hash in nightly versions to prevent semver normalization (#25304) 2026-04-13 17:55:11 +00:00
Christian Gunderman a5f7b453ca Stop suppressing thoughts and text in model response (#25073) 2026-04-13 17:47:48 +00:00
Sandy Tao 26f04c9d9a feat(core): add skill patching support with /memory inbox integration (#25148) 2026-04-13 17:44:52 +00:00
Jesse Rosenstock 5d8bd41937 docs(contributing): clarify self-assignment policy for issues (#23087) 2026-04-13 17:15:54 +00:00
Jack Wotherspoon 6b6ea56437 fix(core): fix quota footer for non-auto models and improve display (#25121) 2026-04-13 17:03:41 +00:00
Mark Griffith 0179726222 Memory fix for trace's streamWrapper. (#25089) 2026-04-11 05:26:07 +00:00
Abhijit Balaji 0957f7d3e2 fix(cli): exclude update_topic from confirmation queue count (#24945) 2026-04-10 23:04:59 +00:00
Coco Sheng 773567c6ca docs: correct documentation for enforced authentication type (#25142) 2026-04-10 22:43:54 +00:00
Sam Roberts 58901207bd docs: Update docs-audit to include changes in PR body (#25153) 2026-04-10 22:29:04 +00:00
M Junaid Shaukat 21e1c60922 fix: improve audio MIME normalization and validation in file reads (#21636)
Co-authored-by: Coco Sheng <cocosheng@google.com>
2026-04-10 21:46:24 +00:00
Coco Sheng 35907057ad chore: switch from keytar to @github/keytar (#25143) 2026-04-10 21:20:26 +00:00
Emily Hedlund e2a5231e30 perf(sandbox): optimize Windows sandbox initialization via native ACL application (#25077) 2026-04-10 20:50:21 +00:00
Tommaso Sciortino db6943fbee chore(deps): update vulnerable dependencies via npm audit fix (#25140) 2026-04-10 14:40:41 -07:00
Spencer 128e3567cb fix(core): resolve PTY exhaustion and orphan MCP subprocess leaks (#25079) 2026-04-10 18:57:09 +00:00
cynthialong0-0 a74bb603c0 feat(core): add large memory regression test (#25059) 2026-04-10 18:47:23 +00:00
euxaristia 565eafc1ec fix(cli): resolve text sanitization data loss due to C1 control characters (#22624) 2026-04-10 18:44:28 +00:00
Jason Matthew Suhari 7bba7f437c fix(cli): pass session id to interactive shell executions (#25114) 2026-04-10 18:38:35 +00:00
Christopher Thomas b499562921 docs: add vi shortcuts and clarify MCP sandbox setup (#21679)
Co-authored-by: Jenna Inouye <jinouye@google.com>
2026-04-10 18:00:25 +00:00
Jason Matthew Suhari 86f5a198bd fix(core): preserve shell execution config fields on update (#25113) 2026-04-10 17:55:40 +00:00
Aishanee Shah 31069329bb feat(core): add Strategic Re-evaluation guidance to system prompt (#25062) 2026-04-10 17:15:41 +00:00
Michael Bleigh 3b7c17a22c refactor(core): consolidate execute() arguments into ExecuteOptions (#25101) 2026-04-10 17:11:17 +00:00
Abhi 1d36309f5f chore(core): increase codebase investigator turn limits to 50 (#25125) 2026-04-10 16:56:07 +00:00
Abhi 7d1de3bccc feat(core): persist subagent agentId in tool call records (#25092) 2026-04-10 16:47:25 +00:00
Spencer f6c08a114b fix(core): remove buffer slice to prevent OOM on large output streams (#25094) 2026-04-10 16:33:36 +00:00
Jacob Richman c1fd6027e2 fix(cli): mark /settings as unsafe to run concurrently (#25061) 2026-04-10 16:31:37 +00:00
JAYADITYA 0fd0851e1a docs(cli): updates f12 description to be more precise (#15816) 2026-04-10 15:53:17 +00:00
Spencer 5fc8fea8d7 fix: resolve lifecycle memory leaks by cleaning up listeners and root closures (#25049) 2026-04-10 04:21:14 +00:00
201 changed files with 840956 additions and 2269 deletions
+1
View File
@@ -2,6 +2,7 @@
"experimental": {
"extensionReloading": true,
"modelSteering": true,
"memoryManager": true,
"topicUpdateNarration": true
},
"general": {
+14 -2
View File
@@ -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
View File
@@ -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
+7 -3
View File
@@ -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
View File
@@ -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**
+4
View File
@@ -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
+78 -1
View File
@@ -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
View File
@@ -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',
];
+12 -4
View File
@@ -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');
+30 -5
View File
@@ -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
+317
View File
@@ -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'),
};
}
+550 -81
View File
@@ -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
View File
@@ -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": {
+2 -2
View File
@@ -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({
+4 -2
View File
@@ -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: {
+20 -7
View File
@@ -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')}`,
};
}
}
+2 -2
View File
@@ -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.',
+2 -1
View File
@@ -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()),
+4
View File
@@ -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),
+49 -7
View File
@@ -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(''),
+73 -58
View File
@@ -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>
+7 -4
View File
@@ -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();
});
+65 -45
View File
@@ -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}
/>
);
+20 -35
View File
@@ -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,
},
},
});
+5 -5
View File
@@ -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}
+1 -40
View File
@@ -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 &quot;{selectedSkill?.name}&quot;</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 &quot;{selectedItem.skill.name}&quot;</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
"
`;
@@ -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

@@ -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

@@ -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;
};
+90 -20
View File
@@ -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,
+12 -6
View File
@@ -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,
+5 -23
View File
@@ -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 }) => {
+2 -1
View File
@@ -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.',
+2
View File
@@ -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).
+14 -4
View File
@@ -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,
};
}
+18
View File
@@ -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', () => {
+19 -9
View File
@@ -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);
}
/**
+14
View File
@@ -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({
+2 -1
View File
@@ -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({
-18
View File
@@ -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', () => {
+10 -5
View File
@@ -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);
}
+24 -5
View File
@@ -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. */
+8 -6
View File
@@ -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: {
+1 -1
View File
@@ -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({
+11 -6
View File
@@ -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',
+708
View File
@@ -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();
});
});
});
+475
View File
@@ -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.`,
};
}
+119
View File
@@ -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', () => {
+39 -24
View File
@@ -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(() => ({
+4
View File
@@ -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