Compare commits

...

73 Commits

Author SHA1 Message Date
Spencer 0203ab0a1c Merge branch 'main' into memory_usage3 2026-04-16 13:02:06 -04:00
Sandy Tao 6d7974f1ef feat(core): integrate skill-creator into skill extraction agent (#25421) 2026-04-16 01:38:59 +00:00
gemini-cli-robot 34a9d6e421 Changelog for v0.38.1 (#25476)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-04-16 00:06:43 +00:00
Sandy Tao 00b7781c3c fix(evals): update eval tests for invoke_agent telemetry and project-scoped memory (#25502) 2026-04-15 23:14:12 +00:00
Spencer cdf5c265d8 Merge branch 'main' into memory_usage3 2026-04-15 16:15:59 -04:00
gemini-cli-robot e827cfdf83 Changelog for v0.38.0 (#25470)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-04-15 20:02:53 +00:00
Jason Matthew Suhari cb35ee6710 fix(cli): clean up slash command IDE listeners (#24397)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-04-15 19:41:50 +00:00
Christopher Thomas cb289e0724 fix(core): honor GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL (#25357) 2026-04-15 19:38:38 +00:00
Z1xus c5ad0abb5d feat: add github colorblind themes (#15504)
Co-authored-by: Coco Sheng <cocosheng@google.com>
2026-04-15 19:23:50 +00:00
anj-s e664cc20fe test(evals): add subagent delegation evaluation tests (#24619) 2026-04-15 19:07:29 +00:00
Sandy Tao 485f3d92d8 improve(core): require recurrence evidence before extracting skills (#25147) 2026-04-15 18:45:31 +00:00
cynthialong0-0 5333e5ab20 feat(test): add large conversation scenario for performance test (#25331) 2026-04-15 18:08:43 +00:00
gemini-cli-robot 166845d933 Changelog for v0.39.0-preview.0 (#25417)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-04-15 17:28:39 +00:00
Spencer d0272a6436 Merge branch 'main' into memory_usage3 2026-04-15 11:26:52 -04:00
Tommaso Sciortino 55620235c0 feat: bundle ripgrep binaries into SEA for offline support (#25342) 2026-04-15 06:28:06 +00:00
Gal Zahavi 366f9e4766 fix(core): prevent YOLO mode from being downgraded (#25341) 2026-04-15 06:27:36 +00:00
Rob Clevenger 06e7621b26 Fix(core): retry additional OpenSSL 3.x SSL errors during streaming (#16075) (#25187) 2026-04-15 02:50:22 +00:00
gemini-cli-robot 8d05bdbe49 chore(release): bump version to 0.40.0-nightly.20260414.g5b1f7375a (#25420) 2026-04-15 00:06:35 +00:00
gemini-cli-robot 5b1f7375a3 Changelog for v0.37.2 (#25336)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-04-14 21:44:49 +00:00
Tommaso Sciortino d613dd05db use macos-latest-large runner where applicable. (#25413) 2026-04-14 14:05:25 -07:00
Spencer d14205a4ce Merge branch 'main' into memory_usage3 2026-04-14 16:31:05 -04:00
Adam Weidman a6d43cba2d ci: add agent session drift check workflow (#25389) 2026-04-14 19:31:48 +00:00
Clay 161ba28966 fix(core): detect kmscon terminal as supporting true color (#25282)
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-14 19:08:51 +00:00
Adib234 05aa1465fe feat(cli): enable mouse clicking for cursor positioning in AskUser multi-line answers (#24630) 2026-04-14 19:07:00 +00:00
cynthialong0-0 8f6edc50c1 feat(test): add a performance test in asian language (#25392) 2026-04-14 18:43:36 +00:00
Emily Hedlund 88ddcab616 fix(core): use debug level for keychain fallback logging (#25398) 2026-04-14 18:33:01 +00:00
ruomeng 02792264ed feat(plan): update plan mode prompt to allow showing plan content (#25058) 2026-04-14 17:36:37 +00:00
Emily Hedlund 059d9175eb test(core): improve sandbox integration test coverage and fix OS-specific failures (#25307)
Co-authored-by: David Pierce <davidapierce@google.com>
2026-04-14 17:33:07 +00:00
Spencer 20ca623cb8 Merge branch 'main' into memory_usage3 2026-04-14 13:31:08 -04:00
Jack Wotherspoon 212edf31ed chore(mcp): check MCP error code over brittle string match (#25381) 2026-04-14 17:24:21 +00:00
Abhi 1bb41262b0 docs(core): update generalist agent documentation (#25325) 2026-04-14 01:29:19 +00:00
Spencer 7ea1654706 test: use rig.log instead of console.error in file stream tests 2026-04-13 22:07:40 +00:00
joshualitt daf5006237 feat(core): introduce decoupled ContextManager and Sidecar architecture (#24752) 2026-04-13 22:02:22 +00:00
Spencer 4cde103712 Merge branch 'main' into memory_usage3 2026-04-13 17:57:12 -04:00
Spencer ee635bb0e9 fix(core): remove unused getPathIdentity import in shell.ts 2026-04-13 21:29:52 +00:00
Christian Gunderman 706d4d4707 fix(core): prevent secondary crash in ModelRouterService finally block (#25333) 2026-04-13 20:57:01 +00:00
Spencer 7181242f69 Merge branch 'main' into memory_usage3 2026-04-13 16:50:07 -04:00
Kevin Zhao 24f9ec51d2 fix: correct redirect count increment in fetchJson (#24896)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-04-13 20:45:52 +00:00
Spencer 44abbdf56e test(cli): fix duplicate getSessionId in config mock 2026-04-13 20:25:20 +00:00
Spencer 152806379c test(cli): fix flaky InputPrompt SVGs failing in test:ci suite due to styling leaks 2026-04-13 20:25:20 +00:00
Spencer 03efe65dd5 test(cli): update InputPrompt svg snapshots 2026-04-13 20:25:20 +00:00
Spencer 9e3a48a3b6 test(cli): fix flaky InputPrompt snapshot test caused by polling inside waitFor 2026-04-13 20:25:20 +00:00
Spencer e064cfe043 fix(core): ensure childProcessFallback also streams file_data unconditionally without truncation 2026-04-13 20:25:20 +00:00
Spencer 63a6211fe0 fix(core): ensure binary shell output files are still written to disk for 20MB files, and wait for stream close 2026-04-13 20:25:20 +00:00
Spencer c2a17ae257 test: revert to scanning tmpdir because readToolLogs does not expose tool result metadata 2026-04-13 20:25:20 +00:00
Spencer c1297436b9 fix(core): ensure file stream output dir exists and test uses explicit tool output path 2026-04-13 20:25:19 +00:00
Spencer 3d1c2e849c test: fix tests failing due to close event changes in childProcessFallback and add missing config mocks 2026-04-13 20:25:19 +00:00
Spencer cfac19e772 fix: resolve childProcessFallback on close instead of exit to prevent stdout truncation, and fix recursive file search in tests 2026-04-13 20:25:19 +00:00
Spencer ad28dc83c3 test: fix failing tests in core due to shell stream logic changes 2026-04-13 20:25:19 +00:00
Spencer bcd0acae4f fix: preserve fullOutputFilePath when content is summarized and flush pending lines on exit 2026-04-13 20:25:19 +00:00
Spencer 1755678cf9 fix: properly serialize initial AnsiOutput for background processes 2026-04-13 20:25:19 +00:00
Spencer 886025f6b9 chore: finish truncation and stream logging logic 2026-04-13 20:25:19 +00:00
Spencer 7cdfaaa6bd chore: fix truncation logic and test duplications 2026-04-13 20:25:19 +00:00
A.K.M. Adib ea4dd5ae35 lint passes and failing test passes 2026-04-13 20:25:19 +00:00
A.K.M. Adib 8df9a80cc4 bring new functiontruncateOutputIfNeeded without changes to current branch in there 2026-04-13 20:25:19 +00:00
A.K.M. Adib e3baad48c8 tests pass 2026-04-13 20:25:18 +00:00
A.K.M. Adib 0ca2e4e2ae fix build 2026-04-13 20:25:18 +00:00
jacob314 30b4dcecbc Code review fix. 2026-04-13 20:25:18 +00:00
jacob314 954835123f Checkpoint of shell optimization
fix(cli): Write shell command output to a file and limit memory buffered in UI

Fixes.

Checkpoint.

fix(core, cli): await outputStream.end() to prevent race conditions

This commit fixes a critical race condition where
was called synchronously without being awaited. This led to potential file
truncation or EBUSY errors on Windows when attempting to manipulate the file
immediately after the  call.

Additionally, this change removes fixed wait times (`setTimeout`) that
were previously used in test files as a band-aid.

fix(core): stream processed xterm output to file to remove spurious escape codes

test(core): update shell regression tests to use file_data events
2026-04-13 20:25:18 +00:00
Jerop Kipruto 050c30330e feat(core): implement silent fallback for Plan Mode model routing (#25317) 2026-04-13 19:59:24 +00:00
Tanmay Vartak a172b328e2 feat: support auth block in MCP servers config in agents (#24770) 2026-04-13 19:41:40 +00:00
Adib234 a4318f22ec fix(core): expose GEMINI_PLANS_DIR to hook environment (#25296)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-13 19:26:52 +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
218 changed files with 16449 additions and 2989 deletions
+1
View File
@@ -2,6 +2,7 @@
"experimental": {
"extensionReloading": true,
"modelSteering": true,
"memoryManager": true,
"topicUpdateNarration": true
},
"general": {
@@ -0,0 +1,132 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: 'Agent Session Drift Check'
on:
pull_request:
branches:
- 'main'
- 'release/**'
paths:
- 'packages/cli/src/nonInteractiveCli.ts'
- 'packages/cli/src/nonInteractiveCliAgentSession.ts'
concurrency:
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
cancel-in-progress: true
jobs:
check-drift:
name: 'Check Agent Session Drift'
runs-on: 'ubuntu-latest'
if: "github.repository == 'google-gemini/gemini-cli'"
permissions:
contents: 'read'
pull-requests: 'write'
steps:
- name: 'Detect drift and comment'
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v8
with:
script: |-
// === Pair configuration — append here to cover more pairs ===
const PAIRS = [
{
legacy: 'packages/cli/src/nonInteractiveCli.ts',
session: 'packages/cli/src/nonInteractiveCliAgentSession.ts',
label: 'non-interactive CLI',
},
// Future pairs can be added here. Remember to also add both
// paths to the `paths:` filter at the top of this workflow.
// Example:
// {
// legacy: 'packages/core/src/agents/local-invocation.ts',
// session: 'packages/core/src/agents/local-session-invocation.ts',
// label: 'local subagent invocation',
// },
];
// ============================================================
const prNumber = context.payload.pull_request.number;
const { owner, repo } = context.repo;
// Use the API to list changed files — no checkout/git diff needed.
const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
const changed = new Set(files.map((f) => f.filename));
const warnings = [];
for (const { legacy, session, label } of PAIRS) {
const legacyChanged = changed.has(legacy);
const sessionChanged = changed.has(session);
if (legacyChanged && !sessionChanged) {
warnings.push(
`**${label}**: \`${legacy}\` was modified but \`${session}\` was not.`,
);
} else if (!legacyChanged && sessionChanged) {
warnings.push(
`**${label}**: \`${session}\` was modified but \`${legacy}\` was not.`,
);
}
}
const MARKER = '<!-- agent-session-drift-check -->';
// Look up our existing drift comment (for upsert/cleanup).
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: prNumber,
per_page: 100,
});
const existing = comments.find(
(c) => c.user?.type === 'Bot' && c.body?.includes(MARKER),
);
if (warnings.length === 0) {
core.info('No drift detected.');
// If drift was previously flagged and is now resolved, remove the comment.
if (existing) {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: existing.id,
});
core.info(`Deleted stale drift comment ${existing.id}.`);
}
return;
}
const body = [
MARKER,
'### ⚠️ Invocation Drift Warning',
'',
'The following file pairs should generally be kept in sync during the AgentSession migration:',
'',
...warnings.map((w) => `- ${w}`),
'',
'If this is intentional (e.g., a bug fix specific to one implementation), you can ignore this comment.',
'',
'_This check will be removed once the legacy implementations are deleted._',
].join('\n');
if (existing) {
core.info(`Updating existing drift comment ${existing.id}.`);
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
} else {
core.info('Creating new drift comment.');
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body,
});
}
+1 -1
View File
@@ -183,7 +183,7 @@ jobs:
needs:
- 'merge_queue_skipper'
- 'parse_run_context'
runs-on: 'macos-latest'
runs-on: 'macos-latest-large'
if: |
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
steps:
+1 -1
View File
@@ -224,7 +224,7 @@ jobs:
test_mac:
name: 'Test (Mac) - ${{ matrix.node-version }}, ${{ matrix.shard }}'
runs-on: 'macos-latest'
runs-on: 'macos-latest-large'
needs:
- 'merge_queue_skipper'
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
+1 -1
View File
@@ -77,7 +77,7 @@ jobs:
deflake_e2e_mac:
name: 'E2E Test (macOS)'
runs-on: 'macos-latest'
runs-on: 'macos-latest-large'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
+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
+21
View File
@@ -18,6 +18,27 @@ on GitHub.
| [Preview](preview.md) | Experimental features ready for early feedback. |
| [Stable](latest.md) | Stable, recommended for general use. |
## Announcements: v0.38.0 - 2026-04-14
- **Chapters Narrative Flow:** Group agent interactions into "Chapters" based on
intent and tool usage for better session structure
([#23150](https://github.com/google-gemini/gemini-cli/pull/23150) by
@Abhijit-2592,
[#24079](https://github.com/google-gemini/gemini-cli/pull/24079) by
@gundermanc).
- **Context Compression Service:** Advanced context management to efficiently
distill conversation history
([#24483](https://github.com/google-gemini/gemini-cli/pull/24483) by
@joshualitt).
- **UI Flicker & UX Enhancements:** Solved rendering flicker with "Terminal
Buffer" mode and introduced selective topic expansion
([#24512](https://github.com/google-gemini/gemini-cli/pull/24512) by
@jacob314, [#24793](https://github.com/google-gemini/gemini-cli/pull/24793) by
@Abhijit-2592).
- **Persistent Policy Approvals:** Implemented context-aware persistent
approvals for tool execution
([#23257](https://github.com/google-gemini/gemini-cli/pull/23257) by @jerop).
## Announcements: v0.37.0 - 2026-04-08
- **Dynamic Sandbox Expansion:** Implemented dynamic sandbox expansion and
+253 -407
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.37.1
# Latest stable release: v0.38.1
Released: April 09, 2026
Released: April 15, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -11,415 +11,261 @@ npm install -g @google/gemini-cli
## Highlights
- **Dynamic Sandbox Expansion:** Implemented dynamic sandbox expansion and
worktree support for both Linux and Windows, enhancing development flexibility
in restricted environments.
- **Tool-Based Topic Grouping (Chapters):** Introduced "Chapters" to logically
group agent interactions based on tool usage and intent, providing a clearer
narrative flow in long sessions.
- **Enhanced Browser Agent:** Added persistent session management, dynamic
read-only tool discovery, and sandbox-aware initialization for the browser
agent.
- **Security & Permission Hardening:** Implemented secret visibility lockdown
for environment files and integrated integrity controls for Windows
sandboxing.
- **Chapters Narrative Flow:** Introduced tool-based topic grouping ("Chapters")
to provide better session structure and narrative continuity in long-running
tasks.
- **Context Compression Service:** Implemented a dedicated service for advanced
context management, efficiently distilling conversation history to preserve
focus and tokens.
- **Enhanced UI Stability & UX:** Introduced a new "Terminal Buffer" mode to
solve rendering flicker, along with selective topic expansion and improved
tool confirmation layouts.
- **Context-Aware Policy Approvals:** Users can now grant persistent,
context-aware approvals for tools, significantly reducing manual confirmation
overhead for trusted workflows.
- **Background Process Monitoring:** New tools for monitoring and inspecting
background shell processes, providing better visibility into asynchronous
tasks.
## What's Changed
- fix(acp): handle all InvalidStreamError types gracefully in prompt
[#24540](https://github.com/google-gemini/gemini-cli/pull/24540)
- feat(acp): add support for /about command
[#24649](https://github.com/google-gemini/gemini-cli/pull/24649)
- feat(acp): add /help command
[#24839](https://github.com/google-gemini/gemini-cli/pull/24839)
- feat(evals): centralize test agents into test-utils for reuse by @Samee24 in
[#23616](https://github.com/google-gemini/gemini-cli/pull/23616)
- revert: chore(config): disable agents by default by @abhipatel12 in
[#23672](https://github.com/google-gemini/gemini-cli/pull/23672)
- fix(plan): update telemetry attribute keys and add timestamp by @Adib234 in
[#23685](https://github.com/google-gemini/gemini-cli/pull/23685)
- fix(core): prevent premature MCP discovery completion by @jackwotherspoon in
[#23637](https://github.com/google-gemini/gemini-cli/pull/23637)
- feat(browser): add maxActionsPerTask for browser agent setting by
@cynthialong0-0 in
[#23216](https://github.com/google-gemini/gemini-cli/pull/23216)
- fix(core): improve agent loader error formatting for empty paths by
@adamfweidman in
[#23690](https://github.com/google-gemini/gemini-cli/pull/23690)
- fix(cli): only show updating spinner when auto-update is in progress by
@scidomino in [#23709](https://github.com/google-gemini/gemini-cli/pull/23709)
- Refine onboarding metrics to log the duration explicitly and use the tier
name. by @yunaseoul in
[#23678](https://github.com/google-gemini/gemini-cli/pull/23678)
- chore(tools): add toJSON to tools and invocations to reduce logging verbosity
by @alisa-alisa in
[#22899](https://github.com/google-gemini/gemini-cli/pull/22899)
- fix(cli): stabilize copy mode to prevent flickering and cursor resets by
@mattKorwel in
[#22584](https://github.com/google-gemini/gemini-cli/pull/22584)
- fix(test): move flaky ctrl-c-exit test to non-blocking suite by @mattKorwel in
[#23732](https://github.com/google-gemini/gemini-cli/pull/23732)
- feat(skills): add ci skill for automated failure replication by @mattKorwel in
[#23720](https://github.com/google-gemini/gemini-cli/pull/23720)
- feat(sandbox): implement forbiddenPaths for OS-specific sandbox managers by
@ehedlund in [#23282](https://github.com/google-gemini/gemini-cli/pull/23282)
- fix(core): conditionally expose additional_permissions in shell tool by
@galz10 in [#23729](https://github.com/google-gemini/gemini-cli/pull/23729)
- refactor(core): standardize OS-specific sandbox tests and extract linux helper
methods by @ehedlund in
[#23715](https://github.com/google-gemini/gemini-cli/pull/23715)
- format recently added script by @scidomino in
[#23739](https://github.com/google-gemini/gemini-cli/pull/23739)
- fix(ui): prevent over-eager slash subcommand completion by @keithguerin in
[#20136](https://github.com/google-gemini/gemini-cli/pull/20136)
- Fix dynamic model routing for gemini 3.1 pro to customtools model by
@kevinjwang1 in
[#23641](https://github.com/google-gemini/gemini-cli/pull/23641)
- feat(core): support inline agentCardJson for remote agents by @adamfweidman in
[#23743](https://github.com/google-gemini/gemini-cli/pull/23743)
- fix(cli): skip console log/info in headless mode by @cynthialong0-0 in
[#22739](https://github.com/google-gemini/gemini-cli/pull/22739)
- test(core): install bubblewrap on Linux CI for sandbox integration tests by
@ehedlund in [#23583](https://github.com/google-gemini/gemini-cli/pull/23583)
- docs(reference): split tools table into category sections by @sheikhlimon in
[#21516](https://github.com/google-gemini/gemini-cli/pull/21516)
- fix(browser): detect embedded URLs in query params to prevent allowedDomains
bypass by @tony-shi in
[#23225](https://github.com/google-gemini/gemini-cli/pull/23225)
- fix(browser): add proxy bypass constraint to domain restriction system prompt
by @tony-shi in
[#23229](https://github.com/google-gemini/gemini-cli/pull/23229)
- fix(policy): relax write_file argsPattern in plan mode to allow paths without
session ID by @Adib234 in
[#23695](https://github.com/google-gemini/gemini-cli/pull/23695)
- docs: fix grammar in CONTRIBUTING and numbering in sandbox docs by
@splint-disk-8i in
[#23448](https://github.com/google-gemini/gemini-cli/pull/23448)
- fix(acp): allow attachments by adding a permission prompt by @sripasg in
[#23680](https://github.com/google-gemini/gemini-cli/pull/23680)
- fix(core): thread AbortSignal to chat compression requests (#20405) by
@SH20RAJ in [#20778](https://github.com/google-gemini/gemini-cli/pull/20778)
- feat(core): implement Windows sandbox dynamic expansion Phase 1 and 2.1 by
@scidomino in [#23691](https://github.com/google-gemini/gemini-cli/pull/23691)
- Add note about root privileges in sandbox docs by @diodesign in
[#23314](https://github.com/google-gemini/gemini-cli/pull/23314)
- docs(core): document agent_card_json string literal options for remote agents
by @adamfweidman in
[#23797](https://github.com/google-gemini/gemini-cli/pull/23797)
- fix(cli): resolve TTY hang on headless environments by unconditionally
resuming process.stdin before React Ink launch by @cocosheng-g in
[#23673](https://github.com/google-gemini/gemini-cli/pull/23673)
- fix(ui): cleanup estimated string length hacks in composer by @keithguerin in
[#23694](https://github.com/google-gemini/gemini-cli/pull/23694)
- feat(browser): dynamically discover read-only tools by @cynthialong0-0 in
[#23805](https://github.com/google-gemini/gemini-cli/pull/23805)
- docs: clarify policy requirement for `general.plan.directory` in settings
schema by @jerop in
[#23784](https://github.com/google-gemini/gemini-cli/pull/23784)
- Revert "perf(cli): optimize --version startup time (#23671)" by @scidomino in
[#23812](https://github.com/google-gemini/gemini-cli/pull/23812)
- don't silence errors from wombat by @scidomino in
[#23822](https://github.com/google-gemini/gemini-cli/pull/23822)
- fix(ui): prevent escape key from cancelling requests in shell mode by
@PrasannaPal21 in
[#21245](https://github.com/google-gemini/gemini-cli/pull/21245)
- Changelog for v0.36.0-preview.0 by @gemini-cli-robot in
[#23702](https://github.com/google-gemini/gemini-cli/pull/23702)
- feat(core,ui): Add experiment-gated support for gemini flash 3.1 lite by
@chrstnb in [#23794](https://github.com/google-gemini/gemini-cli/pull/23794)
- Changelog for v0.36.0-preview.3 by @gemini-cli-robot in
[#23827](https://github.com/google-gemini/gemini-cli/pull/23827)
- new linting check: github-actions-pinning by @alisa-alisa in
[#23808](https://github.com/google-gemini/gemini-cli/pull/23808)
- fix(cli): show helpful guidance when no skills are available by @Niralisj in
[#23785](https://github.com/google-gemini/gemini-cli/pull/23785)
- fix: Chat logs and errors handle tail tool calls correctly by @googlestrobe in
[#22460](https://github.com/google-gemini/gemini-cli/pull/22460)
- Don't try removing a tag from a non-existent release. by @scidomino in
[#23830](https://github.com/google-gemini/gemini-cli/pull/23830)
- fix(cli): allow ask question dialog to take full window height by @jacob314 in
[#23693](https://github.com/google-gemini/gemini-cli/pull/23693)
- fix(core): strip leading underscores from error types in telemetry by
@yunaseoul in [#23824](https://github.com/google-gemini/gemini-cli/pull/23824)
- Changelog for v0.35.0 by @gemini-cli-robot in
[#23819](https://github.com/google-gemini/gemini-cli/pull/23819)
- feat(evals): add reliability harvester and 500/503 retry support by
@alisa-alisa in
[#23626](https://github.com/google-gemini/gemini-cli/pull/23626)
- feat(sandbox): dynamic Linux sandbox expansion and worktree support by @galz10
in [#23692](https://github.com/google-gemini/gemini-cli/pull/23692)
- Merge examples of use into quickstart documentation by @diodesign in
[#23319](https://github.com/google-gemini/gemini-cli/pull/23319)
- fix(cli): prioritize primary name matches in slash command search by @sehoon38
in [#23850](https://github.com/google-gemini/gemini-cli/pull/23850)
- Changelog for v0.35.1 by @gemini-cli-robot in
[#23840](https://github.com/google-gemini/gemini-cli/pull/23840)
- fix(browser): keep input blocker active across navigations by @kunal-10-cloud
in [#22562](https://github.com/google-gemini/gemini-cli/pull/22562)
- feat(core): new skill to look for duplicated code while reviewing PRs by
@devr0306 in [#23704](https://github.com/google-gemini/gemini-cli/pull/23704)
- fix(core): replace hardcoded non-interactive ASK_USER denial with explicit
policy rules by @ruomengz in
[#23668](https://github.com/google-gemini/gemini-cli/pull/23668)
- fix(plan): after exiting plan mode switches model to a flash model by @Adib234
in [#23885](https://github.com/google-gemini/gemini-cli/pull/23885)
- feat(gcp): add development worker infrastructure by @mattKorwel in
[#23814](https://github.com/google-gemini/gemini-cli/pull/23814)
- fix(a2a-server): A2A server should execute ask policies in interactive mode by
@kschaab in [#23831](https://github.com/google-gemini/gemini-cli/pull/23831)
- feat(core): define TrajectoryProvider interface by @sehoon38 in
[#23050](https://github.com/google-gemini/gemini-cli/pull/23050)
- Docs: Update quotas and pricing by @jkcinouye in
[#23835](https://github.com/google-gemini/gemini-cli/pull/23835)
- fix(core): allow disabling environment variable redaction by @galz10 in
[#23927](https://github.com/google-gemini/gemini-cli/pull/23927)
- feat(cli): enable notifications cross-platform via terminal bell fallback by
@genneth in [#21618](https://github.com/google-gemini/gemini-cli/pull/21618)
- feat(sandbox): implement secret visibility lockdown for env files by
@DavidAPierce in
[#23712](https://github.com/google-gemini/gemini-cli/pull/23712)
- fix(core): remove shell outputChunks buffer caching to prevent memory bloat
and sanitize prompt input by @spencer426 in
[#23751](https://github.com/google-gemini/gemini-cli/pull/23751)
- feat(core): implement persistent browser session management by @kunal-10-cloud
in [#21306](https://github.com/google-gemini/gemini-cli/pull/21306)
- refactor(core): delegate sandbox denial parsing to SandboxManager by
@scidomino in [#23928](https://github.com/google-gemini/gemini-cli/pull/23928)
- dep(update) Update Ink version to 6.5.0 by @jacob314 in
[#23843](https://github.com/google-gemini/gemini-cli/pull/23843)
- Docs: Update 'docs-writer' skill for relative links by @jkcinouye in
[#21463](https://github.com/google-gemini/gemini-cli/pull/21463)
- Changelog for v0.36.0-preview.4 by @gemini-cli-robot in
[#23935](https://github.com/google-gemini/gemini-cli/pull/23935)
- fix(acp): Update allow approval policy flow for ACP clients to fix config
persistence and compatible with TUI by @sripasg in
[#23818](https://github.com/google-gemini/gemini-cli/pull/23818)
- Changelog for v0.35.2 by @gemini-cli-robot in
[#23960](https://github.com/google-gemini/gemini-cli/pull/23960)
- ACP integration documents by @g-samroberts in
[#22254](https://github.com/google-gemini/gemini-cli/pull/22254)
- fix(core): explicitly set error names to avoid bundling renaming issues by
@yunaseoul in [#23913](https://github.com/google-gemini/gemini-cli/pull/23913)
- feat(core): subagent isolation and cleanup hardening by @abhipatel12 in
[#23903](https://github.com/google-gemini/gemini-cli/pull/23903)
- disable extension-reload test by @scidomino in
[#24018](https://github.com/google-gemini/gemini-cli/pull/24018)
- feat(core): add forbiddenPaths to GlobalSandboxOptions and refactor
createSandboxManager by @ehedlund in
[#23936](https://github.com/google-gemini/gemini-cli/pull/23936)
- refactor(core): improve ignore resolution and fix directory-matching bug by
@ehedlund in [#23816](https://github.com/google-gemini/gemini-cli/pull/23816)
- revert(core): support custom base URL via env vars by @spencer426 in
[#23976](https://github.com/google-gemini/gemini-cli/pull/23976)
- Increase memory limited for eslint. by @jacob314 in
[#24022](https://github.com/google-gemini/gemini-cli/pull/24022)
- fix(acp): prevent crash on empty response in ACP mode by @sripasg in
[#23952](https://github.com/google-gemini/gemini-cli/pull/23952)
- feat(core): Land `AgentHistoryProvider`. by @joshualitt in
[#23978](https://github.com/google-gemini/gemini-cli/pull/23978)
- fix(core): switch to subshells for shell tool wrapping to fix heredocs and
edge cases by @abhipatel12 in
[#24024](https://github.com/google-gemini/gemini-cli/pull/24024)
- Debug command. by @jacob314 in
[#23851](https://github.com/google-gemini/gemini-cli/pull/23851)
- Changelog for v0.36.0-preview.5 by @gemini-cli-robot in
[#24046](https://github.com/google-gemini/gemini-cli/pull/24046)
- Fix test flakes by globally mocking ink-spinner by @jacob314 in
[#24044](https://github.com/google-gemini/gemini-cli/pull/24044)
- Enable network access in sandbox configuration by @galz10 in
[#24055](https://github.com/google-gemini/gemini-cli/pull/24055)
- feat(context): add configurable memoryBoundaryMarkers setting by @SandyTao520
in [#24020](https://github.com/google-gemini/gemini-cli/pull/24020)
- feat(core): implement windows sandbox expansion and denial detection by
@scidomino in [#24027](https://github.com/google-gemini/gemini-cli/pull/24027)
- fix(core): resolve ACP Operation Aborted Errors in grep_search by @ivanporty
in [#23821](https://github.com/google-gemini/gemini-cli/pull/23821)
- fix(hooks): prevent SessionEnd from firing twice in non-interactive mode by
@krishdef7 in [#22139](https://github.com/google-gemini/gemini-cli/pull/22139)
- Re-word intro to Gemini 3 page. by @g-samroberts in
[#24069](https://github.com/google-gemini/gemini-cli/pull/24069)
- fix(cli): resolve layout contention and flashing loop in StatusRow by
@keithguerin in
[#24065](https://github.com/google-gemini/gemini-cli/pull/24065)
- fix(sandbox): implement Windows Mandatory Integrity Control for GeminiSandbox
by @galz10 in [#24057](https://github.com/google-gemini/gemini-cli/pull/24057)
- feat(core): implement tool-based topic grouping (Chapters) by @Abhijit-2592 in
[#23150](https://github.com/google-gemini/gemini-cli/pull/23150)
- feat(cli): support 'tab to queue' for messages while generating by @gundermanc
in [#24052](https://github.com/google-gemini/gemini-cli/pull/24052)
- feat(core): agnostic background task UI with CompletionBehavior by
@adamfweidman in
[#22740](https://github.com/google-gemini/gemini-cli/pull/22740)
- UX for topic narration tool by @gundermanc in
[#24079](https://github.com/google-gemini/gemini-cli/pull/24079)
- fix: shellcheck warnings in scripts by @scidomino in
[#24035](https://github.com/google-gemini/gemini-cli/pull/24035)
- test(evals): add comprehensive subagent delegation evaluations by @abhipatel12
in [#24132](https://github.com/google-gemini/gemini-cli/pull/24132)
- fix(a2a-server): prioritize ADC before evaluating headless constraints for
auth initialization by @spencer426 in
[#23614](https://github.com/google-gemini/gemini-cli/pull/23614)
- Text can be added after /plan command by @rambleraptor in
[#22833](https://github.com/google-gemini/gemini-cli/pull/22833)
- fix(cli): resolve missing F12 logs via global console store by @scidomino in
[#24235](https://github.com/google-gemini/gemini-cli/pull/24235)
- fix broken tests by @scidomino in
[#24279](https://github.com/google-gemini/gemini-cli/pull/24279)
- fix(evals): add update_topic behavioral eval by @gundermanc in
[#24223](https://github.com/google-gemini/gemini-cli/pull/24223)
- feat(core): Unified Context Management and Tool Distillation. by @joshualitt
in [#24157](https://github.com/google-gemini/gemini-cli/pull/24157)
- Default enable narration for the team. by @gundermanc in
[#24224](https://github.com/google-gemini/gemini-cli/pull/24224)
- fix(core): ensure default agents provide tools and use model-specific schemas
by @abhipatel12 in
[#24268](https://github.com/google-gemini/gemini-cli/pull/24268)
- feat(cli): show Flash Lite Preview model regardless of user tier by @sehoon38
in [#23904](https://github.com/google-gemini/gemini-cli/pull/23904)
- feat(cli): implement compact tool output by @jwhelangoog in
[#20974](https://github.com/google-gemini/gemini-cli/pull/20974)
- Add security settings for tool sandboxing by @galz10 in
[#23923](https://github.com/google-gemini/gemini-cli/pull/23923)
- chore(test-utils): switch integration tests to use PREVIEW_GEMINI_MODEL by
@sehoon38 in [#24276](https://github.com/google-gemini/gemini-cli/pull/24276)
- feat(core): enable topic update narration for legacy models by @Abhijit-2592
in [#24241](https://github.com/google-gemini/gemini-cli/pull/24241)
- feat(core): add project-level memory scope to save_memory tool by @SandyTao520
in [#24161](https://github.com/google-gemini/gemini-cli/pull/24161)
- test(integration): fix plan mode write denial test false positive by @sehoon38
in [#24299](https://github.com/google-gemini/gemini-cli/pull/24299)
- feat(plan): support `Plan` mode in untrusted folders by @Adib234 in
[#17586](https://github.com/google-gemini/gemini-cli/pull/17586)
- fix(core): enable mid-stream retries for all models and re-enable compression
test by @sehoon38 in
[#24302](https://github.com/google-gemini/gemini-cli/pull/24302)
- Changelog for v0.36.0-preview.6 by @gemini-cli-robot in
[#24082](https://github.com/google-gemini/gemini-cli/pull/24082)
- Changelog for v0.35.3 by @gemini-cli-robot in
[#24083](https://github.com/google-gemini/gemini-cli/pull/24083)
- feat(cli): add auth info to footer by @sehoon38 in
[#24042](https://github.com/google-gemini/gemini-cli/pull/24042)
- fix(browser): reset action counter for each agent session and let it ignore
internal actions by @cynthialong0-0 in
[#24228](https://github.com/google-gemini/gemini-cli/pull/24228)
- feat(plan): promote planning feature to stable by @ruomengz in
[#24282](https://github.com/google-gemini/gemini-cli/pull/24282)
- fix(browser): terminate subagent immediately on domain restriction violations
by @gsquared94 in
[#24313](https://github.com/google-gemini/gemini-cli/pull/24313)
- feat(cli): add UI to update extensions by @ruomengz in
[#23682](https://github.com/google-gemini/gemini-cli/pull/23682)
- Fix(browser): terminate immediately for "browser is already running" error by
@cynthialong0-0 in
[#24233](https://github.com/google-gemini/gemini-cli/pull/24233)
- docs: Add 'plan' option to approval mode in CLI reference by @YifanRuan in
[#24134](https://github.com/google-gemini/gemini-cli/pull/24134)
- fix(core): batch macOS seatbelt rules into a profile file to prevent ARG_MAX
errors by @ehedlund in
[#24255](https://github.com/google-gemini/gemini-cli/pull/24255)
- fix(core): fix race condition between browser agent and main closing process
by @cynthialong0-0 in
[#24340](https://github.com/google-gemini/gemini-cli/pull/24340)
- perf(build): optimize build scripts for parallel execution and remove
redundant checks by @sehoon38 in
[#24307](https://github.com/google-gemini/gemini-cli/pull/24307)
- ci: install bubblewrap on Linux for release workflows by @ehedlund in
[#24347](https://github.com/google-gemini/gemini-cli/pull/24347)
- chore(release): allow bundling for all builds, including stable by @sehoon38
in [#24305](https://github.com/google-gemini/gemini-cli/pull/24305)
- Revert "Add security settings for tool sandboxing" by @jerop in
[#24357](https://github.com/google-gemini/gemini-cli/pull/24357)
- docs: update subagents docs to not be experimental by @abhipatel12 in
[#24343](https://github.com/google-gemini/gemini-cli/pull/24343)
- fix(core): implement **read and **write commands in sandbox managers by
@galz10 in [#24283](https://github.com/google-gemini/gemini-cli/pull/24283)
- don't try to remove tags in dry run by @scidomino in
[#24356](https://github.com/google-gemini/gemini-cli/pull/24356)
- fix(config): disable JIT context loading by default by @SandyTao520 in
[#24364](https://github.com/google-gemini/gemini-cli/pull/24364)
- test(sandbox): add integration test for dynamic permission expansion by
@galz10 in [#24359](https://github.com/google-gemini/gemini-cli/pull/24359)
- docs(policy): remove unsupported mcpName wildcard edge case by @abhipatel12 in
[#24133](https://github.com/google-gemini/gemini-cli/pull/24133)
- docs: fix broken GEMINI.md link in CONTRIBUTING.md by @Panchal-Tirth in
[#24182](https://github.com/google-gemini/gemini-cli/pull/24182)
- feat(core): infrastructure for event-driven subagent history by @abhipatel12
in [#23914](https://github.com/google-gemini/gemini-cli/pull/23914)
- fix(core): resolve Plan Mode deadlock during plan file creation due to sandbox
restrictions by @DavidAPierce in
[#24047](https://github.com/google-gemini/gemini-cli/pull/24047)
- fix(core): fix browser agent UX issues and improve E2E test reliability by
@gsquared94 in
[#24312](https://github.com/google-gemini/gemini-cli/pull/24312)
- fix(ui): wrap topic and intent fields in TopicMessage by @jwhelangoog in
[#24386](https://github.com/google-gemini/gemini-cli/pull/24386)
- refactor(core): Centralize context management logic into src/context by
@joshualitt in
[#24380](https://github.com/google-gemini/gemini-cli/pull/24380)
- fix(core): pin AuthType.GATEWAY to use Gemini 3.1 Pro/Flash Lite by default by
@sripasg in [#24375](https://github.com/google-gemini/gemini-cli/pull/24375)
- feat(ui): add Tokyo Night theme by @danrneal in
[#24054](https://github.com/google-gemini/gemini-cli/pull/24054)
- fix(cli): refactor test config loading and mock debugLogger in test-setup by
@mattKorwel in
[#24389](https://github.com/google-gemini/gemini-cli/pull/24389)
- Set memoryManager to false in settings.json by @mattKorwel in
[#24393](https://github.com/google-gemini/gemini-cli/pull/24393)
- ink 6.6.3 by @jacob314 in
[#24372](https://github.com/google-gemini/gemini-cli/pull/24372)
- fix(core): resolve subagent chat recording gaps and directory inheritance by
- fix(patch): cherry-pick 050c303 to release/v0.38.0-pr-25317 to patch version
v0.38.0 and create version 0.38.1 by @gemini-cli-robot in
[#25466](https://github.com/google-gemini/gemini-cli/pull/25466)
- fix(cli): refresh slash command list after /skills reload by @NTaylorMullen in
[#24454](https://github.com/google-gemini/gemini-cli/pull/24454)
- Update README.md for links. by @g-samroberts in
[#22759](https://github.com/google-gemini/gemini-cli/pull/22759)
- fix(core): ensure complete_task tool calls are recorded in chat history by
@abhipatel12 in
[#24368](https://github.com/google-gemini/gemini-cli/pull/24368)
- fix(cli): cap shell output at 10 MB to prevent RangeError crash by @ProthamD
in [#24168](https://github.com/google-gemini/gemini-cli/pull/24168)
- feat(plan): conditionally add enter/exit plan mode tools based on current mode
by @ruomengz in
[#24378](https://github.com/google-gemini/gemini-cli/pull/24378)
- feat(core): prioritize discussion before formal plan approval by @jerop in
[#24423](https://github.com/google-gemini/gemini-cli/pull/24423)
- fix(ui): add accelerated scrolling on alternate buffer mode by @devr0306 in
[#23940](https://github.com/google-gemini/gemini-cli/pull/23940)
- feat(core): populate sandbox forbidden paths with project ignore file contents
by @ehedlund in
[#24038](https://github.com/google-gemini/gemini-cli/pull/24038)
- fix(core): ensure blue border overlay and input blocker to act correctly
depending on browser agent activities by @cynthialong0-0 in
[#24385](https://github.com/google-gemini/gemini-cli/pull/24385)
- fix(ui): removed additional vertical padding for tables by @devr0306 in
[#24381](https://github.com/google-gemini/gemini-cli/pull/24381)
- fix(build): upload full bundle directory archive to GitHub releases by
@sehoon38 in [#24403](https://github.com/google-gemini/gemini-cli/pull/24403)
- fix(build): wire bundle:browser-mcp into bundle pipeline by @gsquared94 in
[#24424](https://github.com/google-gemini/gemini-cli/pull/24424)
- feat(browser): add sandbox-aware browser agent initialization by @gsquared94
in [#24419](https://github.com/google-gemini/gemini-cli/pull/24419)
- feat(core): enhance tracker task schemas for detailed titles and descriptions
by @anj-s in [#23902](https://github.com/google-gemini/gemini-cli/pull/23902)
- refactor(core): Unified context management settings schema by @joshualitt in
[#24391](https://github.com/google-gemini/gemini-cli/pull/24391)
- feat(core): update browser agent prompt to check open pages first when
bringing up by @cynthialong0-0 in
[#24431](https://github.com/google-gemini/gemini-cli/pull/24431)
- fix(acp) refactor(core,cli): centralize model discovery logic in
ModelConfigService by @sripasg in
[#24392](https://github.com/google-gemini/gemini-cli/pull/24392)
- Changelog for v0.36.0-preview.7 by @gemini-cli-robot in
[#24346](https://github.com/google-gemini/gemini-cli/pull/24346)
- fix: update task tracker storage location in system prompt by @anj-s in
[#24034](https://github.com/google-gemini/gemini-cli/pull/24034)
- feat(browser): supersede stale snapshots to reclaim context-window tokens by
[#24437](https://github.com/google-gemini/gemini-cli/pull/24437)
- feat(policy): explicitly allow web_fetch in plan mode with ask_user by
@Adib234 in [#24456](https://github.com/google-gemini/gemini-cli/pull/24456)
- fix(core): refactor linux sandbox to fix ARG_MAX crashes by @ehedlund in
[#24286](https://github.com/google-gemini/gemini-cli/pull/24286)
- feat(config): add experimental.adk.agentSessionNoninteractiveEnabled setting
by @adamfweidman in
[#24439](https://github.com/google-gemini/gemini-cli/pull/24439)
- Changelog for v0.36.0-preview.8 by @gemini-cli-robot in
[#24453](https://github.com/google-gemini/gemini-cli/pull/24453)
- feat(cli): change default loadingPhrases to 'off' to hide tips by @keithguerin
in [#24342](https://github.com/google-gemini/gemini-cli/pull/24342)
- fix(cli): ensure agent stops when all declinable tools are cancelled by
@NTaylorMullen in
[#24479](https://github.com/google-gemini/gemini-cli/pull/24479)
- fix(core): enhance sandbox usability and fix build error by @galz10 in
[#24460](https://github.com/google-gemini/gemini-cli/pull/24460)
- Terminal Serializer Optimization by @jacob314 in
[#24485](https://github.com/google-gemini/gemini-cli/pull/24485)
- Auto configure memory. by @jacob314 in
[#24474](https://github.com/google-gemini/gemini-cli/pull/24474)
- Unused error variables in catch block are not allowed by @alisa-alisa in
[#24487](https://github.com/google-gemini/gemini-cli/pull/24487)
- feat(core): add background memory service for skill extraction by @SandyTao520
in [#24274](https://github.com/google-gemini/gemini-cli/pull/24274)
- feat: implement high-signal PR regression check for evaluations by
@alisa-alisa in
[#23937](https://github.com/google-gemini/gemini-cli/pull/23937)
- Fix shell output display by @jacob314 in
[#24490](https://github.com/google-gemini/gemini-cli/pull/24490)
- fix(ui): resolve unwanted vertical spacing around various tool output
treatments by @jwhelangoog in
[#24449](https://github.com/google-gemini/gemini-cli/pull/24449)
- revert(cli): bring back input box and footer visibility in copy mode by
@sehoon38 in [#24504](https://github.com/google-gemini/gemini-cli/pull/24504)
- fix(cli): prevent crash in AnsiOutputText when handling non-array data by
@sehoon38 in [#24498](https://github.com/google-gemini/gemini-cli/pull/24498)
- feat(cli): support default values for environment variables by @ruomengz in
[#24469](https://github.com/google-gemini/gemini-cli/pull/24469)
- Implement background process monitoring and inspection tools by @cocosheng-g
in [#23799](https://github.com/google-gemini/gemini-cli/pull/23799)
- docs(browser-agent): update stale browser agent documentation by @gsquared94
in [#24463](https://github.com/google-gemini/gemini-cli/pull/24463)
- fix: enable browser_agent in integration tests and add localhost fixture tests
by @gsquared94 in
[#24523](https://github.com/google-gemini/gemini-cli/pull/24523)
- fix(browser): handle computer-use model detection for analyze_screenshot by
@gsquared94 in
[#24440](https://github.com/google-gemini/gemini-cli/pull/24440)
- docs(core): add subagent tool isolation draft doc by @akh64bit in
[#23275](https://github.com/google-gemini/gemini-cli/pull/23275)
- fix(patch): cherry-pick 64c928f to release/v0.37.0-preview.0-pr-23257 to patch
version v0.37.0-preview.0 and create version 0.37.0-preview.1 by
@gemini-cli-robot in
[#24561](https://github.com/google-gemini/gemini-cli/pull/24561)
- fix(patch): cherry-pick cb7f7d6 to release/v0.37.0-preview.1-pr-24342 to patch
version v0.37.0-preview.1 and create version 0.37.0-preview.2 by
@gemini-cli-robot in
[#24842](https://github.com/google-gemini/gemini-cli/pull/24842)
[#24502](https://github.com/google-gemini/gemini-cli/pull/24502)
- feat(core): Land ContextCompressionService by @joshualitt in
[#24483](https://github.com/google-gemini/gemini-cli/pull/24483)
- feat(core): scope subagent workspace directories via AsyncLocalStorage by
@SandyTao520 in
[#24445](https://github.com/google-gemini/gemini-cli/pull/24445)
- Update ink version to 6.6.7 by @jacob314 in
[#24514](https://github.com/google-gemini/gemini-cli/pull/24514)
- fix(acp): handle all InvalidStreamError types gracefully in prompt by @sripasg
in [#24540](https://github.com/google-gemini/gemini-cli/pull/24540)
- Fix crash when vim editor is not found in PATH on Windows by
@Nagajyothi-tammisetti in
[#22423](https://github.com/google-gemini/gemini-cli/pull/22423)
- fix(core): move project memory dir under tmp directory by @SandyTao520 in
[#24542](https://github.com/google-gemini/gemini-cli/pull/24542)
- Enable 'Other' option for yesno question type by @ruomengz in
[#24545](https://github.com/google-gemini/gemini-cli/pull/24545)
- fix(cli): clear stale retry/loading state after cancellation (#21096) by
@Aaxhirrr in [#21960](https://github.com/google-gemini/gemini-cli/pull/21960)
- Changelog for v0.37.0-preview.0 by @gemini-cli-robot in
[#24464](https://github.com/google-gemini/gemini-cli/pull/24464)
- feat(core): implement context-aware persistent policy approvals by @jerop in
[#23257](https://github.com/google-gemini/gemini-cli/pull/23257)
- docs: move agent disabling instructions and update remote agent status by
@jackwotherspoon in
[#24559](https://github.com/google-gemini/gemini-cli/pull/24559)
- feat(cli): migrate nonInteractiveCli to LegacyAgentSession by @adamfweidman in
[#22987](https://github.com/google-gemini/gemini-cli/pull/22987)
- fix(core): unsafe type assertions in Core File System #19712 by
@aniketsaurav18 in
[#19739](https://github.com/google-gemini/gemini-cli/pull/19739)
- fix(ui): hide model quota in /stats and refactor quota display by @danzaharia1
in [#24206](https://github.com/google-gemini/gemini-cli/pull/24206)
- Changelog for v0.36.0 by @gemini-cli-robot in
[#24558](https://github.com/google-gemini/gemini-cli/pull/24558)
- Changelog for v0.37.0-preview.1 by @gemini-cli-robot in
[#24568](https://github.com/google-gemini/gemini-cli/pull/24568)
- docs: add missing .md extensions to internal doc links by @ishaan-arora-1 in
[#24145](https://github.com/google-gemini/gemini-cli/pull/24145)
- fix(ui): fixed table styling by @devr0306 in
[#24565](https://github.com/google-gemini/gemini-cli/pull/24565)
- fix(core): pass includeDirectories to sandbox configuration by @galz10 in
[#24573](https://github.com/google-gemini/gemini-cli/pull/24573)
- feat(ui): enable "TerminalBuffer" mode to solve flicker by @jacob314 in
[#24512](https://github.com/google-gemini/gemini-cli/pull/24512)
- docs: clarify release coordination by @scidomino in
[#24575](https://github.com/google-gemini/gemini-cli/pull/24575)
- fix(core): remove broken PowerShell translation and fix native \_\_write in
Windows sandbox by @scidomino in
[#24571](https://github.com/google-gemini/gemini-cli/pull/24571)
- Add instructions for how to start react in prod and force react to prod mode
by @jacob314 in
[#24590](https://github.com/google-gemini/gemini-cli/pull/24590)
- feat(cli): minimalist sandbox status labels by @galz10 in
[#24582](https://github.com/google-gemini/gemini-cli/pull/24582)
- Feat/browser agent metrics by @kunal-10-cloud in
[#24210](https://github.com/google-gemini/gemini-cli/pull/24210)
- test: fix Windows CI execution and resolve exposed platform failures by
@ehedlund in [#24476](https://github.com/google-gemini/gemini-cli/pull/24476)
- feat(core,cli): prioritize summary for topics (#24608) by @Abhijit-2592 in
[#24609](https://github.com/google-gemini/gemini-cli/pull/24609)
- show color by @jacob314 in
[#24613](https://github.com/google-gemini/gemini-cli/pull/24613)
- feat(cli): enable compact tool output by default (#24509) by @jwhelangoog in
[#24510](https://github.com/google-gemini/gemini-cli/pull/24510)
- fix(core): inject skill system instructions into subagent prompts if activated
by @abhipatel12 in
[#24620](https://github.com/google-gemini/gemini-cli/pull/24620)
- fix(core): improve windows sandbox reliability and fix integration tests by
@ehedlund in [#24480](https://github.com/google-gemini/gemini-cli/pull/24480)
- fix(core): ensure sandbox approvals are correctly persisted and matched for
proactive expansions by @galz10 in
[#24577](https://github.com/google-gemini/gemini-cli/pull/24577)
- feat(cli) Scrollbar for input prompt by @jacob314 in
[#21992](https://github.com/google-gemini/gemini-cli/pull/21992)
- Do not run pr-eval workflow when no steering changes detected by @alisa-alisa
in [#24621](https://github.com/google-gemini/gemini-cli/pull/24621)
- Fix restoration of topic headers. by @gundermanc in
[#24650](https://github.com/google-gemini/gemini-cli/pull/24650)
- feat(core): discourage update topic tool for simple tasks by @Samee24 in
[#24640](https://github.com/google-gemini/gemini-cli/pull/24640)
- fix(core): ensure global temp directory is always in sandbox allowed paths by
@galz10 in [#24638](https://github.com/google-gemini/gemini-cli/pull/24638)
- fix(core): detect uninitialized lines by @jacob314 in
[#24646](https://github.com/google-gemini/gemini-cli/pull/24646)
- docs: update sandboxing documentation and toolSandboxing settings by @galz10
in [#24655](https://github.com/google-gemini/gemini-cli/pull/24655)
- feat(cli): enhance tool confirmation UI and selection layout by @galz10 in
[#24376](https://github.com/google-gemini/gemini-cli/pull/24376)
- feat(acp): add support for `/about` command by @sripasg in
[#24649](https://github.com/google-gemini/gemini-cli/pull/24649)
- feat(cli): add role specific metrics to /stats by @cynthialong0-0 in
[#24659](https://github.com/google-gemini/gemini-cli/pull/24659)
- split context by @jacob314 in
[#24623](https://github.com/google-gemini/gemini-cli/pull/24623)
- fix(cli): remove -S from shebang to fix Windows and BSD execution by
@scidomino in [#24756](https://github.com/google-gemini/gemini-cli/pull/24756)
- Fix issue where topic headers can be posted back to back by @gundermanc in
[#24759](https://github.com/google-gemini/gemini-cli/pull/24759)
- fix(core): handle partial llm_request in BeforeModel hook override by
@krishdef7 in [#22326](https://github.com/google-gemini/gemini-cli/pull/22326)
- fix(ui): improve narration suppression and reduce flicker by @gundermanc in
[#24635](https://github.com/google-gemini/gemini-cli/pull/24635)
- fix(ui): fixed auth race condition causing logo to flicker by @devr0306 in
[#24652](https://github.com/google-gemini/gemini-cli/pull/24652)
- fix(browser): remove premature browser cleanup after subagent invocation by
@gsquared94 in
[#24753](https://github.com/google-gemini/gemini-cli/pull/24753)
- Revert "feat(core,cli): prioritize summary for topics (#24608)" by
@Abhijit-2592 in
[#24777](https://github.com/google-gemini/gemini-cli/pull/24777)
- relax tool sandboxing overrides for plan mode to match defaults. by
@DavidAPierce in
[#24762](https://github.com/google-gemini/gemini-cli/pull/24762)
- fix(cli): respect global environment variable allowlist by @scidomino in
[#24767](https://github.com/google-gemini/gemini-cli/pull/24767)
- fix(cli): ensure skills list outputs to stdout in non-interactive environments
by @spencer426 in
[#24566](https://github.com/google-gemini/gemini-cli/pull/24566)
- Add an eval for and fix unsafe cloning behavior. by @gundermanc in
[#24457](https://github.com/google-gemini/gemini-cli/pull/24457)
- fix(policy): allow complete_task in plan mode by @abhipatel12 in
[#24771](https://github.com/google-gemini/gemini-cli/pull/24771)
- feat(telemetry): add browser agent clearcut metrics by @gsquared94 in
[#24688](https://github.com/google-gemini/gemini-cli/pull/24688)
- feat(cli): support selective topic expansion and click-to-expand by
@Abhijit-2592 in
[#24793](https://github.com/google-gemini/gemini-cli/pull/24793)
- temporarily disable sandbox integration test on windows by @ehedlund in
[#24786](https://github.com/google-gemini/gemini-cli/pull/24786)
- Remove flakey test by @scidomino in
[#24837](https://github.com/google-gemini/gemini-cli/pull/24837)
- Alisa/approve button by @alisa-alisa in
[#24645](https://github.com/google-gemini/gemini-cli/pull/24645)
- feat(hooks): display hook system messages in UI by @mbleigh in
[#24616](https://github.com/google-gemini/gemini-cli/pull/24616)
- fix(core): propagate BeforeModel hook model override end-to-end by @krishdef7
in [#24784](https://github.com/google-gemini/gemini-cli/pull/24784)
- chore: fix formatting for behavioral eval skill reference file by @abhipatel12
in [#24846](https://github.com/google-gemini/gemini-cli/pull/24846)
- fix: use directory junctions on Windows for skill linking by @enjoykumawat in
[#24823](https://github.com/google-gemini/gemini-cli/pull/24823)
- fix(cli): prevent multiple banner increments on remount by @sehoon38 in
[#24843](https://github.com/google-gemini/gemini-cli/pull/24843)
- feat(acp): add /help command by @sripasg in
[#24839](https://github.com/google-gemini/gemini-cli/pull/24839)
- fix(core): remove tmux alternate buffer warning by @jackwotherspoon in
[#24852](https://github.com/google-gemini/gemini-cli/pull/24852)
- Improve sandbox error matching and caching by @DavidAPierce in
[#24550](https://github.com/google-gemini/gemini-cli/pull/24550)
- feat(core): add agent protocol UI types and experimental flag by @mbleigh in
[#24275](https://github.com/google-gemini/gemini-cli/pull/24275)
- feat(core): use experiment flags for default fetch timeouts by @yunaseoul in
[#24261](https://github.com/google-gemini/gemini-cli/pull/24261)
- Revert "fix(ui): improve narration suppression and reduce flicker (#2… by
@gundermanc in
[#24857](https://github.com/google-gemini/gemini-cli/pull/24857)
- refactor(cli): remove duplication in interactive shell awaiting input hint by
@JayadityaGit in
[#24801](https://github.com/google-gemini/gemini-cli/pull/24801)
- refactor(core): make LegacyAgentSession dependencies optional by @mbleigh in
[#24287](https://github.com/google-gemini/gemini-cli/pull/24287)
- Changelog for v0.37.0-preview.2 by @gemini-cli-robot in
[#24848](https://github.com/google-gemini/gemini-cli/pull/24848)
- fix(cli): always show shell command description or actual command by @jacob314
in [#24774](https://github.com/google-gemini/gemini-cli/pull/24774)
- Added flag for ept size and increased default size by @devr0306 in
[#24859](https://github.com/google-gemini/gemini-cli/pull/24859)
- fix(core): dispose Scheduler to prevent McpProgress listener leak by
@Anjaligarhwal in
[#24870](https://github.com/google-gemini/gemini-cli/pull/24870)
- fix(cli): switch default back to terminalBuffer=false and fix regressions
introduced for that mode by @jacob314 in
[#24873](https://github.com/google-gemini/gemini-cli/pull/24873)
- feat(cli): switch to ctrl+g from ctrl-x by @jacob314 in
[#24861](https://github.com/google-gemini/gemini-cli/pull/24861)
- fix: isolate concurrent browser agent instances by @gsquared94 in
[#24794](https://github.com/google-gemini/gemini-cli/pull/24794)
- docs: update MCP server OAuth redirect port documentation by @adamfweidman in
[#24844](https://github.com/google-gemini/gemini-cli/pull/24844)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.36.0...v0.37.1
https://github.com/google-gemini/gemini-cli/compare/v0.38.0...v0.38.1
+236 -247
View File
@@ -1,6 +1,6 @@
# Preview release: v0.38.0-preview.0
# Preview release: v0.39.0-preview.0
Released: April 08, 2026
Released: April 14, 2026
Our preview release includes the latest, new, and experimental features. This
release may not be as stable as our [latest weekly release](latest.md).
@@ -13,256 +13,245 @@ npm install -g @google/gemini-cli@preview
## Highlights
- **Context Management:** Introduced a Context Compression Service to optimize
context window usage and landed a background memory service for skill
extraction.
- **Enhanced Security:** Implemented context-aware persistent policy approvals
for smarter tool permissions and enabled `web_fetch` in plan mode with user
confirmation.
- **Workflow Monitoring:** Added background process monitoring and inspection
tools for better visibility into long-running tasks.
- **UI/UX Refinements:** Enhanced the tool confirmation UI, selection layout,
and added support for selective topic expansion and click-to-expand.
- **Core Stability:** Improved sandbox reliability on Linux and Windows,
resolved shebang compatibility issues, and fixed various crashes in the CLI
and core services.
- **Refactored Subagents and Unified Tooling:** Consolidate subagent tools into
a single `invoke_subagent` tool, removed legacy wrapping tools, and improved
turn limits for codebase investigator.
- **Advanced Memory and Skill Management:** Introduced `/memory` inbox for
reviewing extracted skills and added skill patching support, enhancing agent
learning and persistence.
- **Expanded Test and Evaluation Infrastructure:** Added memory and CPU
performance integration test harnesses and generalized evaluation
infrastructure for better suite organization.
- **Sandbox and Security Hardening:** Centralized sandbox paths for Linux and
macOS, enforced read-only security for async git worktree resolution, and
optimized Windows sandbox initialization.
- **Enhanced CLI UX and UI Stability:** Improved scroll momentum, added a
`debugRainbow` setting, and resolved various memory leaks and PTY exhaustion
issues for a smoother terminal experience.
## What's Changed
- fix(cli): refresh slash command list after /skills reload by @NTaylorMullen in
[#24454](https://github.com/google-gemini/gemini-cli/pull/24454)
- Update README.md for links. by @g-samroberts in
[#22759](https://github.com/google-gemini/gemini-cli/pull/22759)
- fix(core): ensure complete_task tool calls are recorded in chat history by
@abhipatel12 in
[#24437](https://github.com/google-gemini/gemini-cli/pull/24437)
- feat(policy): explicitly allow web_fetch in plan mode with ask_user by
@Adib234 in [#24456](https://github.com/google-gemini/gemini-cli/pull/24456)
- fix(core): refactor linux sandbox to fix ARG_MAX crashes by @ehedlund in
[#24286](https://github.com/google-gemini/gemini-cli/pull/24286)
- feat(config): add experimental.adk.agentSessionNoninteractiveEnabled setting
by @adamfweidman in
[#24439](https://github.com/google-gemini/gemini-cli/pull/24439)
- Changelog for v0.36.0-preview.8 by @gemini-cli-robot in
[#24453](https://github.com/google-gemini/gemini-cli/pull/24453)
- feat(cli): change default loadingPhrases to 'off' to hide tips by @keithguerin
in [#24342](https://github.com/google-gemini/gemini-cli/pull/24342)
- fix(cli): ensure agent stops when all declinable tools are cancelled by
@NTaylorMullen in
[#24479](https://github.com/google-gemini/gemini-cli/pull/24479)
- fix(core): enhance sandbox usability and fix build error by @galz10 in
[#24460](https://github.com/google-gemini/gemini-cli/pull/24460)
- Terminal Serializer Optimization by @jacob314 in
[#24485](https://github.com/google-gemini/gemini-cli/pull/24485)
- Auto configure memory. by @jacob314 in
[#24474](https://github.com/google-gemini/gemini-cli/pull/24474)
- Unused error variables in catch block are not allowed by @alisa-alisa in
[#24487](https://github.com/google-gemini/gemini-cli/pull/24487)
- feat(core): add background memory service for skill extraction by @SandyTao520
in [#24274](https://github.com/google-gemini/gemini-cli/pull/24274)
- feat: implement high-signal PR regression check for evaluations by
@alisa-alisa in
[#23937](https://github.com/google-gemini/gemini-cli/pull/23937)
- Fix shell output display by @jacob314 in
[#24490](https://github.com/google-gemini/gemini-cli/pull/24490)
- fix(ui): resolve unwanted vertical spacing around various tool output
treatments by @jwhelangoog in
[#24449](https://github.com/google-gemini/gemini-cli/pull/24449)
- revert(cli): bring back input box and footer visibility in copy mode by
@sehoon38 in [#24504](https://github.com/google-gemini/gemini-cli/pull/24504)
- fix(cli): prevent crash in AnsiOutputText when handling non-array data by
@sehoon38 in [#24498](https://github.com/google-gemini/gemini-cli/pull/24498)
- feat(cli): support default values for environment variables by @ruomengz in
[#24469](https://github.com/google-gemini/gemini-cli/pull/24469)
- Implement background process monitoring and inspection tools by @cocosheng-g
in [#23799](https://github.com/google-gemini/gemini-cli/pull/23799)
- docs(browser-agent): update stale browser agent documentation by @gsquared94
in [#24463](https://github.com/google-gemini/gemini-cli/pull/24463)
- fix: enable browser_agent in integration tests and add localhost fixture tests
by @gsquared94 in
[#24523](https://github.com/google-gemini/gemini-cli/pull/24523)
- fix(browser): handle computer-use model detection for analyze_screenshot by
@gsquared94 in
[#24502](https://github.com/google-gemini/gemini-cli/pull/24502)
- feat(core): Land ContextCompressionService by @joshualitt in
[#24483](https://github.com/google-gemini/gemini-cli/pull/24483)
- feat(core): scope subagent workspace directories via AsyncLocalStorage by
- refactor(plan): simplify policy priorities and consolidate read-only rules by
@ruomengz in [#24849](https://github.com/google-gemini/gemini-cli/pull/24849)
- feat(test-utils): add memory usage integration test harness by @sripasg in
[#24876](https://github.com/google-gemini/gemini-cli/pull/24876)
- feat(memory): add /memory inbox command for reviewing extracted skills by
@SandyTao520 in
[#24445](https://github.com/google-gemini/gemini-cli/pull/24445)
- Update ink version to 6.6.7 by @jacob314 in
[#24514](https://github.com/google-gemini/gemini-cli/pull/24514)
- fix(acp): handle all InvalidStreamError types gracefully in prompt by @sripasg
in [#24540](https://github.com/google-gemini/gemini-cli/pull/24540)
- Fix crash when vim editor is not found in PATH on Windows by
@Nagajyothi-tammisetti in
[#22423](https://github.com/google-gemini/gemini-cli/pull/22423)
- fix(core): move project memory dir under tmp directory by @SandyTao520 in
[#24542](https://github.com/google-gemini/gemini-cli/pull/24542)
- Enable 'Other' option for yesno question type by @ruomengz in
[#24545](https://github.com/google-gemini/gemini-cli/pull/24545)
- fix(cli): clear stale retry/loading state after cancellation (#21096) by
@Aaxhirrr in [#21960](https://github.com/google-gemini/gemini-cli/pull/21960)
- Changelog for v0.37.0-preview.0 by @gemini-cli-robot in
[#24464](https://github.com/google-gemini/gemini-cli/pull/24464)
- feat(core): implement context-aware persistent policy approvals by @jerop in
[#23257](https://github.com/google-gemini/gemini-cli/pull/23257)
- docs: move agent disabling instructions and update remote agent status by
@jackwotherspoon in
[#24559](https://github.com/google-gemini/gemini-cli/pull/24559)
- feat(cli): migrate nonInteractiveCli to LegacyAgentSession by @adamfweidman in
[#22987](https://github.com/google-gemini/gemini-cli/pull/22987)
- fix(core): unsafe type assertions in Core File System #19712 by
@aniketsaurav18 in
[#19739](https://github.com/google-gemini/gemini-cli/pull/19739)
- fix(ui): hide model quota in /stats and refactor quota display by @danzaharia1
in [#24206](https://github.com/google-gemini/gemini-cli/pull/24206)
- Changelog for v0.36.0 by @gemini-cli-robot in
[#24558](https://github.com/google-gemini/gemini-cli/pull/24558)
- Changelog for v0.37.0-preview.1 by @gemini-cli-robot in
[#24568](https://github.com/google-gemini/gemini-cli/pull/24568)
- docs: add missing .md extensions to internal doc links by @ishaan-arora-1 in
[#24145](https://github.com/google-gemini/gemini-cli/pull/24145)
- fix(ui): fixed table styling by @devr0306 in
[#24565](https://github.com/google-gemini/gemini-cli/pull/24565)
- fix(core): pass includeDirectories to sandbox configuration by @galz10 in
[#24573](https://github.com/google-gemini/gemini-cli/pull/24573)
- feat(ui): enable "TerminalBuffer" mode to solve flicker by @jacob314 in
[#24512](https://github.com/google-gemini/gemini-cli/pull/24512)
- docs: clarify release coordination by @scidomino in
[#24575](https://github.com/google-gemini/gemini-cli/pull/24575)
- fix(core): remove broken PowerShell translation and fix native \_\_write in
Windows sandbox by @scidomino in
[#24571](https://github.com/google-gemini/gemini-cli/pull/24571)
- Add instructions for how to start react in prod and force react to prod mode
by @jacob314 in
[#24590](https://github.com/google-gemini/gemini-cli/pull/24590)
- feat(cli): minimalist sandbox status labels by @galz10 in
[#24582](https://github.com/google-gemini/gemini-cli/pull/24582)
- Feat/browser agent metrics by @kunal-10-cloud in
[#24210](https://github.com/google-gemini/gemini-cli/pull/24210)
- test: fix Windows CI execution and resolve exposed platform failures by
@ehedlund in [#24476](https://github.com/google-gemini/gemini-cli/pull/24476)
- feat(core,cli): prioritize summary for topics (#24608) by @Abhijit-2592 in
[#24609](https://github.com/google-gemini/gemini-cli/pull/24609)
- show color by @jacob314 in
[#24613](https://github.com/google-gemini/gemini-cli/pull/24613)
- feat(cli): enable compact tool output by default (#24509) by @jwhelangoog in
[#24510](https://github.com/google-gemini/gemini-cli/pull/24510)
- fix(core): inject skill system instructions into subagent prompts if activated
by @abhipatel12 in
[#24620](https://github.com/google-gemini/gemini-cli/pull/24620)
- fix(core): improve windows sandbox reliability and fix integration tests by
@ehedlund in [#24480](https://github.com/google-gemini/gemini-cli/pull/24480)
- fix(core): ensure sandbox approvals are correctly persisted and matched for
proactive expansions by @galz10 in
[#24577](https://github.com/google-gemini/gemini-cli/pull/24577)
- feat(cli) Scrollbar for input prompt by @jacob314 in
[#21992](https://github.com/google-gemini/gemini-cli/pull/21992)
- Do not run pr-eval workflow when no steering changes detected by @alisa-alisa
in [#24621](https://github.com/google-gemini/gemini-cli/pull/24621)
- Fix restoration of topic headers. by @gundermanc in
[#24650](https://github.com/google-gemini/gemini-cli/pull/24650)
- feat(core): discourage update topic tool for simple tasks by @Samee24 in
[#24640](https://github.com/google-gemini/gemini-cli/pull/24640)
- fix(core): ensure global temp directory is always in sandbox allowed paths by
@galz10 in [#24638](https://github.com/google-gemini/gemini-cli/pull/24638)
- fix(core): detect uninitialized lines by @jacob314 in
[#24646](https://github.com/google-gemini/gemini-cli/pull/24646)
- docs: update sandboxing documentation and toolSandboxing settings by @galz10
in [#24655](https://github.com/google-gemini/gemini-cli/pull/24655)
- feat(cli): enhance tool confirmation UI and selection layout by @galz10 in
[#24376](https://github.com/google-gemini/gemini-cli/pull/24376)
- feat(acp): add support for `/about` command by @sripasg in
[#24649](https://github.com/google-gemini/gemini-cli/pull/24649)
- feat(cli): add role specific metrics to /stats by @cynthialong0-0 in
[#24659](https://github.com/google-gemini/gemini-cli/pull/24659)
- split context by @jacob314 in
[#24623](https://github.com/google-gemini/gemini-cli/pull/24623)
- fix(cli): remove -S from shebang to fix Windows and BSD execution by
@scidomino in [#24756](https://github.com/google-gemini/gemini-cli/pull/24756)
- Fix issue where topic headers can be posted back to back by @gundermanc in
[#24759](https://github.com/google-gemini/gemini-cli/pull/24759)
- fix(core): handle partial llm_request in BeforeModel hook override by
@krishdef7 in [#22326](https://github.com/google-gemini/gemini-cli/pull/22326)
- fix(ui): improve narration suppression and reduce flicker by @gundermanc in
[#24635](https://github.com/google-gemini/gemini-cli/pull/24635)
- fix(ui): fixed auth race condition causing logo to flicker by @devr0306 in
[#24652](https://github.com/google-gemini/gemini-cli/pull/24652)
- fix(browser): remove premature browser cleanup after subagent invocation by
@gsquared94 in
[#24753](https://github.com/google-gemini/gemini-cli/pull/24753)
- Revert "feat(core,cli): prioritize summary for topics (#24608)" by
@Abhijit-2592 in
[#24777](https://github.com/google-gemini/gemini-cli/pull/24777)
- relax tool sandboxing overrides for plan mode to match defaults. by
@DavidAPierce in
[#24762](https://github.com/google-gemini/gemini-cli/pull/24762)
- fix(cli): respect global environment variable allowlist by @scidomino in
[#24767](https://github.com/google-gemini/gemini-cli/pull/24767)
- fix(cli): ensure skills list outputs to stdout in non-interactive environments
[#24544](https://github.com/google-gemini/gemini-cli/pull/24544)
- chore(release): bump version to 0.39.0-nightly.20260408.e77b22e63 by
@gemini-cli-robot in
[#24939](https://github.com/google-gemini/gemini-cli/pull/24939)
- fix(core): ensure robust sandbox cleanup in all process execution paths by
@ehedlund in [#24763](https://github.com/google-gemini/gemini-cli/pull/24763)
- chore: update ink version to 6.6.8 by @jacob314 in
[#24934](https://github.com/google-gemini/gemini-cli/pull/24934)
- Changelog for v0.38.0-preview.0 by @gemini-cli-robot in
[#24938](https://github.com/google-gemini/gemini-cli/pull/24938)
- chore: ignore conductor directory by @JayadityaGit in
[#22128](https://github.com/google-gemini/gemini-cli/pull/22128)
- Changelog for v0.37.0 by @gemini-cli-robot in
[#24940](https://github.com/google-gemini/gemini-cli/pull/24940)
- feat(plan): require user confirmation for activate_skill in Plan Mode by
@ruomengz in [#24946](https://github.com/google-gemini/gemini-cli/pull/24946)
- feat(test-utils): add CPU performance integration test harness by @sripasg in
[#24951](https://github.com/google-gemini/gemini-cli/pull/24951)
- fix(cli-ui): enable Ctrl+Backspace for word deletion in Windows Terminal by
@dogukanozen in
[#21447](https://github.com/google-gemini/gemini-cli/pull/21447)
- test(sdk): add unit tests for GeminiCliSession by @AdamyaSingh7 in
[#21897](https://github.com/google-gemini/gemini-cli/pull/21897)
- fix(core): resolve windows symlink bypass and stabilize sandbox integration
tests by @ehedlund in
[#24834](https://github.com/google-gemini/gemini-cli/pull/24834)
- fix(cli): restore file path display in edit and write tool confirmations by
@jwhelangoog in
[#24974](https://github.com/google-gemini/gemini-cli/pull/24974)
- feat(core): refine shell tool description display logic by @jwhelangoog in
[#24903](https://github.com/google-gemini/gemini-cli/pull/24903)
- fix(core): dynamic session ID injection to resolve resume bugs by @scidomino
in [#24972](https://github.com/google-gemini/gemini-cli/pull/24972)
- Update ink version to 6.6.9 by @jacob314 in
[#24980](https://github.com/google-gemini/gemini-cli/pull/24980)
- Generalize evals infra to support more types of evals, organization and
queuing of named suites by @gundermanc in
[#24941](https://github.com/google-gemini/gemini-cli/pull/24941)
- fix(cli): optimize startup with lightweight parent process by @sehoon38 in
[#24667](https://github.com/google-gemini/gemini-cli/pull/24667)
- refactor(sandbox): use centralized sandbox paths in macOS Seatbelt
implementation by @ehedlund in
[#24984](https://github.com/google-gemini/gemini-cli/pull/24984)
- feat(cli): refine tool output formatting for compact mode by @jwhelangoog in
[#24677](https://github.com/google-gemini/gemini-cli/pull/24677)
- fix(sdk): skip broken sendStream tests to unblock nightly by @SandyTao520 in
[#25000](https://github.com/google-gemini/gemini-cli/pull/25000)
- refactor(core): use centralized path resolution for Linux sandbox by @ehedlund
in [#24985](https://github.com/google-gemini/gemini-cli/pull/24985)
- Support ctrl+shift+g by @jacob314 in
[#25035](https://github.com/google-gemini/gemini-cli/pull/25035)
- feat(core): refactor subagent tool to unified invoke_subagent tool by
@abhipatel12 in
[#24489](https://github.com/google-gemini/gemini-cli/pull/24489)
- fix(core): add explicit git identity env vars to prevent sandbox checkpointing
error by @mrpmohiburrahman in
[#19775](https://github.com/google-gemini/gemini-cli/pull/19775)
- fix: respect hideContextPercentage when FooterConfigDialog is closed without
changes by @chernistry in
[#24773](https://github.com/google-gemini/gemini-cli/pull/24773)
- fix(cli): suppress unhandled AbortError logs during request cancellation by
@euxaristia in
[#22621](https://github.com/google-gemini/gemini-cli/pull/22621)
- Automated documentation audit by @g-samroberts in
[#24567](https://github.com/google-gemini/gemini-cli/pull/24567)
- feat(cli): implement useAgentStream hook by @mbleigh in
[#24292](https://github.com/google-gemini/gemini-cli/pull/24292)
- refactor(plan) Clean default plan toml by @ruomengz in
[#25037](https://github.com/google-gemini/gemini-cli/pull/25037)
- refactor(core): remove legacy subagent wrapping tools by @abhipatel12 in
[#25053](https://github.com/google-gemini/gemini-cli/pull/25053)
- fix(core): honor retryDelay in RetryInfo for 503 errors by @yunaseoul in
[#25057](https://github.com/google-gemini/gemini-cli/pull/25057)
- fix(core): remediate subagent memory leaks using AbortSignal in MessageBus by
@abhipatel12 in
[#25048](https://github.com/google-gemini/gemini-cli/pull/25048)
- feat(cli): wire up useAgentStream in AppContainer by @mbleigh in
[#24297](https://github.com/google-gemini/gemini-cli/pull/24297)
- feat(core): migrate chat recording to JSONL streaming by @spencer426 in
[#23749](https://github.com/google-gemini/gemini-cli/pull/23749)
- fix(core): clear 5-minute timeouts in oauth flow to prevent memory leaks by
@spencer426 in
[#24968](https://github.com/google-gemini/gemini-cli/pull/24968)
- fix(sandbox): centralize async git worktree resolution and enforce read-only
security by @ehedlund in
[#25040](https://github.com/google-gemini/gemini-cli/pull/25040)
- feat(test): add high-volume shell test and refine perf harness by @sripasg in
[#24983](https://github.com/google-gemini/gemini-cli/pull/24983)
- fix(core): silently handle EPERM when listing dir structure by @scidomino in
[#25066](https://github.com/google-gemini/gemini-cli/pull/25066)
- Changelog for v0.37.1 by @gemini-cli-robot in
[#25055](https://github.com/google-gemini/gemini-cli/pull/25055)
- fix: decode Uint8Array and multi-byte UTF-8 in API error messages by
@kimjune01 in [#23341](https://github.com/google-gemini/gemini-cli/pull/23341)
- Automated documentation audit results by @g-samroberts in
[#22755](https://github.com/google-gemini/gemini-cli/pull/22755)
- debugging(ui): add optional debugRainbow setting by @jacob314 in
[#25088](https://github.com/google-gemini/gemini-cli/pull/25088)
- fix: resolve lifecycle memory leaks by cleaning up listeners and root closures
by @spencer426 in
[#24566](https://github.com/google-gemini/gemini-cli/pull/24566)
- Add an eval for and fix unsafe cloning behavior. by @gundermanc in
[#24457](https://github.com/google-gemini/gemini-cli/pull/24457)
- fix(policy): allow complete_task in plan mode by @abhipatel12 in
[#24771](https://github.com/google-gemini/gemini-cli/pull/24771)
- feat(telemetry): add browser agent clearcut metrics by @gsquared94 in
[#24688](https://github.com/google-gemini/gemini-cli/pull/24688)
- feat(cli): support selective topic expansion and click-to-expand by
@Abhijit-2592 in
[#24793](https://github.com/google-gemini/gemini-cli/pull/24793)
- temporarily disable sandbox integration test on windows by @ehedlund in
[#24786](https://github.com/google-gemini/gemini-cli/pull/24786)
- Remove flakey test by @scidomino in
[#24837](https://github.com/google-gemini/gemini-cli/pull/24837)
- Alisa/approve button by @alisa-alisa in
[#24645](https://github.com/google-gemini/gemini-cli/pull/24645)
- feat(hooks): display hook system messages in UI by @mbleigh in
[#24616](https://github.com/google-gemini/gemini-cli/pull/24616)
- fix(core): propagate BeforeModel hook model override end-to-end by @krishdef7
in [#24784](https://github.com/google-gemini/gemini-cli/pull/24784)
- chore: fix formatting for behavioral eval skill reference file by @abhipatel12
in [#24846](https://github.com/google-gemini/gemini-cli/pull/24846)
- fix: use directory junctions on Windows for skill linking by @enjoykumawat in
[#24823](https://github.com/google-gemini/gemini-cli/pull/24823)
- fix(cli): prevent multiple banner increments on remount by @sehoon38 in
[#24843](https://github.com/google-gemini/gemini-cli/pull/24843)
- feat(acp): add /help command by @sripasg in
[#24839](https://github.com/google-gemini/gemini-cli/pull/24839)
- fix(core): remove tmux alternate buffer warning by @jackwotherspoon in
[#24852](https://github.com/google-gemini/gemini-cli/pull/24852)
- Improve sandbox error matching and caching by @DavidAPierce in
[#24550](https://github.com/google-gemini/gemini-cli/pull/24550)
- feat(core): add agent protocol UI types and experimental flag by @mbleigh in
[#24275](https://github.com/google-gemini/gemini-cli/pull/24275)
- feat(core): use experiment flags for default fetch timeouts by @yunaseoul in
[#24261](https://github.com/google-gemini/gemini-cli/pull/24261)
- Revert "fix(ui): improve narration suppression and reduce flicker (#2… by
[#25049](https://github.com/google-gemini/gemini-cli/pull/25049)
- docs(cli): updates f12 description to be more precise by @JayadityaGit in
[#15816](https://github.com/google-gemini/gemini-cli/pull/15816)
- fix(cli): mark /settings as unsafe to run concurrently by @jacob314 in
[#25061](https://github.com/google-gemini/gemini-cli/pull/25061)
- fix(core): remove buffer slice to prevent OOM on large output streams by
@spencer426 in
[#25094](https://github.com/google-gemini/gemini-cli/pull/25094)
- feat(core): persist subagent agentId in tool call records by @abhipatel12 in
[#25092](https://github.com/google-gemini/gemini-cli/pull/25092)
- chore(core): increase codebase investigator turn limits to 50 by @abhipatel12
in [#25125](https://github.com/google-gemini/gemini-cli/pull/25125)
- refactor(core): consolidate execute() arguments into ExecuteOptions by
@mbleigh in [#25101](https://github.com/google-gemini/gemini-cli/pull/25101)
- feat(core): add Strategic Re-evaluation guidance to system prompt by
@aishaneeshah in
[#25062](https://github.com/google-gemini/gemini-cli/pull/25062)
- fix(core): preserve shell execution config fields on update by
@jasonmatthewsuhari in
[#25113](https://github.com/google-gemini/gemini-cli/pull/25113)
- docs: add vi shortcuts and clarify MCP sandbox setup by @chrisjcthomas in
[#21679](https://github.com/google-gemini/gemini-cli/pull/21679)
- fix(cli): pass session id to interactive shell executions by
@jasonmatthewsuhari in
[#25114](https://github.com/google-gemini/gemini-cli/pull/25114)
- fix(cli): resolve text sanitization data loss due to C1 control characters by
@euxaristia in
[#22624](https://github.com/google-gemini/gemini-cli/pull/22624)
- feat(core): add large memory regression test by @cynthialong0-0 in
[#25059](https://github.com/google-gemini/gemini-cli/pull/25059)
- fix(core): resolve PTY exhaustion and orphan MCP subprocess leaks by
@spencer426 in
[#25079](https://github.com/google-gemini/gemini-cli/pull/25079)
- chore(deps): update vulnerable dependencies via npm audit fix by @scidomino in
[#25140](https://github.com/google-gemini/gemini-cli/pull/25140)
- perf(sandbox): optimize Windows sandbox initialization via native ACL
application by @ehedlund in
[#25077](https://github.com/google-gemini/gemini-cli/pull/25077)
- chore: switch from keytar to @github/keytar by @cocosheng-g in
[#25143](https://github.com/google-gemini/gemini-cli/pull/25143)
- fix: improve audio MIME normalization and validation in file reads by
@junaiddshaukat in
[#21636](https://github.com/google-gemini/gemini-cli/pull/21636)
- docs: Update docs-audit to include changes in PR body by @g-samroberts in
[#25153](https://github.com/google-gemini/gemini-cli/pull/25153)
- docs: correct documentation for enforced authentication type by @cocosheng-g
in [#25142](https://github.com/google-gemini/gemini-cli/pull/25142)
- fix(cli): exclude update_topic from confirmation queue count by @Abhijit-2592
in [#24945](https://github.com/google-gemini/gemini-cli/pull/24945)
- Memory fix for trace's streamWrapper. by @anthraxmilkshake in
[#25089](https://github.com/google-gemini/gemini-cli/pull/25089)
- fix(core): fix quota footer for non-auto models and improve display by
@jackwotherspoon in
[#25121](https://github.com/google-gemini/gemini-cli/pull/25121)
- docs(contributing): clarify self-assignment policy for issues by @jmr in
[#23087](https://github.com/google-gemini/gemini-cli/pull/23087)
- feat(core): add skill patching support with /memory inbox integration by
@SandyTao520 in
[#25148](https://github.com/google-gemini/gemini-cli/pull/25148)
- Stop suppressing thoughts and text in model response by @gundermanc in
[#25073](https://github.com/google-gemini/gemini-cli/pull/25073)
- fix(release): prefix git hash in nightly versions to prevent semver
normalization by @SandyTao520 in
[#25304](https://github.com/google-gemini/gemini-cli/pull/25304)
- feat(cli): extract QuotaContext and resolve infinite render loop by @Adib234
in [#24959](https://github.com/google-gemini/gemini-cli/pull/24959)
- refactor(core): extract and centralize sandbox path utilities by @ehedlund in
[#25305](https://github.com/google-gemini/gemini-cli/pull/25305)
- feat(ui): added enhancements to scroll momentum by @devr0306 in
[#24447](https://github.com/google-gemini/gemini-cli/pull/24447)
- fix(core): replace custom binary detection with isbinaryfile to correctly
handle UTF-8 (U+FFFD) by @Anjaligarhwal in
[#25297](https://github.com/google-gemini/gemini-cli/pull/25297)
- feat(agent): implement tool-controlled display protocol (Steps 2-3) by
@mbleigh in [#25134](https://github.com/google-gemini/gemini-cli/pull/25134)
- Stop showing scrollbar unless we are in terminalBuffer mode by @jacob314 in
[#25320](https://github.com/google-gemini/gemini-cli/pull/25320)
- feat: support auth block in MCP servers config in agents by @TanmayVartak in
[#24770](https://github.com/google-gemini/gemini-cli/pull/24770)
- fix(core): expose GEMINI_PLANS_DIR to hook environment by @Adib234 in
[#25296](https://github.com/google-gemini/gemini-cli/pull/25296)
- feat(core): implement silent fallback for Plan Mode model routing by @jerop in
[#25317](https://github.com/google-gemini/gemini-cli/pull/25317)
- fix: correct redirect count increment in fetchJson by @KevinZhao in
[#24896](https://github.com/google-gemini/gemini-cli/pull/24896)
- fix(core): prevent secondary crash in ModelRouterService finally block by
@gundermanc in
[#24857](https://github.com/google-gemini/gemini-cli/pull/24857)
- refactor(cli): remove duplication in interactive shell awaiting input hint by
@JayadityaGit in
[#24801](https://github.com/google-gemini/gemini-cli/pull/24801)
- refactor(core): make LegacyAgentSession dependencies optional by @mbleigh in
[#24287](https://github.com/google-gemini/gemini-cli/pull/24287)
- Changelog for v0.37.0-preview.2 by @gemini-cli-robot in
[#24848](https://github.com/google-gemini/gemini-cli/pull/24848)
- fix(cli): always show shell command description or actual command by @jacob314
in [#24774](https://github.com/google-gemini/gemini-cli/pull/24774)
- Added flag for ept size and increased default size by @devr0306 in
[#24859](https://github.com/google-gemini/gemini-cli/pull/24859)
- fix(core): dispose Scheduler to prevent McpProgress listener leak by
@Anjaligarhwal in
[#24870](https://github.com/google-gemini/gemini-cli/pull/24870)
- fix(cli): switch default back to terminalBuffer=false and fix regressions
introduced for that mode by @jacob314 in
[#24873](https://github.com/google-gemini/gemini-cli/pull/24873)
- feat(cli): switch to ctrl+g from ctrl-x by @jacob314 in
[#24861](https://github.com/google-gemini/gemini-cli/pull/24861)
- fix: isolate concurrent browser agent instances by @gsquared94 in
[#24794](https://github.com/google-gemini/gemini-cli/pull/24794)
- docs: update MCP server OAuth redirect port documentation by @adamfweidman in
[#24844](https://github.com/google-gemini/gemini-cli/pull/24844)
[#25333](https://github.com/google-gemini/gemini-cli/pull/25333)
- feat(core): introduce decoupled ContextManager and Sidecar architecture by
@joshualitt in
[#24752](https://github.com/google-gemini/gemini-cli/pull/24752)
- docs(core): update generalist agent documentation by @abhipatel12 in
[#25325](https://github.com/google-gemini/gemini-cli/pull/25325)
- chore(mcp): check MCP error code over brittle string match by @jackwotherspoon
in [#25381](https://github.com/google-gemini/gemini-cli/pull/25381)
- feat(plan): update plan mode prompt to allow showing plan content by @ruomengz
in [#25058](https://github.com/google-gemini/gemini-cli/pull/25058)
- test(core): improve sandbox integration test coverage and fix OS-specific
failures by @ehedlund in
[#25307](https://github.com/google-gemini/gemini-cli/pull/25307)
- fix(core): use debug level for keychain fallback logging by @ehedlund in
[#25398](https://github.com/google-gemini/gemini-cli/pull/25398)
- feat(test): add a performance test in asian language by @cynthialong0-0 in
[#25392](https://github.com/google-gemini/gemini-cli/pull/25392)
- feat(cli): enable mouse clicking for cursor positioning in AskUser multi-line
answers by @Adib234 in
[#24630](https://github.com/google-gemini/gemini-cli/pull/24630)
- fix(core): detect kmscon terminal as supporting true color by @claygeo in
[#25282](https://github.com/google-gemini/gemini-cli/pull/25282)
- ci: add agent session drift check workflow by @adamfweidman in
[#25389](https://github.com/google-gemini/gemini-cli/pull/25389)
- use macos-latest-large runner where applicable. by @scidomino in
[#25413](https://github.com/google-gemini/gemini-cli/pull/25413)
- Changelog for v0.37.2 by @gemini-cli-robot in
[#25336](https://github.com/google-gemini/gemini-cli/pull/25336)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.37.0-preview.2...v0.38.0-preview.0
https://github.com/google-gemini/gemini-cli/compare/v0.38.0-preview.0...v0.39.0-preview.0
+10 -2
View File
@@ -327,8 +327,12 @@ Storage whenever Gemini CLI exits Plan Mode to start the implementation.
```bash
#!/usr/bin/env bash
# Extract the plan path from the tool input JSON
plan_path=$(jq -r '.tool_input.plan_path // empty')
# Extract the plan filename from the tool input JSON
plan_filename=$(jq -r '.tool_input.plan_filename // empty')
plan_filename=$(basename -- "$plan_filename")
# Construct the absolute path using the GEMINI_PLANS_DIR environment variable
plan_path="$GEMINI_PLANS_DIR/$plan_filename"
if [ -f "$plan_path" ]; then
# Generate a unique filename using a timestamp
@@ -441,6 +445,10 @@ on the current phase of your task:
switches to a high-speed **Flash** model. This provides a faster, more
responsive experience during the implementation of the plan.
If the high-reasoning model is unavailable or you don't have access to it,
Gemini CLI automatically and silently falls back to a faster model to ensure
your workflow isn't interrupted.
This behavior is enabled by default to provide the best balance of quality and
performance. You can disable this automatic switching in your settings:
+17 -5
View File
@@ -87,11 +87,23 @@ Gemini CLI comes with the following built-in subagents:
### Generalist Agent
- **Name:** `generalist_agent`
- **Purpose:** Route tasks to the appropriate specialized subagent.
- **When to use:** Implicitly used by the main agent for routing. Not directly
invoked by the user.
- **Configuration:** Enabled by default. No specific configuration options.
- **Name:** `generalist`
- **Purpose:** A general, all-purpose subagent that uses the inherited tool
access and configurations from the main agent. Useful for executing broad,
resource-heavy subtasks in an isolated conversation, optimizing your main
agent's context by returning only the final result of that given task.
- **When to use:** Use this agent when a task requires many steps, handles large
volumes of information, or requires the same full capabilities as the main
agent. It is ideal for:
- **Multi-file modifications:** Applying refactors or fixing errors across
several files at once.
- **High-volume execution:** Running commands or tests that produce extensive
terminal output.
- **Action-oriented research:** Investigations where the agent needs to both
search code and run commands or make edits to find a solution. By delegating
these tasks, you prevent your main conversation from becoming cluttered or
slow. You can invoke it explicitly using `@generalist`.
- **Configuration:** Enabled by default.
### Browser Agent (experimental)
+1
View File
@@ -138,6 +138,7 @@ multiple layers in the following order of precedence (highest to lowest):
Hooks are executed with a sanitized environment.
- `GEMINI_PROJECT_DIR`: The absolute path to the project root.
- `GEMINI_PLANS_DIR`: The absolute path to the plans directory.
- `GEMINI_SESSION_ID`: The unique ID for the current session.
- `GEMINI_CWD`: The current working directory.
- `CLAUDE_PROJECT_DIR`: (Alias) Provided for compatibility.
+15
View File
@@ -2148,6 +2148,21 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
- When set, overrides the default API version used by the SDK.
- Example: `export GOOGLE_GENAI_API_VERSION="v1"` (Windows PowerShell:
`$env:GOOGLE_GENAI_API_VERSION="v1"`)
- **`GOOGLE_GEMINI_BASE_URL`**:
- Overrides the default base URL for Gemini API requests (when using
`gemini-api-key` authentication).
- Must be a valid URL. For security, it must use HTTPS unless pointing to
`localhost` (or `127.0.0.1` / `[::1]`).
- Example: `export GOOGLE_GEMINI_BASE_URL="https://my-proxy.com"` (Windows
PowerShell: `$env:GOOGLE_GEMINI_BASE_URL="https://my-proxy.com"`)
- **`GOOGLE_VERTEX_BASE_URL`**:
- Overrides the default base URL for Vertex AI API requests (when using
`vertex-ai` authentication).
- Must be a valid URL. For security, it must use HTTPS unless pointing to
`localhost` (or `127.0.0.1` / `[::1]`).
- Example: `export GOOGLE_VERTEX_BASE_URL="https://my-vertex-proxy.com"`
(Windows PowerShell:
`$env:GOOGLE_VERTEX_BASE_URL="https://my-vertex-proxy.com"`)
- **`OTLP_GOOGLE_CLOUD_PROJECT`**:
- Your Google Cloud Project ID for Telemetry in Google Cloud
- Example: `export OTLP_GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"` (Windows
+11 -3
View File
@@ -17,9 +17,17 @@ describe('CliHelpAgent Delegation', () => {
timeout: 60000,
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs();
const toolCallIndex = toolLogs.findIndex(
(log) => log.toolRequest.name === 'cli_help',
);
const toolCallIndex = toolLogs.findIndex((log) => {
if (log.toolRequest.name === 'invoke_agent') {
try {
const args = JSON.parse(log.toolRequest.args);
return args.agent_name === 'cli_help';
} catch {
return false;
}
}
return false;
});
expect(toolCallIndex).toBeGreaterThan(-1);
expect(toolCallIndex).toBeLessThan(5); // Called within first 5 turns
},
+17 -1
View File
@@ -16,6 +16,7 @@ import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { randomUUID } from 'node:crypto';
import { vi } from 'vitest';
import {
Config,
type ConfigParameters,
@@ -52,6 +53,7 @@ export interface ComponentEvalCase extends BaseEvalCase {
export class ComponentRig {
public config: Config | undefined;
public testDir: string;
public homeDir: string;
public sessionId: string;
constructor(
@@ -61,6 +63,9 @@ export class ComponentRig {
this.testDir = fs.mkdtempSync(
path.join(os.tmpdir(), `gemini-component-rig-${uniqueId.slice(0, 8)}-`),
);
this.homeDir = fs.mkdtempSync(
path.join(os.tmpdir(), `gemini-component-home-${uniqueId.slice(0, 8)}-`),
);
this.sessionId = `test-session-${uniqueId}`;
}
@@ -89,12 +94,23 @@ export class ComponentRig {
this.config = makeFakeConfig(configParams);
await this.config.initialize();
// Refresh auth using USE_GEMINI to initialize the real BaseLlmClient
// Refresh auth using USE_GEMINI to initialize the real BaseLlmClient.
// This must happen BEFORE stubbing GEMINI_CLI_HOME because OAuth credential
// lookup resolves through homedir() → GEMINI_CLI_HOME.
await this.config.refreshAuth(AuthType.USE_GEMINI);
// Isolate storage paths (session files, skills, extraction state) by
// pointing GEMINI_CLI_HOME at a per-test temp directory. Storage resolves
// global paths through `homedir()` which reads this env var. This is set
// after auth so credential lookup uses the real home directory.
vi.stubEnv('GEMINI_CLI_HOME', this.homeDir);
}
async cleanup() {
await this.config?.dispose();
vi.unstubAllEnvs();
fs.rmSync(this.testDir, { recursive: true, force: true });
fs.rmSync(this.homeDir, { recursive: true, force: true });
}
}
+14 -3
View File
@@ -26,11 +26,22 @@ describe('generalist_agent', () => {
prompt:
'Please use the generalist agent to create a file called "generalist_test_file.txt" containing exactly the following text: success',
assert: async (rig) => {
// 1) Verify the generalist agent was invoked
const foundToolCall = await rig.waitForToolCall('generalist');
// 1) Verify the generalist agent was invoked via invoke_agent
const foundToolCall = await rig.waitForToolCall(
'invoke_agent',
undefined,
(args) => {
try {
const parsed = JSON.parse(args);
return parsed.agent_name === 'generalist';
} catch {
return false;
}
},
);
expect(
foundToolCall,
'Expected to find a tool call for generalist agent',
'Expected to find an invoke_agent tool call for generalist agent',
).toBeTruthy();
// 2) Verify the file was created as expected
+63 -31
View File
@@ -145,22 +145,30 @@ describe('save_memory', () => {
},
});
const ignoringDbSchemaLocation =
"Agent ignores workspace's database schema location";
const savingDbSchemaLocationAsProjectMemory =
'Agent saves workspace database schema location as project memory';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: ignoringDbSchemaLocation,
name: savingDbSchemaLocationAsProjectMemory,
prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`,
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
const wasToolCalled = rig
.readToolLogs()
.some((log) => log.toolRequest.name === 'save_memory');
const wasToolCalled = await rig.waitForToolCall(
'save_memory',
undefined,
(args) => {
try {
const params = JSON.parse(args);
return params.scope === 'project';
} catch {
return false;
}
},
);
expect(
wasToolCalled,
'save_memory should not be called for workspace-specific information',
).toBe(false);
'Expected save_memory to be called with scope="project" for workspace-specific information',
).toBe(true);
assertModelHasOutput(result);
},
@@ -188,42 +196,59 @@ describe('save_memory', () => {
},
});
const ignoringBuildArtifactLocation =
'Agent ignores workspace build artifact location';
const savingBuildArtifactLocationAsProjectMemory =
'Agent saves workspace build artifact location as project memory';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: ignoringBuildArtifactLocation,
name: savingBuildArtifactLocationAsProjectMemory,
prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`,
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
const wasToolCalled = rig
.readToolLogs()
.some((log) => log.toolRequest.name === 'save_memory');
const wasToolCalled = await rig.waitForToolCall(
'save_memory',
undefined,
(args) => {
try {
const params = JSON.parse(args);
return params.scope === 'project';
} catch {
return false;
}
},
);
expect(
wasToolCalled,
'save_memory should not be called for workspace-specific information',
).toBe(false);
'Expected save_memory to be called with scope="project" for workspace-specific information',
).toBe(true);
assertModelHasOutput(result);
},
});
const ignoringMainEntryPoint = "Agent ignores workspace's main entry point";
const savingMainEntryPointAsProjectMemory =
'Agent saves workspace main entry point as project memory';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: ignoringMainEntryPoint,
name: savingMainEntryPointAsProjectMemory,
prompt: `The main entry point for this workspace is \`src/index.js\`.`,
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
const wasToolCalled = rig
.readToolLogs()
.some((log) => log.toolRequest.name === 'save_memory');
const wasToolCalled = await rig.waitForToolCall(
'save_memory',
undefined,
(args) => {
try {
const params = JSON.parse(args);
return params.scope === 'project';
} catch {
return false;
}
},
);
expect(
wasToolCalled,
'save_memory should not be called for workspace-specific information',
).toBe(false);
'Expected save_memory to be called with scope="project" for workspace-specific information',
).toBe(true);
assertModelHasOutput(result);
},
@@ -317,13 +342,13 @@ describe('save_memory', () => {
'Please save any persistent preferences or facts about me from our conversation to memory.',
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall(
'save_memory',
'invoke_agent',
undefined,
(args) => /vitest/i.test(args),
(args) => /save_memory/i.test(args) && /vitest/i.test(args),
);
expect(
wasToolCalled,
'Expected save_memory to be called with the Vitest preference from the conversation history',
'Expected invoke_agent to be called with save_memory agent and the Vitest preference from the conversation history',
).toBe(true);
assertModelHasOutput(result);
@@ -379,8 +404,15 @@ describe('save_memory', () => {
],
prompt: 'Please save the preferences I mentioned earlier to memory.',
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
expect(wasToolCalled, 'Expected save_memory to be called').toBe(true);
const wasToolCalled = await rig.waitForToolCall(
'invoke_agent',
undefined,
(args) => /save_memory/i.test(args),
);
expect(
wasToolCalled,
'Expected invoke_agent to be called with save_memory agent',
).toBe(true);
assertModelHasOutput(result);
},
+349
View File
@@ -0,0 +1,349 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fsp from 'node:fs/promises';
import path from 'node:path';
import { describe, expect } from 'vitest';
import {
type Config,
ApprovalMode,
SESSION_FILE_PREFIX,
getProjectHash,
startMemoryService,
} from '@google/gemini-cli-core';
import { componentEvalTest } from './component-test-helper.js';
interface SeedSession {
sessionId: string;
summary: string;
userTurns: string[];
timestampOffsetMinutes: number;
}
interface MessageRecord {
id: string;
timestamp: string;
type: string;
content: Array<{ text: string }>;
}
const WORKSPACE_FILES = {
'package.json': JSON.stringify(
{
name: 'skill-extraction-eval',
private: true,
scripts: {
build: 'echo build',
lint: 'echo lint',
test: 'echo test',
},
},
null,
2,
),
'README.md': `# Skill Extraction Eval
This workspace exists to exercise background skill extraction from prior chats.
`,
};
function buildMessages(userTurns: string[]): MessageRecord[] {
const baseTime = new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString();
return userTurns.flatMap((text, index) => [
{
id: `u${index + 1}`,
timestamp: baseTime,
type: 'user',
content: [{ text }],
},
{
id: `a${index + 1}`,
timestamp: baseTime,
type: 'gemini',
content: [{ text: `Acknowledged: ${index + 1}` }],
},
]);
}
async function seedSessions(
config: Config,
sessions: SeedSession[],
): Promise<void> {
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
await fsp.mkdir(chatsDir, { recursive: true });
const projectRoot = config.storage.getProjectRoot();
for (const session of sessions) {
const timestamp = new Date(
Date.now() - session.timestampOffsetMinutes * 60 * 1000,
)
.toISOString()
.slice(0, 16)
.replace(/:/g, '-');
const filename = `${SESSION_FILE_PREFIX}${timestamp}-${session.sessionId.slice(0, 8)}.json`;
const conversation = {
sessionId: session.sessionId,
projectHash: getProjectHash(projectRoot),
summary: session.summary,
startTime: new Date(Date.now() - 7 * 60 * 60 * 1000).toISOString(),
lastUpdated: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
messages: buildMessages(session.userTurns),
};
await fsp.writeFile(
path.join(chatsDir, filename),
JSON.stringify(conversation, null, 2),
);
}
}
async function runExtractionAndReadState(config: Config): Promise<{
state: { runs: Array<{ sessionIds: string[]; skillsCreated: string[] }> };
skillsDir: string;
}> {
await startMemoryService(config);
const memoryDir = config.storage.getProjectMemoryTempDir();
const skillsDir = config.storage.getProjectSkillsMemoryDir();
const statePath = path.join(memoryDir, '.extraction-state.json');
const raw = await fsp.readFile(statePath, 'utf-8');
const state = JSON.parse(raw) as {
runs?: Array<{ sessionIds?: string[]; skillsCreated?: string[] }>;
};
if (!Array.isArray(state.runs) || state.runs.length === 0) {
throw new Error('Skill extraction finished without writing any run state');
}
return {
state: {
runs: state.runs.map((run) => ({
sessionIds: Array.isArray(run.sessionIds) ? run.sessionIds : [],
skillsCreated: Array.isArray(run.skillsCreated)
? run.skillsCreated
: [],
})),
},
skillsDir,
};
}
async function readSkillBodies(skillsDir: string): Promise<string[]> {
try {
const entries = await fsp.readdir(skillsDir, { withFileTypes: true });
const skillDirs = entries.filter((entry) => entry.isDirectory());
const bodies = await Promise.all(
skillDirs.map((entry) =>
fsp.readFile(path.join(skillsDir, entry.name, 'SKILL.md'), 'utf-8'),
),
);
return bodies;
} catch {
return [];
}
}
/**
* Shared configOverrides for all skill extraction component evals.
* - experimentalMemoryManager: enables the memory extraction pipeline.
* - approvalMode: YOLO auto-approves tool calls (write_file, read_file) so the
* background agent can execute without interactive confirmation.
*/
const EXTRACTION_CONFIG_OVERRIDES = {
experimentalMemoryManager: true,
approvalMode: ApprovalMode.YOLO,
};
describe('Skill Extraction', () => {
componentEvalTest('USUALLY_PASSES', {
suiteName: 'skill-extraction',
suiteType: 'component-level',
name: 'ignores one-off incidents even when session summaries look similar',
files: WORKSPACE_FILES,
timeout: 180000,
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
setup: async (config) => {
await seedSessions(config, [
{
sessionId: 'incident-login-redirect',
summary: 'Debug login redirect loop in staging',
timestampOffsetMinutes: 420,
userTurns: [
'We only need a one-off fix for incident INC-4412 on branch hotfix/login-loop.',
'The exact failing string is ERR_REDIRECT_4412 and this workaround is incident-specific.',
'Patch packages/auth/src/redirect.ts just for this branch and do not generalize it.',
'The thing that worked was deleting the stale staging cookie before retrying.',
'This is not a normal workflow and should not become a reusable instruction.',
'It only reproduced against the 2026-04-08 staging rollout.',
'After the cookie clear, the branch-specific redirect logic passed.',
'Do not turn this incident writeup into a standing process.',
'Yes, the hotfix worked for this exact redirect-loop incident.',
'Close out INC-4412 once the staging login succeeds again.',
],
},
{
sessionId: 'incident-login-timeout',
summary: 'Debug login callback timeout in staging',
timestampOffsetMinutes: 360,
userTurns: [
'This is another one-off staging incident, this time TICKET-991 for callback timeout.',
'The exact failing string is ERR_CALLBACK_TIMEOUT_991 and it is unrelated to the redirect loop.',
'The temporary fix was rotating the staging secret and deleting a bad feature-flag row.',
'Do not write a generic login-debugging playbook from this.',
'This only applied to the callback timeout during the April rollout.',
'The successful fix was specific to the stale secret in staging.',
'It does not define a durable repo workflow for future tasks.',
'After rotating the secret, the callback timeout stopped reproducing.',
'Treat this as incident response only, not a reusable skill.',
'Once staging passed again, we closed TICKET-991.',
],
},
]);
},
assert: async (config) => {
const { state, skillsDir } = await runExtractionAndReadState(config);
const skillBodies = await readSkillBodies(skillsDir);
expect(state.runs).toHaveLength(1);
expect(state.runs[0].sessionIds).toHaveLength(2);
expect(state.runs[0].skillsCreated).toEqual([]);
expect(skillBodies).toEqual([]);
},
});
componentEvalTest('USUALLY_PASSES', {
suiteName: 'skill-extraction',
suiteType: 'component-level',
name: 'extracts a repeated project-specific workflow into a skill',
files: WORKSPACE_FILES,
timeout: 180000,
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
setup: async (config) => {
await seedSessions(config, [
{
sessionId: 'settings-docs-regen-1',
summary: 'Update settings docs after adding a config option',
timestampOffsetMinutes: 420,
userTurns: [
'When we add a new config option, we have to regenerate the settings docs in a specific order.',
'The sequence that worked was npm run predocs:settings, npm run schema:settings, then npm run docs:settings.',
'Do not hand-edit generated settings docs.',
'If predocs is skipped, the generated schema docs miss the new defaults.',
'Update the source first, then run that generation sequence.',
'After regenerating, verify the schema output and docs changed together.',
'We used this same sequence the last time we touched settings docs.',
'That ordered workflow passed and produced the expected generated files.',
'Please keep the exact command order because reversing it breaks the output.',
'Yes, the generated settings docs were correct after those three commands.',
],
},
{
sessionId: 'settings-docs-regen-2',
summary: 'Regenerate settings schema docs for another new setting',
timestampOffsetMinutes: 360,
userTurns: [
'We are touching another setting, so follow the same settings-doc regeneration workflow again.',
'Run npm run predocs:settings before npm run schema:settings and npm run docs:settings.',
'The project keeps generated settings docs in sync through those commands, not manual edits.',
'Skipping predocs caused stale defaults in the generated output before.',
'Change the source, then execute the same three commands in order.',
'Verify both the schema artifact and docs update together after regeneration.',
'This is the recurring workflow we use whenever a setting changes.',
'The exact order worked again on this second settings update.',
'Please preserve that ordering constraint for future settings changes.',
'Confirmed: the settings docs regenerated correctly with the same command sequence.',
],
},
]);
},
assert: async (config) => {
const { state, skillsDir } = await runExtractionAndReadState(config);
const skillBodies = await readSkillBodies(skillsDir);
const combinedSkills = skillBodies.join('\n\n');
expect(state.runs).toHaveLength(1);
expect(state.runs[0].sessionIds).toHaveLength(2);
expect(state.runs[0].skillsCreated.length).toBeGreaterThanOrEqual(1);
expect(skillBodies.length).toBeGreaterThanOrEqual(1);
expect(combinedSkills).toContain('npm run predocs:settings');
expect(combinedSkills).toContain('npm run schema:settings');
expect(combinedSkills).toContain('npm run docs:settings');
expect(combinedSkills).toMatch(/Verification/i);
// Verify the extraction agent activated skill-creator for design guidance.
expect(config.getSkillManager().isSkillActive('skill-creator')).toBe(
true,
);
},
});
componentEvalTest('USUALLY_PASSES', {
suiteName: 'skill-extraction',
suiteType: 'component-level',
name: 'extracts a repeated multi-step migration workflow with ordering constraints',
files: WORKSPACE_FILES,
timeout: 180000,
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
setup: async (config) => {
await seedSessions(config, [
{
sessionId: 'db-migration-v12',
summary: 'Run database migration for v12 schema update',
timestampOffsetMinutes: 420,
userTurns: [
'Every time we change the database schema we follow a specific migration workflow.',
'First run npm run db:check to verify no pending migrations conflict.',
'Then run npm run db:migrate to apply the new migration files.',
'After migration, always run npm run db:validate to confirm schema integrity.',
'If db:validate fails, immediately run npm run db:rollback before anything else.',
'Never skip db:check — last time we did, two migrations collided and corrupted the index.',
'The ordering is critical: check, migrate, validate. Reversing migrate and validate caused silent data loss before.',
'This v12 migration passed after following that exact sequence.',
'We use this same three-step workflow every time the schema changes.',
'Confirmed: db:check, db:migrate, db:validate completed successfully for v12.',
],
},
{
sessionId: 'db-migration-v13',
summary: 'Run database migration for v13 schema update',
timestampOffsetMinutes: 360,
userTurns: [
'New schema change for v13, following the same database migration workflow as before.',
'Start with npm run db:check to ensure no conflicting pending migrations.',
'Then npm run db:migrate to apply the v13 migration files.',
'Then npm run db:validate to confirm the schema is consistent.',
'If validation fails, run npm run db:rollback immediately — do not attempt manual fixes.',
'We learned the hard way that skipping db:check causes index corruption.',
'The check-migrate-validate order is mandatory for every schema change.',
'This is the same recurring workflow we used for v12 and earlier migrations.',
'The v13 migration passed with the same three-step sequence.',
'Confirmed: the standard db migration workflow succeeded again for v13.',
],
},
]);
},
assert: async (config) => {
const { state, skillsDir } = await runExtractionAndReadState(config);
const skillBodies = await readSkillBodies(skillsDir);
const combinedSkills = skillBodies.join('\n\n');
expect(state.runs).toHaveLength(1);
expect(state.runs[0].sessionIds).toHaveLength(2);
expect(state.runs[0].skillsCreated.length).toBeGreaterThanOrEqual(1);
expect(skillBodies.length).toBeGreaterThanOrEqual(1);
expect(combinedSkills).toContain('npm run db:check');
expect(combinedSkills).toContain('npm run db:migrate');
expect(combinedSkills).toContain('npm run db:validate');
expect(combinedSkills).toMatch(/rollback/i);
// Verify the extraction agent activated skill-creator for design guidance.
expect(config.getSkillManager().isSkillActive('skill-creator')).toBe(
true,
);
},
});
});
+130
View File
@@ -0,0 +1,130 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import {
TRACKER_CREATE_TASK_TOOL_NAME,
TRACKER_UPDATE_TASK_TOOL_NAME,
} from '@google/gemini-cli-core';
import { evalTest, TEST_AGENTS } from './test-helper.js';
describe('subtask delegation eval test cases', () => {
/**
* Checks that the main agent can correctly decompose a complex, sequential
* task into subtasks using the task tracker and delegate each to the appropriate expert subagent.
*
* The task requires:
* 1. Reading requirements (researcher)
* 2. Implementing logic (developer)
* 3. Documenting (doc expert)
*/
evalTest('USUALLY_PASSES', {
name: 'should delegate sequential subtasks to relevant experts using the task tracker',
params: {
settings: {
experimental: {
enableAgents: true,
taskTracker: true,
},
},
},
prompt:
'Please read the requirements in requirements.txt using a researcher, then implement the requested logic in src/logic.ts using a developer, and finally document the implementation in docs/logic.md using a documentation expert.',
files: {
'.gemini/agents/researcher.md': `---
name: researcher
description: Expert in reading files and extracting requirements.
tools:
- read_file
---
You are the researcher. Read the provided file and extract requirements.`,
'.gemini/agents/developer.md': `---
name: developer
description: Expert in implementing logic in TypeScript.
tools:
- write_file
---
You are the developer. Implement the requested logic in the specified file.`,
'.gemini/agents/doc-expert.md': `---
name: doc-expert
description: Expert in writing technical documentation.
tools:
- write_file
---
You are the doc expert. Document the provided implementation clearly.`,
'requirements.txt':
'Implement a function named "calculateSum" that adds two numbers.',
},
assert: async (rig, _result) => {
// Verify tracker tasks were created
const wasCreateCalled = await rig.waitForToolCall(
TRACKER_CREATE_TASK_TOOL_NAME,
);
expect(wasCreateCalled).toBe(true);
const toolLogs = rig.readToolLogs();
const createCalls = toolLogs.filter(
(l) => l.toolRequest.name === TRACKER_CREATE_TASK_TOOL_NAME,
);
expect(createCalls.length).toBeGreaterThanOrEqual(3);
await rig.expectToolCallSuccess([
'researcher',
'developer',
'doc-expert',
]);
const logicFile = rig.readFile('src/logic.ts');
const docFile = rig.readFile('docs/logic.md');
expect(logicFile).toContain('calculateSum');
expect(docFile).toBeTruthy();
},
});
/**
* Checks that the main agent can delegate a batch of independent subtasks
* to multiple subagents in parallel using the task tracker to manage state.
*/
evalTest('USUALLY_PASSES', {
name: 'should delegate independent subtasks to specialists using the task tracker',
params: {
settings: {
experimental: {
enableAgents: true,
taskTracker: true,
},
},
},
prompt:
'Please update the project for internationalization (i18n), audit the security of the current code, and update the CSS to use a blue theme. Use specialized experts for each task.',
files: {
...TEST_AGENTS.I18N_AGENT.asFile(),
...TEST_AGENTS.SECURITY_AGENT.asFile(),
...TEST_AGENTS.CSS_AGENT.asFile(),
'index.ts': 'console.log("Hello World");',
},
assert: async (rig, _result) => {
// Verify tracker tasks were created
const wasCreateCalled = await rig.waitForToolCall(
TRACKER_CREATE_TASK_TOOL_NAME,
);
expect(wasCreateCalled).toBe(true);
const toolLogs = rig.readToolLogs();
const createCalls = toolLogs.filter(
(l) => l.toolRequest.name === TRACKER_CREATE_TASK_TOOL_NAME,
);
expect(createCalls.length).toBeGreaterThanOrEqual(3);
await rig.expectToolCallSuccess([
TEST_AGENTS.I18N_AGENT.name,
TEST_AGENTS.SECURITY_AGENT.name,
TEST_AGENTS.CSS_AGENT.name,
]);
},
});
});
@@ -0,0 +1,275 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
describe('run_shell_command streaming to file regression', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should stream large outputs to a file and verify full content presence', async () => {
await rig.setup(
'should stream large outputs to a file and verify full content presence',
{
settings: { tools: { core: ['run_shell_command'] } },
},
);
const numLines = 20000;
const testFileName = 'large_output_test.txt';
const testFilePath = path.join(rig.testDir!, testFileName);
// Create a ~20MB file with unique content at start and end
const startMarker = 'START_OF_FILE_MARKER';
const endMarker = 'END_OF_FILE_MARKER';
const stream = fs.createWriteStream(testFilePath);
stream.write(startMarker + '\n');
for (let i = 0; i < numLines; i++) {
stream.write(`Line ${i + 1}: ` + 'A'.repeat(1000) + '\n');
}
stream.write(endMarker + '\n');
await new Promise((resolve) => stream.end(resolve));
const fileSize = fs.statSync(testFilePath).size;
expect(fileSize).toBeGreaterThan(20000000);
const prompt = `Use run_shell_command to cat ${testFileName} and say 'Done.'`;
await rig.run({ args: prompt });
let savedFilePath = '';
const tmpdir = path.join(rig.homeDir!, '.gemini', 'tmp');
if (fs.existsSync(tmpdir)) {
const findFiles = (dir: string): string[] => {
let results: string[] = [];
const list = fs.readdirSync(dir, { withFileTypes: true });
for (const file of list) {
const fullPath = path.join(dir, file.name);
if (file.isDirectory()) {
results = results.concat(findFiles(fullPath));
} else if (file.isFile() && file.name.endsWith('.txt')) {
results.push(fullPath);
} else if (file.isFile() && file.name.endsWith('.log')) {
results.push(fullPath);
}
}
return results;
};
const files = findFiles(tmpdir);
files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
for (const p of files) {
try {
const stat = fs.statSync(p);
if (stat.size >= 20000000) {
savedFilePath = p;
break;
}
} catch {
// ignore
}
}
if (!savedFilePath) {
const fileStats = files.map((p) => {
try {
return { p, size: fs.statSync(p).size };
} catch {
return { p, size: 'error' };
}
});
rig.log('Available files:', JSON.stringify(fileStats, null, 2));
}
}
expect(
savedFilePath,
`Expected to find a saved output file >= 20MB in ${tmpdir}`,
).toBeTruthy();
const savedContent = fs.readFileSync(savedFilePath, 'utf8');
expect(savedContent).toContain(startMarker);
expect(savedContent).toContain(endMarker);
expect(savedContent.length).toBeGreaterThanOrEqual(fileSize);
fs.unlinkSync(savedFilePath);
}, 120000);
it('should stream very large (50MB) outputs to a file and verify full content presence', async () => {
await rig.setup(
'should stream very large (50MB) outputs to a file and verify full content presence',
{
settings: { tools: { core: ['run_shell_command'] } },
},
);
const numLines = 1000000;
const testFileName = 'very_large_output_test.txt';
const testFilePath = path.join(rig.testDir!, testFileName);
// Create a ~50MB file with unique content at start and end
const startMarker = 'START_OF_FILE_MARKER';
const endMarker = 'END_OF_FILE_MARKER';
const stream = fs.createWriteStream(testFilePath);
stream.write(startMarker + '\n');
for (let i = 0; i < numLines; i++) {
stream.write(`Line ${i + 1}: ` + 'A'.repeat(40) + '\n');
}
stream.write(endMarker + '\n');
await new Promise((resolve) => stream.end(resolve));
const fileSize = fs.statSync(testFilePath).size;
expect(fileSize).toBeGreaterThan(45000000);
const prompt = `Use run_shell_command to cat ${testFileName} and say 'Done.'`;
await rig.run({ args: prompt });
let savedFilePath = '';
const tmpdir = path.join(rig.homeDir!, '.gemini', 'tmp');
if (fs.existsSync(tmpdir)) {
const findFiles = (dir: string): string[] => {
let results: string[] = [];
const list = fs.readdirSync(dir, { withFileTypes: true });
for (const file of list) {
const fullPath = path.join(dir, file.name);
if (file.isDirectory()) {
results = results.concat(findFiles(fullPath));
} else if (file.isFile() && file.name.endsWith('.txt')) {
results.push(fullPath);
} else if (file.isFile() && file.name.endsWith('.log')) {
results.push(fullPath);
}
}
return results;
};
const files = findFiles(tmpdir);
files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
for (const p of files) {
try {
const stat = fs.statSync(p);
if (stat.size >= 20000000) {
savedFilePath = p;
break;
}
} catch {
// ignore
}
}
if (!savedFilePath) {
const fileStats = files.map((p) => {
try {
return { p, size: fs.statSync(p).size };
} catch {
return { p, size: 'error' };
}
});
rig.log('Available files:', JSON.stringify(fileStats, null, 2));
}
}
expect(
savedFilePath,
`Expected to find a saved output file >= 20MB in ${tmpdir}`,
).toBeTruthy();
const savedContent = fs.readFileSync(savedFilePath, 'utf8');
expect(savedContent).toContain(startMarker);
expect(savedContent).toContain(endMarker);
expect(savedContent.length).toBeGreaterThanOrEqual(fileSize);
fs.unlinkSync(savedFilePath);
}, 120000);
it('should produce clean output resolving carriage returns and backspaces', async () => {
await rig.setup(
'should produce clean output resolving carriage returns and backspaces',
{
settings: {
tools: { core: ['run_shell_command'] },
},
},
);
const script = `
import sys
import time
# Fill buffer to force file streaming/truncation
# 45000 chars to be safe (default threshold is 40000)
print('A' * 45000)
sys.stdout.flush()
# Test sequence
print('XXXXX', end='', flush=True)
time.sleep(0.5)
print('\\rYYYYY', end='', flush=True)
time.sleep(0.5)
print('\\nNext Line', end='', flush=True)
`;
const scriptPath = path.join(rig.testDir!, 'test_script.py');
fs.writeFileSync(scriptPath, script);
const prompt = `run_shell_command python3 "${scriptPath}"`;
await rig.run({ args: prompt });
let savedFilePath = '';
const tmpdir = path.join(rig.homeDir!, '.gemini', 'tmp');
if (fs.existsSync(tmpdir)) {
const findFiles = (dir: string): string[] => {
let results: string[] = [];
const list = fs.readdirSync(dir, { withFileTypes: true });
for (const file of list) {
const fullPath = path.join(dir, file.name);
if (file.isDirectory()) {
results = results.concat(findFiles(fullPath));
} else if (file.isFile() && file.name.endsWith('.txt')) {
results.push(fullPath);
}
}
return results;
};
const files = findFiles(tmpdir);
files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
if (files.length > 0) {
savedFilePath = files[0];
}
}
expect(savedFilePath, 'Output file should exist').toBeTruthy();
const content = fs.readFileSync(savedFilePath, 'utf8');
// Verify it contains the large chunk
expect(content).toContain('AAAA');
// Verify cleanup logic:
// 1. The final text "YYYYY" should be present.
expect(content).toContain('YYYYY');
// 2. The next line should be present.
expect(content).toContain('Next Line');
// 3. Verify overwrite happened.
// In raw output, we would have "XXXXX...YYYYY".
// In processed output, "YYYYY" overwrites "XXXXX".
// We confirm that escape codes are stripped (processed text).
// 4. Check for ANSI escape codes (like \\x1b) just in case
expect(content).not.toContain('\x1b');
}, 60000);
});
+28 -328
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"workspaces": [
"packages/*"
],
@@ -1727,29 +1727,6 @@
"node": ">=8"
}
},
"node_modules/@joshua.litt/get-ripgrep": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/@joshua.litt/get-ripgrep/-/get-ripgrep-0.0.3.tgz",
"integrity": "sha512-rycdieAKKqXi2bsM7G2ayDiNk5CAX8ZOzsTQsirfOqUKPef04Xw40BWGGyimaOOuvPgLWYt3tPnLLG3TvPXi5Q==",
"license": "MIT",
"dependencies": {
"@lvce-editor/verror": "^1.6.0",
"execa": "^9.5.2",
"extract-zip": "^2.0.1",
"fs-extra": "^11.3.0",
"got": "^14.4.5",
"path-exists": "^5.0.0",
"xdg-basedir": "^5.1.0"
}
},
"node_modules/@joshua.litt/get-ripgrep/node_modules/path-exists": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
"integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1932,12 +1909,6 @@
"integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
"license": "MIT"
},
"node_modules/@lvce-editor/verror": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@lvce-editor/verror/-/verror-1.7.0.tgz",
"integrity": "sha512-+LGuAEIC2L7pbvkyAQVWM2Go0dAy+UWEui28g07zNtZsCBhm+gusBK8PNwLJLV5Jay+TyUYuwLIbJdjLLzqEBg==",
"license": "MIT"
},
"node_modules/@lydell/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz",
@@ -3590,18 +3561,6 @@
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/@sindresorhus/is": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.0.2.tgz",
"integrity": "sha512-d9xRovfKNz1SKieM0qJdO+PQonjnnIfSNWfHYnBSJ9hkjm0ZPw6HlxscDXYstp3z+7V2GOFHc+J0CYrYTjqCJw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
@@ -3614,18 +3573,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@szmarczak/http-timer": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz",
"integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==",
"license": "MIT",
"dependencies": {
"defer-to-connect": "^2.0.1"
},
"engines": {
"node": ">=14.16"
}
},
"node_modules/@textlint/ast-node-types": {
"version": "15.2.2",
"resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.2.2.tgz",
@@ -3931,12 +3878,6 @@
"integrity": "sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ==",
"license": "MIT"
},
"node_modules/@types/http-cache-semantics": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
"integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==",
"license": "MIT"
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
@@ -6008,33 +5949,6 @@
"node": ">=8"
}
},
"node_modules/cacheable-lookup": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
"integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==",
"license": "MIT",
"engines": {
"node": ">=14.16"
}
},
"node_modules/cacheable-request": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-12.0.1.tgz",
"integrity": "sha512-Yo9wGIQUaAfIbk+qY0X4cDQgCosecfBe3V9NSyeY4qPC2SAkbCS4Xj79VP8WOzitpJUZKc/wsRCYF5ariDIwkg==",
"license": "MIT",
"dependencies": {
"@types/http-cache-semantics": "^4.0.4",
"get-stream": "^9.0.1",
"http-cache-semantics": "^4.1.1",
"keyv": "^4.5.4",
"mimic-response": "^4.0.0",
"normalize-url": "^8.0.1",
"responselike": "^3.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -6968,33 +6882,6 @@
}
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/decompress-response/node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@@ -7057,15 +6944,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/defer-to-connect": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@@ -8428,9 +8306,9 @@
}
},
"node_modules/execa": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz",
"integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==",
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
"integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==",
"license": "MIT",
"dependencies": {
"@sindresorhus/merge-streams": "^4.0.0",
@@ -8950,15 +8828,6 @@
"node": ">= 6"
}
},
"node_modules/form-data-encoder": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.1.0.tgz",
"integrity": "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==",
"license": "MIT",
"engines": {
"node": ">= 18"
}
},
"node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
@@ -9586,43 +9455,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/got": {
"version": "14.4.8",
"resolved": "https://registry.npmjs.org/got/-/got-14.4.8.tgz",
"integrity": "sha512-vxwU4HuR0BIl+zcT1LYrgBjM+IJjNElOjCzs0aPgHorQyr/V6H6Y73Sn3r3FOlUffvWD+Q5jtRuGWaXkU8Jbhg==",
"license": "MIT",
"dependencies": {
"@sindresorhus/is": "^7.0.1",
"@szmarczak/http-timer": "^5.0.1",
"cacheable-lookup": "^7.0.0",
"cacheable-request": "^12.0.1",
"decompress-response": "^6.0.0",
"form-data-encoder": "^4.0.2",
"http2-wrapper": "^2.2.1",
"lowercase-keys": "^3.0.0",
"p-cancelable": "^4.0.1",
"responselike": "^3.0.0",
"type-fest": "^4.26.1"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sindresorhus/got?sponsor=1"
}
},
"node_modules/got/node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -9878,12 +9710,6 @@
"entities": "^4.4.0"
}
},
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
"license": "BSD-2-Clause"
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -9917,19 +9743,6 @@
"node": ">= 14"
}
},
"node_modules/http2-wrapper": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz",
"integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==",
"license": "MIT",
"dependencies": {
"quick-lru": "^5.1.1",
"resolve-alpn": "^1.2.0"
},
"engines": {
"node": ">=10.19.0"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@@ -10839,6 +10652,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",
@@ -11028,6 +10853,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"dev": true,
"license": "MIT"
},
"node_modules/json-parse-better-errors": {
@@ -11223,6 +11049,7 @@
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"json-buffer": "3.0.1"
@@ -11687,18 +11514,6 @@
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"license": "MIT"
},
"node_modules/lowercase-keys": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz",
"integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lowlight": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
@@ -11969,18 +11784,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mimic-response": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz",
"integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
@@ -12359,18 +12162,6 @@
"node": "^16.14.0 || >=18.0.0"
}
},
"node_modules/normalize-url": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.2.tgz",
"integrity": "sha512-Ee/R3SyN4BuynXcnTaekmaVdbDAEiNrHqjQIA37mHU8G9pf7aaAD4ZX3XjBLo6rsdcxA/gtkcNYZLt30ACgynw==",
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/npm-normalize-package-bin": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz",
@@ -12883,15 +12674,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/p-cancelable": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz",
"integrity": "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==",
"license": "MIT",
"engines": {
"node": ">=14.16"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -13731,18 +13513,6 @@
],
"license": "MIT"
},
"node_modules/quick-lru": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -14183,12 +13953,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/resolve-alpn": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
"integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
"license": "MIT"
},
"node_modules/resolve-dir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz",
@@ -14223,21 +13987,6 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/responselike": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz",
"integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==",
"license": "MIT",
"dependencies": {
"lowercase-keys": "^3.0.0"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/restore-cursor": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
@@ -17632,18 +17381,6 @@
}
}
},
"node_modules/xdg-basedir": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz",
"integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/xml2js": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
@@ -17852,7 +17589,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "^7.16.0",
@@ -17967,7 +17704,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
@@ -18067,44 +17804,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/cli/node_modules/execa": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
"integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==",
"license": "MIT",
"dependencies": {
"@sindresorhus/merge-streams": "^4.0.0",
"cross-spawn": "^7.0.6",
"figures": "^6.1.0",
"get-stream": "^9.0.0",
"human-signals": "^8.0.1",
"is-plain-obj": "^4.1.0",
"is-stream": "^4.0.1",
"npm-run-path": "^6.0.0",
"pretty-ms": "^9.2.0",
"signal-exit": "^4.1.0",
"strip-final-newline": "^4.0.0",
"yoctocolors": "^2.1.1"
},
"engines": {
"node": "^18.19.0 || >=20.5.0"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"packages/cli/node_modules/is-stream": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
"integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/cli/node_modules/string-width": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz",
@@ -18139,7 +17838,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -18150,7 +17849,6 @@
"@google/genai": "1.30.0",
"@grpc/grpc-js": "^1.14.3",
"@iarna/toml": "^2.2.5",
"@joshua.litt/get-ripgrep": "^0.0.3",
"@modelcontextprotocol/sdk": "^1.23.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.211.0",
@@ -18178,6 +17876,7 @@
"diff": "^8.0.3",
"dotenv": "^17.2.4",
"dotenv-expand": "^12.0.3",
"execa": "^9.6.1",
"fast-levenshtein": "^2.0.6",
"fdir": "^6.4.6",
"fzf": "^0.5.2",
@@ -18187,6 +17886,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",
@@ -18405,7 +18105,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -18420,7 +18120,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18437,7 +18137,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18455,7 +18155,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.39.0-nightly.20260408.e77b22e63"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.40.0-nightly.20260414.g5b1f7375a"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -27,7 +27,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.39.0-nightly.20260408.e77b22e63"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.40.0-nightly.20260414.g5b1f7375a"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
+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')}`,
};
}
}
@@ -62,6 +62,7 @@ describe('fetchJson', () => {
const res = new EventEmitter() as IncomingMessage;
res.statusCode = 302;
res.headers = { location: 'https://example.com/final' };
res.resume = vi.fn();
(callback as (res: IncomingMessage) => void)(res);
res.emit('end');
return new EventEmitter() as ClientRequest;
@@ -85,6 +86,7 @@ describe('fetchJson', () => {
const res = new EventEmitter() as IncomingMessage;
res.statusCode = 301;
res.headers = { location: 'https://example.com/final-permanent' };
res.resume = vi.fn();
(callback as (res: IncomingMessage) => void)(res);
res.emit('end');
return new EventEmitter() as ClientRequest;
@@ -31,7 +31,11 @@ export async function fetchJson<T>(
if (!res.headers.location) {
return reject(new Error('No location header in redirect response'));
}
fetchJson<T>(res.headers.location, redirectCount++)
res.resume();
fetchJson<T>(
new URL(res.headers.location, url).toString(),
redirectCount + 1,
)
.then(resolve)
.catch(reject);
return;
+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',
@@ -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) {
+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();
});
+50 -42
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,
@@ -2401,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,
@@ -2473,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,
@@ -2592,12 +2604,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
queueErrorMessage,
showApprovalModeIndicator,
allowPlanMode,
userTier,
quotaStats,
proQuotaRequest,
validationRequest,
overageMenuRequest,
emptyWalletRequest,
contextFileNames,
errorCount,
availableTerminalHeight,
@@ -2816,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: {
+16 -11
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { ApiAuthDialog } from './ApiAuthDialog.js';
@@ -40,11 +40,16 @@ vi.mock('../components/shared/text-buffer.js', async (importOriginal) => {
};
});
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: vi.fn(() => ({
terminalWidth: 80,
})),
}));
vi.mock('../contexts/UIStateContext.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../contexts/UIStateContext.js')>();
return {
...actual,
useUIState: vi.fn(() => ({
terminalWidth: 80,
})),
};
});
const mockedUseKeypress = useKeypress as Mock;
const mockedUseTextBuffer = useTextBuffer as Mock;
@@ -73,7 +78,7 @@ describe('ApiAuthDialog', () => {
});
it('renders correctly', async () => {
const { lastFrame, unmount } = await render(
const { lastFrame, unmount } = await renderWithProviders(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
expect(lastFrame()).toMatchSnapshot();
@@ -81,7 +86,7 @@ describe('ApiAuthDialog', () => {
});
it('renders with a defaultValue', async () => {
const { unmount } = await render(
const { unmount } = await renderWithProviders(
<ApiAuthDialog
onSubmit={onSubmit}
onCancel={onCancel}
@@ -111,7 +116,7 @@ describe('ApiAuthDialog', () => {
'calls $expectedCall.name when $keyName is pressed',
async ({ keyName, sequence, expectedCall, args }) => {
mockBuffer.text = 'submitted-key'; // Set for the onSubmit case
const { unmount } = await render(
const { unmount } = await renderWithProviders(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
// calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler)
@@ -133,7 +138,7 @@ describe('ApiAuthDialog', () => {
);
it('displays an error message', async () => {
const { lastFrame, unmount } = await render(
const { lastFrame, unmount } = await renderWithProviders(
<ApiAuthDialog
onSubmit={onSubmit}
onCancel={onCancel}
@@ -146,7 +151,7 @@ describe('ApiAuthDialog', () => {
});
it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => {
const { unmount } = await render(
const { unmount } = await renderWithProviders(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
// Call 0 is ApiAuthDialog (isActive: true)
+23 -20
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import React from 'react';
import { Box, Text } from 'ink';
import type { AnsiLine, AnsiOutput, AnsiToken } from '@google/gemini-cli-core';
@@ -53,23 +53,26 @@ export const AnsiOutputText: React.FC<AnsiOutputProps> = ({
);
};
export const AnsiLineText: React.FC<{ line: AnsiLine }> = ({ line }) => (
<Text>
{line.length > 0
? line.map((token: AnsiToken, tokenIndex: number) => (
<Text
key={tokenIndex}
color={token.fg}
backgroundColor={token.bg}
inverse={token.inverse}
dimColor={token.dim}
bold={token.bold}
italic={token.italic}
underline={token.underline}
>
{token.text}
</Text>
))
: null}
</Text>
export const AnsiLineText = React.memo<{ line: AnsiLine }>(
({ line }: { line: AnsiLine }) => (
<Text>
{line.length > 0
? line.map((token: AnsiToken, tokenIndex: number) => (
<Text
key={tokenIndex}
color={token.fg}
backgroundColor={token.bg}
inverse={token.inverse}
dimColor={token.dim}
bold={token.bold}
italic={token.italic}
underline={token.underline}
>
{token.text}
</Text>
))
: null}
</Text>
),
);
AnsiLineText.displayName = 'AnsiLineText';
@@ -13,7 +13,8 @@ import {
useReducer,
useContext,
} from 'react';
import { Box, Text } from 'ink';
import { Box, Text, type DOMElement } from 'ink';
import { useMouseClick } from '../hooks/useMouseClick.js';
import { theme } from '../semantic-colors.js';
import { checkExhaustive, type Question } from '@google/gemini-cli-core';
import { BaseSelectionList } from './shared/BaseSelectionList.js';
@@ -85,6 +86,24 @@ function autoBoldIfPlain(text: string): string {
return text;
}
const ClickableCheckbox: React.FC<{
isChecked: boolean;
onClick: () => void;
}> = ({ isChecked, onClick }) => {
const ref = useRef<DOMElement>(null);
useMouseClick(ref, () => {
onClick();
});
return (
<Box ref={ref}>
<Text color={isChecked ? theme.status.success : theme.text.secondary}>
[{isChecked ? 'x' : ' '}]
</Text>
</Box>
);
};
interface AskUserDialogState {
answers: { [key: string]: string };
isEditingCustomOption: boolean;
@@ -919,13 +938,14 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
return (
<Box flexDirection="row">
{showCheck && (
<Text
color={
isChecked ? theme.status.success : theme.text.secondary
}
>
[{isChecked ? 'x' : ' '}]
</Text>
<ClickableCheckbox
isChecked={isChecked}
onClick={() => {
if (!context.isSelected) {
handleSelect(optionItem);
}
}}
/>
)}
<Text color={theme.text.primary}> </Text>
<TextInput
@@ -966,13 +986,14 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
<Box flexDirection="column">
<Box flexDirection="row">
{showCheck && (
<Text
color={
isChecked ? theme.status.success : theme.text.secondary
}
>
[{isChecked ? 'x' : ' '}]
</Text>
<ClickableCheckbox
isChecked={isChecked}
onClick={() => {
if (!context.isSelected) {
handleSelect(optionItem);
}
}}
/>
)}
<Text color={labelColor} bold={optionItem.type === 'done'}>
{' '}
@@ -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' && (
@@ -3877,8 +3877,9 @@ describe('InputPrompt', () => {
// 1. Verify initial placeholder
await waitFor(() => {
expect(stdout.lastFrame()).toMatchSnapshot();
expect(stdout.lastFrame()).toContain('[Pasted Text: 10 lines]');
});
expect(stdout.lastFrame()).toMatchSnapshot();
// Simulate double-click to expand
await simulateClick(5, 2);
@@ -3886,8 +3887,9 @@ describe('InputPrompt', () => {
// 2. Verify expanded content is visible
await waitFor(() => {
expect(stdout.lastFrame()).toMatchSnapshot();
expect(stdout.lastFrame()).toContain('line10');
});
expect(stdout.lastFrame()).toMatchSnapshot();
// Simulate double-click to collapse
await simulateClick(5, 2);
@@ -3895,8 +3897,9 @@ describe('InputPrompt', () => {
// 3. Verify placeholder is restored
await waitFor(() => {
expect(stdout.lastFrame()).toMatchSnapshot();
expect(stdout.lastFrame()).toContain('[Pasted Text: 10 lines]');
});
expect(stdout.lastFrame()).toMatchSnapshot();
unmount();
});
@@ -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 │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
@@ -156,7 +156,16 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 2`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Pasted Text: 10 lines]
> line1
line2
line3
line4
line5
line6
line7
line8
line9
line10
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
@@ -11,12 +11,12 @@ exports[`Initial Theme Selection > should default to a dark theme when terminal
│ ● 4. Default Dark (Matches terminal) │ 3 a, b = 0, 1 │ │
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
│ 7. Holiday Dark │ 6 return a │ │
│ 8. Shades Of Purple Dark │ │ │
│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
│ 10. Tokyo Night Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. ANSI Light │ │ │
│ 12. Ayu Light └─────────────────────────────────────────────────┘ │
│ 7. GitHub Dark Colorblind Dark │ 6 return a │ │
│ 8. Holiday Dark │ │ │
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. Tokyo Night Dark │ │ │
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
@@ -34,14 +34,14 @@ exports[`Initial Theme Selection > should default to a light theme when terminal
│ 2. Ayu Light │ 1 # function │ │
│ ● 3. Default Light │ 2 def fibonacci(n): │ │
│ 4. GitHub Light │ 3 a, b = 0, 1 │ │
│ 5. Google Code Light │ 4 for _ in range(n): │ │
│ 6. Solarized Light │ 5 a, b = b, a + b │ │
│ 7. Xcode Light │ 6 return a │ │
│ 8. ANSI Dark (Incompatible) │ │ │
│ 9. Atom One Dark (Incompatible) │ 1 - print("Hello, " + name) │ │
│ 10. Ayu Dark (Incompatible) │ 1 + print(f"Hello, {name}!") │ │
│ 11. Default Dark (Incompatible) │ │ │
│ 12. Dracula Dark (Incompatible) └─────────────────────────────────────────────────┘ │
│ 5. GitHub Light Colorblind Light (Mat… │ 4 for _ in range(n): │ │
│ 6. Google Code Light │ 5 a, b = b, a + b │ │
│ 7. Solarized Light │ 6 return a │ │
│ 8. Xcode Light │ │ │
│ 9. ANSI Dark (Incompatible) │ 1 - print("Hello, " + name) │ │
│ 10. Atom One Dark (Incompatible) │ 1 + print(f"Hello, {name}!") │ │
│ 11. Ayu Dark (Incompatible) │ │ │
│ 12. Default Dark (Incompatible) └─────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
@@ -61,12 +61,12 @@ exports[`Initial Theme Selection > should use the theme from settings even if te
│ 4. Default Dark (Matches terminal) │ 3 a, b = 0, 1 │ │
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
│ 7. Holiday Dark │ 6 return a │ │
│ 8. Shades Of Purple Dark │ │ │
│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
│ 10. Tokyo Night Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. ANSI Light │ │ │
│ 12. Ayu Light └─────────────────────────────────────────────────┘ │
│ 7. GitHub Dark Colorblind Dark │ 6 return a │ │
│ 8. Holiday Dark │ │ │
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. Tokyo Night Dark │ │ │
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
@@ -100,12 +100,12 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
│ 4. Default Dark │ 3 a, b = 0, 1 │ │
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
│ 7. Holiday Dark │ 6 return a │ │
│ 8. Shades Of Purple Dark │ │ │
│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
│ 10. Tokyo Night Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. ANSI Light │ │ │
│ 12. Ayu Light └─────────────────────────────────────────────────┘ │
│ 7. GitHub Dark Colorblind Dark │ 6 return a │ │
│ 8. Holiday Dark │ │ │
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. Tokyo Night Dark │ │ │
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
@@ -125,12 +125,12 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
│ 4. Default Dark │ 3 a, b = 0, 1 │ │
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
│ 7. Holiday Dark │ 6 return a │ │
│ 8. Shades Of Purple Dark │ │ │
│ 9. Solarized Dark │ 1 - print("Hello, " + name) │ │
│ 10. Tokyo Night Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. ANSI Light │ │ │
│ 12. Ayu Light └─────────────────────────────────────────────────┘ │
│ 7. GitHub Dark Colorblind Dark │ 6 return a │ │
│ 8. Holiday Dark │ │ │
│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │
│ 10. Solarized Dark │ 1 + print(f"Hello, {name}!") │ │
│ 11. Tokyo Night Dark │ │ │
│ 12. ANSI Light └─────────────────────────────────────────────────┘ │
│ ▼ │
│ ╭─────────────────────────────────────────────────╮ │
│ │ DEVELOPER TOOLS (Not visible to users) │ │
@@ -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';
@@ -248,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;
@@ -273,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) {
@@ -446,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 ? (
@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { act } from 'react';
import { renderWithProviders } from '../../../test-utils/render.js';
import {
BaseSelectionList,
@@ -14,8 +15,10 @@ import {
import { useSelectionList } from '../../hooks/useSelectionList.js';
import { Text } from 'ink';
import type { theme } from '../../semantic-colors.js';
import { useMouseClick } from '../../hooks/useMouseClick.js';
vi.mock('../../hooks/useSelectionList.js');
vi.mock('../../hooks/useMouseClick.js');
const mockTheme = {
text: { primary: 'COLOR_PRIMARY', secondary: 'COLOR_SECONDARY' },
@@ -35,6 +38,7 @@ describe('BaseSelectionList', () => {
const mockOnSelect = vi.fn();
const mockOnHighlight = vi.fn();
const mockRenderItem = vi.fn();
const mockSetActiveIndex = vi.fn();
const items = [
{ value: 'A', label: 'Item A', key: 'A' },
@@ -54,7 +58,7 @@ describe('BaseSelectionList', () => {
) => {
vi.mocked(useSelectionList).mockReturnValue({
activeIndex,
setActiveIndex: vi.fn(),
setActiveIndex: mockSetActiveIndex,
});
mockRenderItem.mockImplementation(
@@ -484,6 +488,79 @@ describe('BaseSelectionList', () => {
});
});
describe('Mouse Interaction', () => {
it('should register mouse click handler for each item', async () => {
const { unmount } = await renderComponent();
// items are A, B (disabled), C
expect(useMouseClick).toHaveBeenCalledTimes(3);
unmount();
});
it('should update activeIndex on first click and call onSelect on second click', async () => {
const { unmount, waitUntilReady } = await renderComponent();
await waitUntilReady();
// items[0] is 'A' (enabled)
// items[1] is 'B' (disabled)
// items[2] is 'C' (enabled)
// Get the mouse click handler for the third item (index 2)
const mouseClickHandler = (useMouseClick as Mock).mock.calls[2][1];
// First click on item C
act(() => {
mouseClickHandler();
});
expect(mockSetActiveIndex).toHaveBeenCalledWith(2);
expect(mockOnSelect).not.toHaveBeenCalled();
// Now simulate being on item C (isSelected = true)
// Rerender or update mocks for the next check
await renderComponent({}, 2);
// Get the updated mouse click handler for item C
// useMouseClick is called 3 more times on rerender
const updatedMouseClickHandler = (useMouseClick as Mock).mock.calls[5][1];
// Second click on item C
act(() => {
updatedMouseClickHandler();
});
expect(mockOnSelect).toHaveBeenCalledWith('C');
unmount();
});
it('should not call onSelect when a disabled item is clicked', async () => {
const { unmount, waitUntilReady } = await renderComponent();
await waitUntilReady();
// items[1] is 'B' (disabled)
const mouseClickHandler = (useMouseClick as Mock).mock.calls[1][1];
act(() => {
mouseClickHandler();
});
expect(mockSetActiveIndex).not.toHaveBeenCalled();
expect(mockOnSelect).not.toHaveBeenCalled();
unmount();
});
it('should pass isActive: isFocused to useMouseClick', async () => {
const { unmount } = await renderComponent({ isFocused: false });
expect(useMouseClick).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Function),
{ isActive: false },
);
unmount();
});
});
describe('Scroll Arrows (showScrollArrows)', () => {
const longList = Array.from({ length: 10 }, (_, i) => ({
value: `Item ${i + 1}`,
@@ -5,13 +5,14 @@
*/
import type React from 'react';
import { useState } from 'react';
import { Text, Box } from 'ink';
import { useState, useRef } from 'react';
import { Text, Box, type DOMElement } from 'ink';
import { theme } from '../../semantic-colors.js';
import {
useSelectionList,
type SelectionListItem,
} from '../../hooks/useSelectionList.js';
import { useMouseClick } from '../../hooks/useMouseClick.js';
export interface RenderItemContext {
isSelected: boolean;
@@ -38,6 +39,119 @@ export interface BaseSelectionListProps<
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
}
interface SelectionListItemRowProps<
T,
TItem extends SelectionListItem<T> = SelectionListItem<T>,
> {
item: TItem;
itemIndex: number;
isSelected: boolean;
isFocused: boolean;
showNumbers: boolean;
selectedIndicator: string;
numberColumnWidth: number;
onSelect: (value: T) => void;
setActiveIndex: (index: number) => void;
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
}
function SelectionListItemRow<
T,
TItem extends SelectionListItem<T> = SelectionListItem<T>,
>({
item,
itemIndex,
isSelected,
isFocused,
showNumbers,
selectedIndicator,
numberColumnWidth,
onSelect,
setActiveIndex,
renderItem,
}: SelectionListItemRowProps<T, TItem>) {
const containerRef = useRef<DOMElement>(null);
useMouseClick(
containerRef,
() => {
if (!item.disabled) {
if (isSelected) {
// Second click on the same item triggers submission
onSelect(item.value);
} else {
// First click highlights the item
setActiveIndex(itemIndex);
}
}
},
{ isActive: isFocused },
);
let titleColor = theme.text.primary;
let numberColor = theme.text.primary;
if (isSelected) {
titleColor = theme.ui.focus;
numberColor = theme.ui.focus;
} else if (item.disabled) {
titleColor = theme.text.secondary;
numberColor = theme.text.secondary;
}
if (!isFocused && !item.disabled) {
numberColor = theme.text.secondary;
}
if (!showNumbers) {
numberColor = theme.text.secondary;
}
const itemNumberText = `${String(itemIndex + 1).padStart(
numberColumnWidth,
)}.`;
return (
<Box
ref={containerRef}
key={item.key}
alignItems="flex-start"
backgroundColor={isSelected ? theme.background.focus : undefined}
>
{/* Radio button indicator */}
<Box minWidth={2} flexShrink={0}>
<Text
color={isSelected ? theme.ui.focus : theme.text.primary}
aria-hidden
>
{isSelected ? selectedIndicator : ' '}
</Text>
</Box>
{/* Item number */}
{showNumbers && !item.hideNumber && (
<Box
marginRight={1}
flexShrink={0}
minWidth={itemNumberText.length}
aria-state={{ checked: isSelected }}
>
<Text color={numberColor}>{itemNumberText}</Text>
</Box>
)}
{/* Custom content via render prop */}
<Box flexGrow={1}>
{renderItem(item, {
isSelected,
titleColor,
numberColor,
})}
</Box>
</Box>
);
}
/**
* Base component for selection lists that provides common UI structure
* and keyboard navigation logic via the useSelectionList hook.
@@ -70,7 +184,7 @@ export function BaseSelectionList<
selectedIndicator = '●',
renderItem,
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
const { activeIndex } = useSelectionList({
const { activeIndex, setActiveIndex } = useSelectionList({
items,
initialIndex,
onSelect,
@@ -107,10 +221,12 @@ export function BaseSelectionList<
);
const numberColumnWidth = String(items.length).length;
const showArrows = showScrollArrows && items.length > maxItemsToShow;
return (
<Box flexDirection="column">
{/* Use conditional coloring instead of conditional rendering */}
{showScrollArrows && items.length > maxItemsToShow && (
{showArrows && (
<Text
color={
effectiveScrollOffset > 0
@@ -126,71 +242,24 @@ export function BaseSelectionList<
const itemIndex = effectiveScrollOffset + index;
const isSelected = activeIndex === itemIndex;
// Determine colors based on selection and disabled state
let titleColor = theme.text.primary;
let numberColor = theme.text.primary;
if (isSelected) {
titleColor = theme.ui.focus;
numberColor = theme.ui.focus;
} else if (item.disabled) {
titleColor = theme.text.secondary;
numberColor = theme.text.secondary;
}
if (!isFocused && !item.disabled) {
numberColor = theme.text.secondary;
}
if (!showNumbers) {
numberColor = theme.text.secondary;
}
const itemNumberText = `${String(itemIndex + 1).padStart(
numberColumnWidth,
)}.`;
return (
<Box
<SelectionListItemRow
key={item.key}
alignItems="flex-start"
backgroundColor={isSelected ? theme.background.focus : undefined}
>
{/* Radio button indicator */}
<Box minWidth={2} flexShrink={0}>
<Text
color={isSelected ? theme.ui.focus : theme.text.primary}
aria-hidden
>
{isSelected ? selectedIndicator : ' '}
</Text>
</Box>
{/* Item number */}
{showNumbers && !item.hideNumber && (
<Box
marginRight={1}
flexShrink={0}
minWidth={itemNumberText.length}
aria-state={{ checked: isSelected }}
>
<Text color={numberColor}>{itemNumberText}</Text>
</Box>
)}
{/* Custom content via render prop */}
<Box flexGrow={1}>
{renderItem(item, {
isSelected,
titleColor,
numberColor,
})}
</Box>
</Box>
item={item}
itemIndex={itemIndex}
isSelected={isSelected}
isFocused={isFocused}
showNumbers={showNumbers}
selectedIndicator={selectedIndicator}
numberColumnWidth={numberColumnWidth}
onSelect={onSelect}
setActiveIndex={setActiveIndex}
renderItem={renderItem}
/>
);
})}
{showScrollArrows && items.length > maxItemsToShow && (
{showArrows && (
<Text
color={
effectiveScrollOffset + maxItemsToShow < items.length
@@ -11,12 +11,17 @@ import { act } from 'react';
import { TextInput } from './TextInput.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { useTextBuffer, type TextBuffer } from './text-buffer.js';
import { useMouseClick } from '../../hooks/useMouseClick.js';
// Mocks
vi.mock('../../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
vi.mock('../../hooks/useMouseClick.js', () => ({
useMouseClick: vi.fn(),
}));
vi.mock('./text-buffer.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./text-buffer.js')>();
const mockTextBuffer = {
@@ -69,6 +74,7 @@ vi.mock('./text-buffer.js', async (importOriginal) => {
const mockedUseKeypress = useKeypress as Mock;
const mockedUseTextBuffer = useTextBuffer as Mock;
const mockedUseMouseClick = useMouseClick as Mock;
describe('TextInput', () => {
const onCancel = vi.fn();
@@ -84,6 +90,7 @@ describe('TextInput', () => {
cursor: [0, 0],
visualCursor: [0, 0],
viewportVisualLines: [''],
visualScrollRow: 0,
pastedContent: {} as Record<string, string>,
handleInput: vi.fn((key) => {
if (key.sequence) {
@@ -408,4 +415,36 @@ describe('TextInput', () => {
expect(lastFrame()).toContain('line2');
unmount();
});
it('registers mouse click handler for free-form text input', async () => {
const { unmount } = await render(
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
);
expect(mockedUseMouseClick).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Function),
expect.objectContaining({ isActive: true, name: 'left-press' }),
);
unmount();
});
it('registers mouse click handler for placeholder view', async () => {
mockBuffer.text = '';
const { unmount } = await render(
<TextInput
buffer={mockBuffer}
placeholder="test"
onSubmit={onSubmit}
onCancel={onCancel}
/>,
);
expect(mockedUseMouseClick).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Function),
expect.objectContaining({ isActive: true, name: 'left-press' }),
);
unmount();
});
});
@@ -5,8 +5,8 @@
*/
import type React from 'react';
import { useCallback } from 'react';
import { Text, Box } from 'ink';
import { useCallback, useRef } from 'react';
import { Text, Box, type DOMElement } from 'ink';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import chalk from 'chalk';
import { theme } from '../../semantic-colors.js';
@@ -14,6 +14,7 @@ import { expandPastePlaceholders, type TextBuffer } from './text-buffer.js';
import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js';
import { Command } from '../../key/keyMatchers.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
import { useMouseClick } from '../../hooks/useMouseClick.js';
export interface TextInputProps {
buffer: TextBuffer;
@@ -31,6 +32,8 @@ export function TextInput({
focus = true,
}: TextInputProps): React.JSX.Element {
const keyMatchers = useKeyMatchers();
const containerRef = useRef<DOMElement>(null);
const {
text,
handleInput,
@@ -40,6 +43,17 @@ export function TextInput({
} = buffer;
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] = visualCursor;
useMouseClick(
containerRef,
(_event, relativeX, relativeY) => {
if (focus) {
const visRowAbsolute = visualScrollRow + relativeY;
buffer.moveToVisualPosition(visRowAbsolute, relativeX);
}
},
{ isActive: focus, name: 'left-press' },
);
const handleKeyPress = useCallback(
(key: Key) => {
if (key.name === 'escape' && onCancel) {
@@ -64,7 +78,7 @@ export function TextInput({
if (showPlaceholder) {
return (
<Box>
<Box ref={containerRef}>
{focus ? (
<Text terminalCursorFocus={focus} terminalCursorPosition={0}>
{chalk.inverse(placeholder[0] || ' ')}
@@ -78,7 +92,7 @@ export function TextInput({
}
return (
<Box flexDirection="column">
<Box ref={containerRef} flexDirection="column">
{viewportVisualLines.map((lineText, idx) => {
const currentVisualRow = visualScrollRow + idx;
const isCursorLine =
@@ -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;
@@ -858,11 +858,81 @@ describe('useSlashCommandProcessor', () => {
});
describe('Lifecycle', () => {
it('removes the IDE status listener on unmount after async initialization', async () => {
let resolveIdeClient:
| ((client: {
addStatusChangeListener: (listener: () => void) => void;
removeStatusChangeListener: (listener: () => void) => void;
}) => void)
| undefined;
const addStatusChangeListener = vi.fn();
const removeStatusChangeListener = vi.fn();
mockIdeClientGetInstance.mockImplementation(
() =>
new Promise((resolve) => {
resolveIdeClient = resolve;
}),
);
const result = await setupProcessorHook();
await act(async () => {
resolveIdeClient?.({
addStatusChangeListener,
removeStatusChangeListener,
});
});
result.unmount();
unmountHook = undefined;
expect(addStatusChangeListener).toHaveBeenCalledTimes(1);
expect(removeStatusChangeListener).toHaveBeenCalledTimes(1);
expect(removeStatusChangeListener).toHaveBeenCalledWith(
addStatusChangeListener.mock.calls[0]?.[0],
);
});
it('does not register an IDE status listener if unmounted before async initialization resolves', async () => {
let resolveIdeClient:
| ((client: {
addStatusChangeListener: (listener: () => void) => void;
removeStatusChangeListener: (listener: () => void) => void;
}) => void)
| undefined;
const addStatusChangeListener = vi.fn();
const removeStatusChangeListener = vi.fn();
mockIdeClientGetInstance.mockImplementation(
() =>
new Promise((resolve) => {
resolveIdeClient = resolve;
}),
);
const result = await setupProcessorHook();
result.unmount();
unmountHook = undefined;
await act(async () => {
resolveIdeClient?.({
addStatusChangeListener,
removeStatusChangeListener,
});
});
expect(addStatusChangeListener).not.toHaveBeenCalled();
expect(removeStatusChangeListener).not.toHaveBeenCalled();
});
it('should abort command loading when the hook unmounts', async () => {
const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
const { unmount } = await setupProcessorHook();
unmount();
unmountHook = undefined;
expect(abortSpy).toHaveBeenCalledTimes(1);
});
@@ -281,10 +281,16 @@ export const useSlashCommandProcessor = (
const listener = () => {
reloadCommands();
};
let isActive = true;
let activeIdeClient: IdeClient | undefined;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(async () => {
const ideClient = await IdeClient.getInstance();
if (!isActive) {
return;
}
activeIdeClient = ideClient;
ideClient.addStatusChangeListener(listener);
})();
@@ -307,11 +313,8 @@ export const useSlashCommandProcessor = (
coreEvents.on('extensionsStopping', extensionEventListener);
return () => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(async () => {
const ideClient = await IdeClient.getInstance();
ideClient.removeStatusChangeListener(listener);
})();
isActive = false;
activeIdeClient?.removeStatusChangeListener(listener);
removeMCPStatusChangeListener(listener);
coreEvents.off('extensionsStarting', extensionEventListener);
coreEvents.off('extensionsStopping', extensionEventListener);
+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(
@@ -76,7 +76,23 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
isBinary: mockIsBinary,
};
});
vi.mock('node:fs');
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
const mocked = {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
createWriteStream: vi.fn(),
promises: {
...actual.promises,
unlink: vi.fn().mockResolvedValue(undefined),
},
};
return {
...mocked,
default: mocked,
};
});
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
const mocked = {
@@ -144,6 +160,10 @@ describe('useExecutionLifecycle', () => {
enableEnvironmentVariableRedaction: false,
},
}),
getTruncateToolOutputThreshold: () => 40000,
storage: {
getProjectTempDir: () => '/tmp/project',
},
} as unknown as Config;
mockGeminiClient = { addHistory: vi.fn() } as unknown as GeminiClient;
@@ -155,6 +175,16 @@ describe('useExecutionLifecycle', () => {
mockIsBinary.mockReturnValue(false);
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(fs.createWriteStream).mockReturnValue({
write: vi.fn(),
end: vi.fn().mockImplementation((cb: () => void) => {
if (cb) cb();
}),
destroy: vi.fn(),
bytesWritten: 0,
closed: false,
} as unknown as fs.WriteStream);
mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => {
mockShellOutputCallback = callback;
return Promise.resolve({
@@ -646,7 +676,7 @@ describe('useExecutionLifecycle', () => {
});
const tmpFile = path.join(os.tmpdir(), 'shell_pwd_abcdef.tmp');
// Verify that the temporary file was cleaned up
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
expect(vi.mocked(fs.promises.unlink)).toHaveBeenCalledWith(tmpFile);
expect(setShellInputFocusedMock).toHaveBeenCalledWith(false);
});
@@ -674,7 +704,7 @@ describe('useExecutionLifecycle', () => {
expect(finalHistoryItem.tools[0].resultDisplay).toContain(
"WARNING: shell mode is stateless; the directory change to '/test/dir/new' will not persist.",
);
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
expect(vi.mocked(fs.promises.unlink)).toHaveBeenCalledWith(tmpFile);
});
it('should NOT show a warning if the directory does not change', async () => {
@@ -20,6 +20,8 @@ import {
ShellExecutionService,
ExecutionLifecycleService,
CoreToolCallStatus,
moveToolOutputToFile,
debugLogger,
} from '@google/gemini-cli-core';
import { type PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -39,16 +41,15 @@ export { type BackgroundTask };
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
const RESTORE_VISIBILITY_DELAY_MS = 300;
const MAX_OUTPUT_LENGTH = 10000;
function addShellCommandToGeminiHistory(
geminiClient: GeminiClient,
rawQuery: string,
resultText: string,
maxOutputLength: number,
) {
const modelContent =
resultText.length > MAX_OUTPUT_LENGTH
? resultText.substring(0, MAX_OUTPUT_LENGTH) + '\n... (truncated)'
maxOutputLength > 0 && resultText.length > maxOutputLength
? resultText.substring(0, maxOutputLength) + '\n... (truncated)'
: resultText;
// Escape backticks to prevent prompt injection breakouts
@@ -424,6 +425,9 @@ export const useExecutionLifecycle = (
let shouldUpdate = false;
switch (event.type) {
case 'raw_data':
case 'file_data':
break;
case 'data':
if (isBinaryStream) break;
if (typeof event.chunk === 'string') {
@@ -533,6 +537,24 @@ export const useExecutionLifecycle = (
} else {
mainContent =
result.output.trim() || '(Command produced no output)';
if (result.fullOutputFilePath) {
const { outputFile: savedPath } = await moveToolOutputToFile(
result.fullOutputFilePath,
SHELL_COMMAND_NAME,
callId,
config.storage.getProjectTempDir(),
config.getSessionId(),
);
const warning = `[Full command output saved to: ${savedPath}]`;
mainContent = mainContent.includes(
'[GEMINI_CLI_WARNING: Output truncated.',
)
? mainContent.replace(
/\[GEMINI_CLI_WARNING: Output truncated\..*?\]/,
warning,
)
: `${mainContent}\n\n${warning}`;
}
}
let finalOutput: string | AnsiOutput =
@@ -617,7 +639,12 @@ export const useExecutionLifecycle = (
);
}
addShellCommandToGeminiHistory(geminiClient, rawQuery, mainContent);
addShellCommandToGeminiHistory(
geminiClient,
rawQuery,
mainContent,
config.getTruncateToolOutputThreshold(),
);
} catch (err) {
setPendingHistoryItem(null);
const errorMessage = err instanceof Error ? err.message : String(err);
@@ -630,8 +657,13 @@ export const useExecutionLifecycle = (
);
} finally {
abortSignal.removeEventListener('abort', abortHandler);
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
fs.unlinkSync(pwdFilePath);
if (pwdFilePath) {
fs.promises.unlink(pwdFilePath).catch((err) => {
debugLogger.warn(
`Failed to cleanup pwd file: ${pwdFilePath}`,
err,
);
});
}
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
@@ -0,0 +1,147 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ColorsTheme, Theme } from '../../theme.js';
import { interpolateColor } from '../../color-utils.js';
const githubDarkColorblindColors: ColorsTheme = {
type: 'dark',
Background: '#0d1117',
Foreground: '#e6edf3',
LightBlue: '#a5d6ff',
AccentBlue: '#79c0ff',
AccentPurple: '#d2a8ff',
AccentCyan: '#a5d6ff',
AccentGreen: '#a5d6ff',
AccentYellow: '#d29922',
AccentRed: '#f0883e',
DiffAdded: '#0d161f',
DiffRemoved: '#1d150e',
Comment: '#7d8590',
Gray: '#7d8590',
DarkGray: interpolateColor('#7d8590', '#0d1117', 0.5),
GradientColors: ['#58a6ff', '#f0883e'],
};
export const GitHubDarkColorblind: Theme = new Theme(
'GitHub Dark Colorblind',
'dark',
{
hljs: {
display: 'block',
overflowX: 'auto',
padding: '0.5em',
color: githubDarkColorblindColors.Foreground,
background: githubDarkColorblindColors.Background,
},
'hljs-comment': {
color: githubDarkColorblindColors.Comment,
fontStyle: 'italic',
},
'hljs-quote': {
color: githubDarkColorblindColors.Comment,
fontStyle: 'italic',
},
'hljs-keyword': {
color: githubDarkColorblindColors.AccentRed,
fontWeight: 'bold',
},
'hljs-selector-tag': {
color: githubDarkColorblindColors.AccentRed,
fontWeight: 'bold',
},
'hljs-subst': {
color: githubDarkColorblindColors.Foreground,
},
'hljs-number': {
color: githubDarkColorblindColors.LightBlue,
},
'hljs-literal': {
color: githubDarkColorblindColors.LightBlue,
},
'hljs-variable': {
color: githubDarkColorblindColors.Foreground,
},
'hljs-template-variable': {
color: githubDarkColorblindColors.Foreground,
},
'hljs-tag .hljs-attr': {
color: githubDarkColorblindColors.AccentYellow,
},
'hljs-string': {
color: githubDarkColorblindColors.AccentCyan,
},
'hljs-doctag': {
color: githubDarkColorblindColors.AccentCyan,
},
'hljs-title': {
color: githubDarkColorblindColors.AccentPurple,
fontWeight: 'bold',
},
'hljs-section': {
color: githubDarkColorblindColors.AccentPurple,
fontWeight: 'bold',
},
'hljs-selector-id': {
color: githubDarkColorblindColors.AccentPurple,
fontWeight: 'bold',
},
'hljs-type': {
color: githubDarkColorblindColors.AccentGreen,
fontWeight: 'bold',
},
'hljs-class .hljs-title': {
color: githubDarkColorblindColors.AccentGreen,
fontWeight: 'bold',
},
'hljs-tag': {
color: githubDarkColorblindColors.AccentGreen,
},
'hljs-name': {
color: githubDarkColorblindColors.AccentGreen,
},
'hljs-attribute': {
color: githubDarkColorblindColors.LightBlue,
},
'hljs-regexp': {
color: githubDarkColorblindColors.AccentCyan,
},
'hljs-link': {
color: githubDarkColorblindColors.AccentCyan,
},
'hljs-symbol': {
color: githubDarkColorblindColors.AccentPurple,
},
'hljs-bullet': {
color: githubDarkColorblindColors.AccentPurple,
},
'hljs-built_in': {
color: githubDarkColorblindColors.LightBlue,
},
'hljs-builtin-name': {
color: githubDarkColorblindColors.LightBlue,
},
'hljs-meta': {
color: githubDarkColorblindColors.LightBlue,
fontWeight: 'bold',
},
'hljs-deletion': {
background: '#682d0f',
color: githubDarkColorblindColors.AccentRed,
},
'hljs-addition': {
background: '#0c2d6b',
color: githubDarkColorblindColors.AccentGreen,
},
'hljs-emphasis': {
fontStyle: 'italic',
},
'hljs-strong': {
fontWeight: 'bold',
},
},
githubDarkColorblindColors,
);
@@ -0,0 +1,147 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ColorsTheme, Theme } from '../../theme.js';
import { interpolateColor } from '../../color-utils.js';
const githubLightColorblindColors: ColorsTheme = {
type: 'light',
Background: '#ffffff',
Foreground: '#1f2328',
LightBlue: '#0a3069',
AccentBlue: '#0550ae',
AccentPurple: '#8250df',
AccentCyan: '#0a3069',
AccentGreen: '#0969da',
AccentYellow: '#9a6700',
AccentRed: '#bc4c00',
DiffAdded: '#ddf4ff',
DiffRemoved: '#fff1e5',
Comment: '#656d76',
Gray: '#656d76',
DarkGray: interpolateColor('#656d76', '#ffffff', 0.5),
GradientColors: ['#0969da', '#bc4c00'],
};
export const GitHubLightColorblind: Theme = new Theme(
'GitHub Light Colorblind',
'light',
{
hljs: {
display: 'block',
overflowX: 'auto',
padding: '0.5em',
color: githubLightColorblindColors.Foreground,
background: githubLightColorblindColors.Background,
},
'hljs-comment': {
color: githubLightColorblindColors.Comment,
fontStyle: 'italic',
},
'hljs-quote': {
color: githubLightColorblindColors.Comment,
fontStyle: 'italic',
},
'hljs-keyword': {
color: githubLightColorblindColors.AccentRed,
fontWeight: 'bold',
},
'hljs-selector-tag': {
color: githubLightColorblindColors.AccentRed,
fontWeight: 'bold',
},
'hljs-subst': {
color: githubLightColorblindColors.Foreground,
},
'hljs-number': {
color: githubLightColorblindColors.LightBlue,
},
'hljs-literal': {
color: githubLightColorblindColors.LightBlue,
},
'hljs-variable': {
color: githubLightColorblindColors.Foreground,
},
'hljs-template-variable': {
color: githubLightColorblindColors.Foreground,
},
'hljs-tag .hljs-attr': {
color: githubLightColorblindColors.AccentYellow,
},
'hljs-string': {
color: githubLightColorblindColors.AccentCyan,
},
'hljs-doctag': {
color: githubLightColorblindColors.AccentCyan,
},
'hljs-title': {
color: githubLightColorblindColors.AccentPurple,
fontWeight: 'bold',
},
'hljs-section': {
color: githubLightColorblindColors.AccentPurple,
fontWeight: 'bold',
},
'hljs-selector-id': {
color: githubLightColorblindColors.AccentPurple,
fontWeight: 'bold',
},
'hljs-type': {
color: githubLightColorblindColors.AccentGreen,
fontWeight: 'bold',
},
'hljs-class .hljs-title': {
color: githubLightColorblindColors.AccentGreen,
fontWeight: 'bold',
},
'hljs-tag': {
color: githubLightColorblindColors.AccentGreen,
},
'hljs-name': {
color: githubLightColorblindColors.AccentGreen,
},
'hljs-attribute': {
color: githubLightColorblindColors.LightBlue,
},
'hljs-regexp': {
color: githubLightColorblindColors.AccentCyan,
},
'hljs-link': {
color: githubLightColorblindColors.AccentCyan,
},
'hljs-symbol': {
color: githubLightColorblindColors.AccentPurple,
},
'hljs-bullet': {
color: githubLightColorblindColors.AccentPurple,
},
'hljs-built_in': {
color: githubLightColorblindColors.LightBlue,
},
'hljs-builtin-name': {
color: githubLightColorblindColors.LightBlue,
},
'hljs-meta': {
color: githubLightColorblindColors.LightBlue,
fontWeight: 'bold',
},
'hljs-deletion': {
background: '#fff1e5',
color: githubLightColorblindColors.AccentRed,
},
'hljs-addition': {
background: '#ddf4ff',
color: githubLightColorblindColors.AccentGreen,
},
'hljs-emphasis': {
fontStyle: 'italic',
},
'hljs-strong': {
fontWeight: 'bold',
},
},
githubLightColorblindColors,
);
@@ -10,6 +10,8 @@ import { AtomOneDark } from './builtin/dark/atom-one-dark.js';
import { Dracula } from './builtin/dark/dracula-dark.js';
import { GitHubDark } from './builtin/dark/github-dark.js';
import { GitHubLight } from './builtin/light/github-light.js';
import { GitHubDarkColorblind } from './builtin/dark/github-dark-colorblind.js';
import { GitHubLightColorblind } from './builtin/light/github-light-colorblind.js';
import { GoogleCode } from './builtin/light/googlecode-light.js';
import { Holiday } from './builtin/dark/holiday-dark.js';
import { DefaultLight } from './builtin/light/default-light.js';
@@ -79,6 +81,8 @@ class ThemeManager {
DefaultDark,
GitHubDark,
GitHubLight,
GitHubDarkColorblind,
GitHubLightColorblind,
GoogleCode,
Holiday,
ShadesOfPurple,
+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).
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -20,7 +20,8 @@
"typecheck": "tsc --noEmit"
},
"files": [
"dist"
"dist",
"vendor"
],
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -31,7 +32,6 @@
"@google/genai": "1.30.0",
"@grpc/grpc-js": "^1.14.3",
"@iarna/toml": "^2.2.5",
"@joshua.litt/get-ripgrep": "^0.0.3",
"@modelcontextprotocol/sdk": "^1.23.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.211.0",
@@ -59,6 +59,7 @@
"diff": "^8.0.3",
"dotenv": "^17.2.4",
"dotenv-expand": "^12.0.3",
"execa": "^9.6.1",
"fast-levenshtein": "^2.0.6",
"fdir": "^6.4.6",
"fzf": "^0.5.2",
@@ -68,6 +69,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",
@@ -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. */
@@ -493,6 +493,42 @@ Body`);
});
});
it('should convert mcp_servers with auth block in local agent (google-credentials)', () => {
const markdown = {
kind: 'local' as const,
name: 'spanner-test-agent',
description: 'An agent to test Spanner MCP with auth',
mcp_servers: {
spanner: {
url: 'https://spanner.googleapis.com/mcp',
type: 'http' as const,
auth: {
type: 'google-credentials' as const,
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
},
timeout: 30000,
},
},
system_prompt: 'You are a Spanner test agent.',
};
const result = markdownToAgentDefinition(
markdown,
) as LocalAgentDefinition;
expect(result.kind).toBe('local');
expect(result.mcpServers).toBeDefined();
expect(result.mcpServers!['spanner']).toMatchObject({
url: 'https://spanner.googleapis.com/mcp',
type: 'http',
authProviderType: 'google_credentials',
oauth: {
enabled: true,
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
},
timeout: 30000,
});
});
it('should pass through unknown model names (e.g. auto)', () => {
const markdown = {
kind: 'local' as const,
+53 -5
View File
@@ -17,7 +17,11 @@ import {
DEFAULT_MAX_TIME_MINUTES,
} from './types.js';
import type { A2AAuthConfig } from './auth-provider/types.js';
import { MCPServerConfig } from '../config/config.js';
import {
MCPServerConfig,
AuthProviderType,
type MCPOAuthConfig,
} from '../config/config.js';
import { isValidToolName } from '../tools/tool-names.js';
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
import { getErrorMessage } from '../utils/errors.js';
@@ -62,6 +66,22 @@ const mcpServerSchema = z.object({
description: z.string().optional(),
include_tools: z.array(z.string()).optional(),
exclude_tools: z.array(z.string()).optional(),
auth: z
.union([
z.object({
type: z.literal('google-credentials'),
scopes: z.array(z.string()).optional(),
}),
z.object({
type: z.literal('oauth'),
client_id: z.string().optional(),
client_secret: z.string().optional(),
scopes: z.array(z.string()).optional(),
authorization_url: z.string().url().optional(),
token_url: z.string().url().optional(),
}),
])
.optional(),
});
const localAgentSchema = z
@@ -74,9 +94,12 @@ const localAgentSchema = z
.array(
z
.string()
.refine((val) => isValidToolName(val, { allowWildcards: true }), {
message: 'Invalid tool name',
}),
.refine(
(val: string) => isValidToolName(val, { allowWildcards: true }),
{
message: 'Invalid tool name',
},
),
)
.optional(),
mcp_servers: z.record(mcpServerSchema).optional(),
@@ -191,7 +214,7 @@ const remoteAgentJsonSchema = baseRemoteAgentSchema
.extend({
agent_card_url: z.undefined().optional(),
agent_card_json: z.string().refine(
(val) => {
(val: string) => {
try {
JSON.parse(val);
return true;
@@ -511,6 +534,28 @@ export function markdownToAgentDefinition(
const mcpServers: Record<string, MCPServerConfig> = {};
if (markdown.mcp_servers) {
for (const [name, config] of Object.entries(markdown.mcp_servers)) {
let authProviderType: AuthProviderType | undefined = undefined;
let oauth: MCPOAuthConfig | undefined = undefined;
if (config.auth) {
if (config.auth.type === 'google-credentials') {
authProviderType = AuthProviderType.GOOGLE_CREDENTIALS;
oauth = {
enabled: true,
scopes: config.auth.scopes,
};
} else if (config.auth.type === 'oauth') {
oauth = {
enabled: true,
clientId: config.auth.client_id,
clientSecret: config.auth.client_secret,
scopes: config.auth.scopes,
authorizationUrl: config.auth.authorization_url,
tokenUrl: config.auth.token_url,
};
}
}
mcpServers[name] = new MCPServerConfig(
config.command,
config.args,
@@ -526,6 +571,9 @@ export function markdownToAgentDefinition(
config.description,
config.include_tools,
config.exclude_tools,
undefined, // extension
oauth,
authProviderType,
);
}
}
@@ -0,0 +1,90 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { SkillExtractionAgent } from './skill-extraction-agent.js';
import {
EDIT_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
} from '../tools/tool-names.js';
import { PREVIEW_GEMINI_FLASH_MODEL } from '../config/models.js';
describe('SkillExtractionAgent', () => {
const skillsDir = '/tmp/skills';
const sessionIndex =
'[NEW] Debug login flow (12 user msgs) — /tmp/chats/session-1.json';
const existingSkillsSummary =
'## Workspace Skills (.gemini/skills — do NOT duplicate)\n- **existing-skill**: Existing description';
const agent = SkillExtractionAgent(
skillsDir,
sessionIndex,
existingSkillsSummary,
);
it('should expose expected metadata, model, and tools', () => {
expect(agent.kind).toBe('local');
expect(agent.name).toBe('confucius');
expect(agent.displayName).toBe('Skill Extractor');
expect(agent.modelConfig.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
expect(agent.toolConfig?.tools).toEqual(
expect.arrayContaining([
READ_FILE_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME,
LS_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
]),
);
});
it('should default to no skill unless recurrence and durability are proven', () => {
const prompt = agent.promptConfig.systemPrompt;
expect(prompt).toContain('Default to NO SKILL.');
expect(prompt).toContain(
'strong evidence this will recur for future agents in this repo/workflow',
);
expect(prompt).toContain('broader than a single incident');
expect(prompt).toContain('A skill MUST meet ALL of these criteria:');
expect(prompt).toContain(
'Future agents in this repo/workflow are likely to need it',
);
});
it('should explicitly reject one-off incidents and single-session preferences', () => {
const prompt = agent.promptConfig.systemPrompt;
expect(prompt).toContain('Single-session preferences');
expect(prompt).toContain('One-off incidents');
expect(prompt).toContain('Output-style preferences');
expect(prompt).toContain('cannot survive renaming the specific');
});
it('should warn that session summaries are user-intent summaries, not workflow evidence', () => {
const query = agent.promptConfig.query ?? '';
expect(query).toContain(existingSkillsSummary);
expect(query).toContain(sessionIndex);
expect(query).toContain(
'The summary is a user-intent summary, not a workflow summary.',
);
expect(query).toContain(
'The session summaries describe user intent, not workflow details.',
);
expect(query).toContain(
'Only write a skill if the evidence shows a durable, recurring workflow',
);
expect(query).toContain(
'If recurrence or future reuse is unclear, create no skill and explain why.',
);
});
});
@@ -7,11 +7,13 @@
import { z } from 'zod';
import type { LocalAgentDefinition } from './types.js';
import {
ACTIVATE_SKILL_TOOL_NAME,
EDIT_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
} from '../tools/tool-names.js';
import { PREVIEW_GEMINI_FLASH_MODEL } from '../config/models.js';
@@ -36,7 +38,7 @@ function buildSystemPrompt(skillsDir: string): string {
'- solve similar tasks with fewer tool calls and fewer reasoning tokens',
'- reuse proven workflows and verification checklists',
'- avoid known failure modes and landmines',
'- anticipate user preferences without being reminded',
'- capture durable workflow constraints that future agents are likely to encounter again',
'',
'============================================================',
'SAFETY AND HYGIENE (STRICT)',
@@ -59,6 +61,10 @@ function buildSystemPrompt(skillsDir: string): string {
'1. "Is this something a competent agent would NOT already know?" If no, STOP.',
'2. "Does an existing skill (listed below) already cover this?" If yes, STOP.',
'3. "Can I write a concrete, step-by-step procedure?" If no, STOP.',
'4. "Is there strong evidence this will recur for future agents in this repo/workflow?" If no, STOP.',
'5. "Is this broader than a single incident (one bug, one ticket, one branch, one date, one exact error)?" If no, STOP.',
'',
'Default to NO SKILL.',
'',
'Do NOT create skills for:',
'',
@@ -67,6 +73,10 @@ function buildSystemPrompt(skillsDir: string): string {
'- **Pure Q&A**: The user asked "how does X work?" and got an answer. No procedure.',
'- **Brainstorming/design**: Discussion of how to build something, without a validated',
' implementation that produced a reusable procedure.',
'- **Single-session preferences**: User-specific style/output preferences or workflow',
' preferences mentioned only once.',
'- **One-off incidents**: Debugging or incident response tied to a single bug, ticket,',
' branch, date, or exact error string.',
'- **Anything already covered by an existing skill** (global, workspace, builtin, or',
' previously extracted). Check the "Existing Skills" section carefully.',
'',
@@ -74,31 +84,40 @@ function buildSystemPrompt(skillsDir: string): string {
'WHAT COUNTS AS A SKILL',
'============================================================',
'',
'A skill MUST meet BOTH of these criteria:',
'A skill MUST meet ALL of these criteria:',
'',
'1. **Procedural and concrete**: It can be expressed as numbered steps with specific',
' commands, paths, or code patterns. If you can only write vague guidance, it is NOT',
' a skill. "Be careful with X" is advice, not a skill.',
'',
'2. **Non-obvious and project-specific**: A competent agent would NOT already know this.',
' It encodes project-specific knowledge, non-obvious ordering constraints, or',
' hard-won failure shields that cannot be inferred from the codebase alone.',
'2. **Durable and reusable**: Future agents in this repo/workflow are likely to need it',
' again. If it only solved one incident, it is NOT a skill.',
'',
'Confidence tiers (prefer higher tiers):',
'3. **Evidence-backed and project-specific**: It encodes project-specific knowledge,',
' repeated operational constraints, or hard-won failure shields supported by session',
' evidence. Do not assume something is non-obvious just because it sounds detailed.',
'',
'**High confidence** — create the skill:',
'- The same workflow appeared in multiple sessions (cross-session repetition)',
'- A multi-step procedure was validated (tests passed, user confirmed success)',
'Confidence tiers:',
'',
'**Medium confidence** — create the skill if it is clearly project-specific:',
'- A project-specific build/test/deploy/release procedure was established',
'- A non-obvious ordering constraint or prerequisite was discovered',
'- A failure mode was hit and a concrete fix was found and verified',
'**High confidence** — create the skill only when recurrence/durability is clear:',
'- The same workflow appeared in multiple sessions (cross-session repetition), OR it is',
' a stable recurring repo workflow (for example setup/build/test/deploy/release) with a',
' clear future trigger',
'- The workflow was validated (tests passed, user confirmed success, or the same fix',
' worked repeatedly)',
'- The skill can be named without referencing a specific incident, bug, branch, or date',
'',
'**Medium confidence** — usually do NOT create the skill yet:',
'- A project-specific procedure appeared once and seems useful, but recurrence is not yet',
' clear',
'- A verified fix exists, but it is still tied to one incident',
'- A user correction changed the approach once, but durability is uncertain',
'',
'**Low confidence** — do NOT create the skill:',
'- A one-off debugging session with no reusable procedure',
'- Generic workflows any agent could figure out from the codebase',
'- A code review or investigation with no durable takeaway',
'- Output-style preferences that do not materially change procedure',
'',
'Aim for 0-2 skills per run. Quality over quantity.',
'',
@@ -117,8 +136,10 @@ function buildSystemPrompt(skillsDir: string): string {
'',
'What to look for:',
'',
'- User corrections: "No, do it this way" -> preference signal',
'- User corrections that change procedure in a durable way, especially when repeated',
' across sessions',
'- Repeated patterns across sessions: same commands, same file paths, same workflow',
'- Stable recurring repo lifecycle workflows with clear future triggers',
'- Failed attempts followed by successful ones -> failure shield',
'- Multi-step procedures that were validated (tests passed, user confirmed)',
'- User interruptions: "Stop, you need to X first" -> ordering constraint',
@@ -129,45 +150,45 @@ function buildSystemPrompt(skillsDir: string): string {
'- Tool outputs that are just data (file contents, search results)',
'- Speculative plans that were never executed',
"- Temporary context (current branch name, today's date, specific error IDs)",
'- Similar session summaries without matching workflow evidence',
'- One-off artifact names: bug IDs, branch names, timestamps, exact incident strings',
'',
'============================================================',
'SKILL FORMAT',
'UPDATING EXISTING SKILLS (PATCHES)',
'============================================================',
'',
'Each skill is a directory containing a SKILL.md file with YAML frontmatter',
'and optional supporting scripts.',
'You can ONLY write files inside your skills directory. However, existing skills',
'may live outside it (global or workspace locations).',
'',
'Directory structure:',
` ${skillsDir}/<skill-name>/`,
' SKILL.md # Required entrypoint',
' scripts/<tool>.* # Optional helper scripts (Python stdlib-only or shell)',
'NEVER patch builtin or extension skills. They are managed externally and',
'overwritten on updates. Patches targeting these paths will be rejected.',
'',
'SKILL.md structure:',
'To propose an update to an existing skill that lives OUTSIDE your directory:',
'',
' ---',
' name: <skill-name>',
' description: <1-2 lines; include concrete triggers in user-like language>',
' ---',
'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`,
'',
' ## When to Use',
' <Clear trigger conditions and non-goals>',
'Patch format (strict unified diff):',
'',
' ## Procedure',
' <Numbered steps with specific commands, paths, code patterns>',
' --- /absolute/path/to/original/SKILL.md',
' +++ /absolute/path/to/original/SKILL.md',
' @@ -<start>,<count> +<start>,<count> @@',
' <context line>',
' -<removed line>',
' +<added line>',
' <context line>',
'',
' ## Pitfalls and Fixes',
' <symptom -> likely cause -> fix; only include observed failures>',
'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.',
'',
' ## Verification',
' <Concrete success checks>',
'',
'Supporting scripts (optional but recommended when applicable):',
'- Put helper scripts in scripts/ and reference them from SKILL.md',
'- Prefer Python (stdlib only) or small shell scripts',
'- Make scripts safe: no destructive actions, no secrets, deterministic output',
'- Include a usage example in SKILL.md',
'',
'Naming: kebab-case (e.g., fix-lint-errors, run-migrations).',
'The same quality bar applies: only propose updates backed by evidence from sessions.',
'',
'============================================================',
'QUALITY RULES (STRICT)',
@@ -177,7 +198,10 @@ function buildSystemPrompt(skillsDir: string): string {
'- Keep scopes distinct. Avoid overlapping "do-everything" skills.',
'- Every skill MUST have: triggers, procedure, at least one pitfall or verification step.',
'- If you cannot write a reliable procedure (too many unknowns), do NOT create the skill.',
'- Do not create skills for generic advice that any competent agent would already know.',
'- If the candidate is tied to one incident or cannot survive renaming the specific',
' bug/ticket, do NOT create it.',
'- Do not create skills for generic advice, output-style preferences, or ephemeral',
' choices that any competent agent would already know or adapt to on the fly.',
'- Prefer fewer, higher-quality skills. 0-2 skills per run is typical. 3+ is unusual.',
'',
'============================================================',
@@ -186,17 +210,32 @@ function buildSystemPrompt(skillsDir: string): string {
'',
`1. Use list_directory on ${skillsDir} to see existing skills.`,
'2. If skills exist, read their SKILL.md files to understand what is already captured.',
'3. Scan the session index provided in the query. Look for [NEW] sessions whose summaries',
' suggest workflows that ALSO appear in other sessions (either [NEW] or [old]).',
'4. Apply the minimum signal gate. If no repeated patterns are visible, report that and finish.',
'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.',
'8. Write COMPLETE files — never partially update a SKILL.md.',
'3. Use activate_skill to load the "skill-creator" skill. Follow its design guidance',
' (conciseness, progressive disclosure, frontmatter format, bundled resources) when',
' writing SKILL.md files. You may also use its init_skill.cjs script to scaffold new',
' skill directories and package_skill.cjs to validate finished skills.',
' IMPORTANT: You are a background agent with no user interaction. Skip any interactive',
' steps in the skill-creator guide (asking clarifying questions, requesting user feedback,',
' installation prompts, iteration loops). Use only its format and quality guidance.',
'4. Scan the session index provided in the query. Look for [NEW] sessions whose summaries',
' hint at workflows that ALSO appear in other sessions (either [NEW] or [old]) or at a',
' stable recurring repo workflow. Remember: summary similarity alone is NOT enough.',
'5. Apply the minimum signal gate. If recurrence or durability is not visible, report that',
' no skill should be created and finish.',
'6. For promising patterns, use read_file on the session file paths to inspect the full',
' conversation. Confirm the workflow was actually repeated and validated. Read at least',
' two sessions unless the candidate is clearly a stable recurring repo lifecycle workflow.',
'7. For each candidate, verify it meets ALL criteria. Before writing, make sure you can',
' state: future trigger, evidence sessions, recurrence signal, validation signal, and',
' why it is not generic.',
'8. Write new SKILL.md files or update existing ones in your directory.',
' Use run_shell_command to run init_skill.cjs for scaffolding and package_skill.cjs for validation.',
' For skills that live OUTSIDE your directory, write a .patch file instead (see UPDATING EXISTING SKILLS).',
'9. Write COMPLETE files — never partially update a SKILL.md.',
'',
'IMPORTANT: Do NOT read every session. Only read sessions whose summaries suggest a',
'repeated pattern worth investigating. Most runs should read 0-3 sessions and create 0 skills.',
'repeated pattern or a stable recurring repo workflow worth investigating. Most runs',
'should read 0-3 sessions and create 0 skills.',
'Do not explore the codebase. Work only with the session index, session files, and the skills directory.',
].join('\n');
}
@@ -206,8 +245,9 @@ function buildSystemPrompt(skillsDir: string): string {
* writes reusable SKILL.md files to the project memory directory.
*
* This agent is designed to run in the background on session startup.
* It has restricted tool access (file tools only, no shell or user interaction)
* and is prompted to only operate within the skills memory directory.
* It has restricted tool access (file tools, shell, and skill activation no
* user interaction) and is prompted to only operate within the skills memory
* directory.
*/
export const SkillExtractionAgent = (
skillsDir: string,
@@ -241,12 +281,14 @@ export const SkillExtractionAgent = (
},
toolConfig: {
tools: [
ACTIVATE_SKILL_TOOL_NAME,
READ_FILE_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME,
LS_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
SHELL_TOOL_NAME,
],
},
get promptConfig() {
@@ -263,6 +305,9 @@ export const SkillExtractionAgent = (
'Below is an index of past conversation sessions. Each line shows:',
'[NEW] or [old] status, a 1-line summary, message count, and the file path.',
'',
'The summary is a user-intent summary, not a workflow summary.',
'Matching summary text alone is never enough evidence for a reusable skill.',
'',
'[NEW] = not yet processed for skill extraction (focus on these)',
'[old] = previously processed (read only if a [NEW] session hints at a repeated pattern)',
'',
@@ -281,7 +326,7 @@ export const SkillExtractionAgent = (
return {
systemPrompt: buildSystemPrompt(skillsDir),
query: `${initialContext}\n\nAnalyze the session index above. Read sessions that suggest repeated workflows using read_file. Extract reusable skills to ${skillsDir}/.`,
query: `${initialContext}\n\nAnalyze the session index above. The session summaries describe user intent, not workflow details. Read sessions that suggest repeated workflows using read_file. Only write a skill if the evidence shows a durable, recurring workflow or a stable recurring repo procedure. If recurrence or future reuse is unclear, create no skill and explain why.`,
};
},
runConfig: {
@@ -41,7 +41,7 @@ const DEFAULT_ACTIONS: ModelPolicyActionMap = {
unknown: 'prompt',
};
const SILENT_ACTIONS: ModelPolicyActionMap = {
export const SILENT_ACTIONS: ModelPolicyActionMap = {
terminal: 'silent',
transient: 'silent',
not_found: 'silent',
@@ -10,7 +10,7 @@ import {
buildFallbackPolicyContext,
applyModelSelection,
} from './policyHelpers.js';
import { createDefaultPolicy } from './policyCatalog.js';
import { createDefaultPolicy, SILENT_ACTIONS } from './policyCatalog.js';
import type { Config } from '../config/config.js';
import {
DEFAULT_GEMINI_FLASH_LITE_MODEL,
@@ -21,6 +21,7 @@ import {
import { AuthType } from '../core/contentGenerator.js';
import { ModelConfigService } from '../services/modelConfigService.js';
import { DEFAULT_MODEL_CONFIGS } from '../config/defaultModelConfigs.js';
import { ApprovalMode } from '../policy/types.js';
const createMockConfig = (overrides: Partial<Config> = {}): Config => {
const config = {
@@ -164,6 +165,18 @@ describe('policyHelpers', () => {
expect(chain[0]?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL);
expect(chain[1]?.model).toBe('gemini-3-flash-preview');
});
it('applies SILENT_ACTIONS when ApprovalMode is PLAN', () => {
const config = createMockConfig({
getApprovalMode: () => ApprovalMode.PLAN,
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
});
const chain = resolvePolicyChain(config);
expect(chain).toHaveLength(2);
expect(chain[0]?.actions).toEqual(SILENT_ACTIONS);
expect(chain[1]?.actions).toEqual(SILENT_ACTIONS);
});
});
describe('resolvePolicyChain behavior is identical between dynamic and legacy implementations', () => {
+50 -38
View File
@@ -18,6 +18,7 @@ import {
createSingleModelChain,
getModelPolicyChain,
getFlashLitePolicyChain,
SILENT_ACTIONS,
} from './policyCatalog.js';
import {
DEFAULT_GEMINI_FLASH_LITE_MODEL,
@@ -29,6 +30,7 @@ import {
} from '../config/models.js';
import type { ModelSelectionResult } from './modelAvailabilityService.js';
import type { ModelConfigKey } from '../services/modelConfigService.js';
import { ApprovalMode } from '../policy/types.js';
/**
* Resolves the active policy chain for the given config, ensuring the
@@ -43,7 +45,7 @@ export function resolvePolicyChain(
preferredModel ?? config.getActiveModel?.() ?? config.getModel();
const configuredModel = config.getModel();
let chain;
let chain: ModelPolicyChain | undefined;
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
const useGemini31FlashLite =
config.getGemini31FlashLiteLaunchedSync?.() ?? false;
@@ -103,45 +105,55 @@ export function resolvePolicyChain(
// No matching modelChains found, default to single model chain
chain = createSingleModelChain(modelFromConfig);
}
return applyDynamicSlicing(chain, resolvedModel, wrapsAround);
}
// --- LEGACY PATH ---
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
chain = getFlashLitePolicyChain();
} else if (
isGemini3Model(resolvedModel, config) ||
isAutoPreferred ||
isAutoConfigured
) {
if (hasAccessToPreview) {
const previewEnabled =
isGemini3Model(resolvedModel, config) ||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
chain = getModelPolicyChain({
previewEnabled,
userTier: config.getUserTier(),
useGemini31,
useGemini31FlashLite,
useCustomToolModel,
});
} else {
// User requested Gemini 3 but has no access. Proactively downgrade
// to the stable Gemini 2.5 chain.
chain = getModelPolicyChain({
previewEnabled: false,
userTier: config.getUserTier(),
useGemini31,
useGemini31FlashLite,
useCustomToolModel,
});
}
chain = applyDynamicSlicing(chain, resolvedModel, wrapsAround);
} else {
chain = createSingleModelChain(modelFromConfig);
// --- LEGACY PATH ---
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
chain = getFlashLitePolicyChain();
} else if (
isGemini3Model(resolvedModel, config) ||
isAutoPreferred ||
isAutoConfigured
) {
if (hasAccessToPreview) {
const previewEnabled =
isGemini3Model(resolvedModel, config) ||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
chain = getModelPolicyChain({
previewEnabled,
userTier: config.getUserTier(),
useGemini31,
useGemini31FlashLite,
useCustomToolModel,
});
} else {
// User requested Gemini 3 but has no access. Proactively downgrade
// to the stable Gemini 2.5 chain.
chain = getModelPolicyChain({
previewEnabled: false,
userTier: config.getUserTier(),
useGemini31,
useGemini31FlashLite,
useCustomToolModel,
});
}
} else {
chain = createSingleModelChain(modelFromConfig);
}
chain = applyDynamicSlicing(chain, resolvedModel, wrapsAround);
}
return applyDynamicSlicing(chain, resolvedModel, wrapsAround);
// Apply Unified Silent Injection for Plan Mode with defensive checks
if (config?.getApprovalMode?.() === ApprovalMode.PLAN) {
return chain.map((policy) => ({
...policy,
actions: { ...SILENT_ACTIONS },
}));
}
return chain;
}
/**
+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.`,
};
}
+72
View File
@@ -3006,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', () => {
+41 -24
View File
@@ -699,6 +699,7 @@ export interface ConfigParameters {
experimentalJitContext?: boolean;
autoDistillation?: boolean;
experimentalMemoryManager?: boolean;
experimentalContextManagementConfig?: string;
experimentalAgentHistoryTruncation?: boolean;
experimentalAgentHistoryTruncationThreshold?: number;
experimentalAgentHistoryRetainedMessages?: number;
@@ -832,18 +833,16 @@ export class Config implements McpContext, AgentLoopContext {
private lastEmittedQuotaLimit: number | undefined;
private emitQuotaChangedEvent(): void {
const pooled = this.getPooledQuota();
const remaining = this.getQuotaRemaining();
const limit = this.getQuotaLimit();
const resetTime = this.getQuotaResetTime();
if (
this.lastEmittedQuotaRemaining !== pooled.remaining ||
this.lastEmittedQuotaLimit !== pooled.limit
this.lastEmittedQuotaRemaining !== remaining ||
this.lastEmittedQuotaLimit !== limit
) {
this.lastEmittedQuotaRemaining = pooled.remaining;
this.lastEmittedQuotaLimit = pooled.limit;
coreEvents.emitQuotaChanged(
pooled.remaining,
pooled.limit,
pooled.resetTime,
);
this.lastEmittedQuotaRemaining = remaining;
this.lastEmittedQuotaLimit = limit;
coreEvents.emitQuotaChanged(remaining, limit, resetTime);
}
}
@@ -941,6 +940,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly adminSkillsEnabled: boolean;
private readonly experimentalJitContext: boolean;
private readonly experimentalMemoryManager: boolean;
private readonly experimentalContextManagementConfig?: string;
private readonly memoryBoundaryMarkers: readonly string[];
private readonly topicUpdateNarration: boolean;
private readonly disableLLMCorrection: boolean;
@@ -1152,6 +1152,8 @@ export class Config implements McpContext, AgentLoopContext {
this.experimentalJitContext = params.experimentalJitContext ?? false;
this.experimentalMemoryManager = params.experimentalMemoryManager ?? false;
this.experimentalContextManagementConfig =
params.experimentalContextManagementConfig;
this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git'];
this.contextManagement = {
enabled: params.contextManagement?.enabled ?? false,
@@ -1819,6 +1821,9 @@ export class Config implements McpContext, AgentLoopContext {
// When the user explicitly sets a model, that becomes the active model.
this._activeModel = newModel;
coreEvents.emitModelChanged(newModel);
this.lastEmittedQuotaRemaining = undefined;
this.lastEmittedQuotaLimit = undefined;
this.emitQuotaChangedEvent();
}
if (this.onModelChange && !isTemporary) {
this.onModelChange(newModel);
@@ -2112,24 +2117,31 @@ export class Config implements McpContext, AgentLoopContext {
this.lastQuotaFetchTime = Date.now();
for (const bucket of quota.buckets) {
if (
bucket.modelId &&
bucket.remainingAmount &&
bucket.remainingFraction != null
) {
const remaining = parseInt(bucket.remainingAmount, 10);
const limit =
if (!bucket.modelId || bucket.remainingFraction == null) {
continue;
}
let remaining: number;
let limit: number;
if (bucket.remainingAmount) {
remaining = parseInt(bucket.remainingAmount, 10);
limit =
bucket.remainingFraction > 0
? Math.round(remaining / bucket.remainingFraction)
: (this.modelQuotas.get(bucket.modelId)?.limit ?? 0);
} else {
// Server only sent remainingFraction — use a normalized scale.
limit = 100;
remaining = Math.round(bucket.remainingFraction * limit);
}
if (!isNaN(remaining) && Number.isFinite(limit) && limit > 0) {
this.modelQuotas.set(bucket.modelId, {
remaining,
limit,
resetTime: bucket.resetTime,
});
}
if (!isNaN(remaining) && Number.isFinite(limit) && limit > 0) {
this.modelQuotas.set(bucket.modelId, {
remaining,
limit,
resetTime: bucket.resetTime,
});
}
}
this.emitQuotaChangedEvent();
@@ -2426,6 +2438,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.experimentalMemoryManager;
}
getExperimentalContextManagementConfig(): string | undefined {
return this.experimentalContextManagementConfig;
}
getContextManagementConfig(): ContextManagementConfig {
return this.contextManagement;
}
@@ -3536,6 +3552,7 @@ export class Config implements McpContext, AgentLoopContext {
registry.registerTool(new RipGrepTool(this, this.messageBus)),
);
} else {
debugLogger.warn(`Ripgrep is not available. Falling back to GrepTool.`);
logRipgrepFallback(this, new RipgrepFallbackEvent(errorString));
maybeRegister(GrepTool, () =>
registry.registerTool(new GrepTool(this, this.messageBus)),
@@ -0,0 +1,94 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { loadContextManagementConfig } from './configLoader.js';
import { defaultContextProfile } from './profiles.js';
import { ContextProcessorRegistry } from './registry.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import type { Config } from '../../config/config.js';
import type { JSONSchemaType } from 'ajv';
describe('SidecarLoader (Real FS)', () => {
let tmpDir: string;
let registry: ContextProcessorRegistry;
let sidecarPath: string;
let mockConfig: Config;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-sidecar-test-'));
sidecarPath = path.join(tmpDir, 'sidecar.json');
registry = new ContextProcessorRegistry();
registry.registerProcessor({
id: 'NodeTruncation',
schema: {
type: 'object',
properties: { maxTokens: { type: 'number' } },
required: ['maxTokens'],
} as unknown as JSONSchemaType<{ maxTokens: number }>,
});
mockConfig = {
getExperimentalContextManagementConfig: () => sidecarPath,
} as unknown as Config;
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it('returns default profile if file does not exist', async () => {
const result = await loadContextManagementConfig(mockConfig, registry);
expect(result).toBe(defaultContextProfile);
});
it('returns default profile if file exists but is 0 bytes', async () => {
await fs.writeFile(sidecarPath, '');
const result = await loadContextManagementConfig(mockConfig, registry);
expect(result).toBe(defaultContextProfile);
});
it('returns parsed config if file is valid', async () => {
const validConfig = {
budget: { retainedTokens: 1000, maxTokens: 2000 },
processorOptions: {
myTruncation: {
type: 'NodeTruncation',
options: { maxTokens: 500 },
},
},
};
await fs.writeFile(sidecarPath, JSON.stringify(validConfig));
const result = await loadContextManagementConfig(mockConfig, registry);
expect(result.config.budget?.maxTokens).toBe(2000);
expect(result.config.processorOptions?.['myTruncation']).toBeDefined();
});
it('throws validation error if processorOptions contains invalid data for the schema', async () => {
const invalidConfig = {
budget: { retainedTokens: 1000, maxTokens: 2000 },
processorOptions: {
myTruncation: {
type: 'NodeTruncation',
options: { maxTokens: 'this should be a number' },
},
},
};
await fs.writeFile(sidecarPath, JSON.stringify(invalidConfig));
await expect(
loadContextManagementConfig(mockConfig, registry),
).rejects.toThrow('Validation error');
});
it('throws validation error if file is empty whitespace', async () => {
await fs.writeFile(sidecarPath, ' \n ');
await expect(
loadContextManagementConfig(mockConfig, registry),
).rejects.toThrow('Unexpected end of JSON input');
});
});
@@ -0,0 +1,90 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '../../config/config.js';
import * as fsSync from 'node:fs';
import * as fs from 'node:fs/promises';
import type { ContextManagementConfig } from './types.js';
import { defaultContextProfile, type ContextProfile } from './profiles.js';
import { SchemaValidator } from '../../utils/schemaValidator.js';
import { getContextManagementConfigSchema } from './schema.js';
import type { ContextProcessorRegistry } from './registry.js';
import { getErrorMessage } from '../../utils/errors.js';
/**
* Loads and validates a sidecar config from a specific file path.
* Throws an error if the file cannot be read, parsed, or fails schema validation.
*/
async function loadConfigFromFile(
sidecarPath: string,
registry: ContextProcessorRegistry,
): Promise<ContextProfile> {
const fileContent = await fs.readFile(sidecarPath, 'utf8');
let parsed: unknown;
try {
parsed = JSON.parse(fileContent);
} catch (error) {
throw new Error(
`Failed to parse Sidecar configuration file at ${sidecarPath}: ${getErrorMessage(
error,
)}`,
);
}
// Validate the complete structure, including deep options
const validationError = SchemaValidator.validate(
getContextManagementConfigSchema(registry),
parsed,
);
if (validationError) {
throw new Error(
`Invalid sidecar configuration in ${sidecarPath}. Validation error: ${validationError}`,
);
}
// Extract strictly what we need.
// Why this unsafe cast is acceptable:
// SchemaValidator just ran \`getSidecarConfigSchema(registry)\` against \`parsed\`.
// That function dynamically maps the \`processorOptions\` to strict JSON schema definitions,
// so we know with absolute certainty at runtime that \`parsed\` conforms to this shape.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const validConfig = parsed as ContextManagementConfig;
return {
...defaultContextProfile,
config: {
...defaultContextProfile.config,
...(validConfig.budget ? { budget: validConfig.budget } : {}),
...(validConfig.processorOptions
? { processorOptions: validConfig.processorOptions }
: {}),
},
};
}
/**
* Generates a Sidecar JSON graph from the experimental config file path or defaults.
* If a config file is present but invalid, this will THROW to prevent silent misconfiguration.
*/
export async function loadContextManagementConfig(
config: Config,
registry: ContextProcessorRegistry,
): Promise<ContextProfile> {
const sidecarPath = config.getExperimentalContextManagementConfig();
if (sidecarPath && fsSync.existsSync(sidecarPath)) {
const size = fsSync.statSync(sidecarPath).size;
// If the file exists but is completely empty (0 bytes), it's safe to fallback.
if (size === 0) {
return defaultContextProfile;
}
// If the file has content, enforce strict validation and throw on failure.
return loadConfigFromFile(sidecarPath, registry);
}
return defaultContextProfile;
}
@@ -0,0 +1,145 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
AsyncPipelineDef,
ContextManagementConfig,
PipelineDef,
} from './types.js';
import type { ContextEnvironment } from '../pipeline/environment.js';
// Import factories
import { createToolMaskingProcessor } from '../processors/toolMaskingProcessor.js';
import { createBlobDegradationProcessor } from '../processors/blobDegradationProcessor.js';
import { createNodeTruncationProcessor } from '../processors/nodeTruncationProcessor.js';
import { createNodeDistillationProcessor } from '../processors/nodeDistillationProcessor.js';
import { createStateSnapshotProcessor } from '../processors/stateSnapshotProcessor.js';
import { createStateSnapshotAsyncProcessor } from '../processors/stateSnapshotAsyncProcessor.js';
/**
* Helper to safely merge static default options with dynamically loaded
* JSON overrides from the SidecarConfig.
*
* Why the unsafe cast is acceptable here:
* Before the \`config\` object ever reaches this function, \`SidecarLoader.ts\`
* passes the raw JSON through \`SchemaValidator\`. The schema dynamically generates
* a \`oneOf\` map linking every \`type\` discriminator to its corresponding processor
* schema definition. By the time we access \`options\` here, its shape has been
* strictly validated against the corresponding Zod/JSONSchema definition at runtime,
* making the generic cast to \`<T>\` structurally safe.
*/
function resolveProcessorOptions<T>(
config: ContextManagementConfig | undefined,
id: string,
defaultOptions: T,
): T {
if (config?.processorOptions && config.processorOptions[id]) {
return {
...defaultOptions,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
...(config.processorOptions[id].options as T),
};
}
return defaultOptions;
}
export interface ContextProfile {
config: ContextManagementConfig;
buildPipelines: (
env: ContextEnvironment,
config?: ContextManagementConfig,
) => PipelineDef[];
buildAsyncPipelines: (
env: ContextEnvironment,
config?: ContextManagementConfig,
) => AsyncPipelineDef[];
}
/**
* The standard default context management profile.
* Optimized for safety, precision, and reliable summarization.
*/
export const defaultContextProfile: ContextProfile = {
config: {
budget: {
retainedTokens: 65000,
maxTokens: 150000,
},
},
buildPipelines: (
env: ContextEnvironment,
config?: ContextManagementConfig,
): PipelineDef[] =>
// Helper to merge default options with dynamically loaded processorOptions by ID
[
{
name: 'Immediate Sanitization',
triggers: ['new_message'],
processors: [
createToolMaskingProcessor(
'ToolMasking',
env,
resolveProcessorOptions(config, 'ToolMasking', {
stringLengthThresholdTokens: 8000,
}),
),
createBlobDegradationProcessor('BlobDegradation', env), // No options
],
},
{
name: 'Normalization',
triggers: ['retained_exceeded'],
processors: [
createNodeTruncationProcessor(
'NodeTruncation',
env,
resolveProcessorOptions(config, 'NodeTruncation', {
maxTokensPerNode: 3000,
}),
),
createNodeDistillationProcessor(
'NodeDistillation',
env,
resolveProcessorOptions(config, 'NodeDistillation', {
nodeThresholdTokens: 5000,
}),
),
],
},
{
name: 'Emergency Backstop',
triggers: ['gc_backstop'],
processors: [
createStateSnapshotProcessor(
'StateSnapshotSync',
env,
resolveProcessorOptions(config, 'StateSnapshotSync', {
target: 'max',
}),
),
],
},
],
buildAsyncPipelines: (
env: ContextEnvironment,
config?: ContextManagementConfig,
): AsyncPipelineDef[] => [
{
name: 'Async Background GC',
triggers: ['nodes_aged_out'],
processors: [
createStateSnapshotAsyncProcessor(
'StateSnapshotAsync',
env,
resolveProcessorOptions(config, 'StateSnapshotAsync', {
type: 'accumulate',
}),
),
],
},
],
};
@@ -0,0 +1,42 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { JSONSchemaType } from 'ajv';
export interface ContextProcessorDef<T = unknown> {
readonly id: string;
readonly schema: JSONSchemaType<T>;
}
/**
* Registry for validating declarative sidecar configuration schemas.
* (Dynamic instantiation has been replaced by static ContextProfiles)
*/
export class ContextProcessorRegistry {
private readonly processors = new Map<string, ContextProcessorDef>();
registerProcessor<T>(def: ContextProcessorDef<T>) {
// Erasing the type.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
this.processors.set(def.id, def as unknown as ContextProcessorDef<unknown>);
}
getSchema(id: string): object | undefined {
return this.processors.get(id)?.schema;
}
getSchemaDefs(): ContextProcessorDef[] {
const defs = [];
for (const def of this.processors.values()) {
if (def.schema) defs.push({ id: def.id, schema: def.schema });
}
return defs;
}
clear() {
this.processors.clear();
}
}
@@ -0,0 +1,55 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ContextProcessorRegistry } from './registry.js';
export function getContextManagementConfigSchema(
registry: ContextProcessorRegistry,
) {
// We use a registry to deeply validate processor overrides.
// We do this by generating a `oneOf` list that matches the `type` discriminator
// to the specific processor `options` schema.
const processorOptionSchemas = registry.getSchemaDefs().map((def) => ({
type: 'object',
required: ['type', 'options'],
properties: {
type: { const: def.id },
options: def.schema,
},
}));
return {
$schema: 'http://json-schema.org/draft-07/schema#',
title: 'ContextManagementConfig',
description: 'The Hyperparameter schema for a Context Profile.',
type: 'object',
properties: {
budget: {
type: 'object',
description: 'Defines the token ceilings and limits for the pipeline.',
required: ['retainedTokens', 'maxTokens'],
properties: {
retainedTokens: {
type: 'number',
description:
'The ideal token count the pipeline tries to shrink down to.',
},
maxTokens: {
type: 'number',
description:
'The absolute maximum token count allowed before synchronous truncation kicks in.',
},
},
},
processorOptions: {
type: 'object',
description:
'Named hyperparameter configurations for ContextProcessors and AsyncProcessors.',
additionalProperties: { oneOf: processorOptionSchemas },
},
},
};
}

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