Compare commits

...

33 Commits

Author SHA1 Message Date
jacob314 7df8c88d96 Checkpoint fixing tests. 2026-04-15 15:10:31 -07: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
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
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
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
joshualitt daf5006237 feat(core): introduce decoupled ContextManager and Sidecar architecture (#24752) 2026-04-13 22:02:22 +00:00
Christian Gunderman 706d4d4707 fix(core): prevent secondary crash in ModelRouterService finally block (#25333) 2026-04-13 20:57:01 +00: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
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
195 changed files with 14235 additions and 3103 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
+6 -3
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.37.1
# Latest stable release: v0.37.2
Released: April 09, 2026
Released: April 13, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -26,6 +26,9 @@ npm install -g @google/gemini-cli
## What's Changed
- fix(patch): cherry-pick 9d741ab to release/v0.37.1-pr-24565 to patch version
v0.37.1 and create version 0.37.2 by @gemini-cli-robot in
[#25322](https://github.com/google-gemini/gemini-cli/pull/25322)
- 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
@@ -422,4 +425,4 @@ npm install -g @google/gemini-cli
[#24842](https://github.com/google-gemini/gemini-cli/pull/24842)
**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.36.0...v0.37.2
+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.
+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;
@@ -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)
@@ -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}
/>
);
+18 -33
View File
@@ -267,17 +267,12 @@ 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,
},
},
});
@@ -292,17 +287,12 @@ 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,
},
},
});
@@ -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,
},
},
});
+4 -2
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 &&
@@ -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' && (
@@ -0,0 +1,146 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders, cleanup } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { type UIState } from '../contexts/UIStateContext.js';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import chalk from 'chalk';
import { isLowColorDepth } from '../utils/terminalUtils.js';
import {
setupInputPromptTest,
TestInputPrompt,
type TestInputPromptProps,
} from './InputPrompt.test.helpers.js';
import '../../test-utils/customMatchers.js';
vi.mock('../utils/terminalUtils.js', () => ({
isLowColorDepth: vi.fn(() => false),
}));
// Mock ink BEFORE importing components that use it to intercept terminalCursorPosition
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
Text: vi.fn(({ children, ...props }) => (
<actual.Text {...props}>{children}</actual.Text>
)),
};
});
describe('InputPrompt - Background Color Styles', () => {
let props: TestInputPromptProps;
beforeEach(() => {
const setup = setupInputPromptTest();
props = setup.props;
vi.mocked(isLowColorDepth).mockReturnValue(false);
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
cleanup();
});
it('should render with background color by default', async () => {
const { stdout, unmount } = await renderWithProviders(
<TestInputPrompt {...props} />,
);
await waitFor(() => {
const frame = stdout.lastFrameRaw();
expect(frame).toContain('▀');
expect(frame).toContain('▄');
});
unmount();
});
it.each([
{ color: 'black', name: 'black' },
{ color: '#000000', name: '#000000' },
{ color: '#000', name: '#000' },
{ color: 'white', name: 'white' },
{ color: '#ffffff', name: '#ffffff' },
{ color: '#fff', name: '#fff' },
])(
'should render with safe grey background but NO side borders in 8-bit mode when background is $name',
async ({ color }) => {
vi.mocked(isLowColorDepth).mockReturnValue(true);
const { stdout, unmount } = await renderWithProviders(
<TestInputPrompt {...props} />,
{
uiState: {
terminalBackgroundColor: color,
} as Partial<UIState>,
},
);
const isWhite =
color === 'white' || color === '#ffffff' || color === '#fff';
const expectedBgColor = isWhite ? '#eeeeee' : '#1c1c1c';
await waitFor(() => {
const frame = stdout.lastFrameRaw();
// Use chalk to get the expected background color escape sequence
const bgCheck = chalk.bgHex(expectedBgColor)(' ');
const bgCode = bgCheck.substring(0, bgCheck.indexOf(' '));
// Background color code should be present
expect(frame).toContain(bgCode);
// Background characters should be rendered
expect(frame).toContain('▀');
expect(frame).toContain('▄');
// Side borders should STILL be removed
expect(frame).not.toContain('│');
});
unmount();
},
);
it('should NOT render with background color but SHOULD render horizontal lines when color depth is < 24 and background is NOT black', async () => {
vi.mocked(isLowColorDepth).mockReturnValue(true);
const { stdout, unmount } = await renderWithProviders(
<TestInputPrompt {...props} />,
{
uiState: {
terminalBackgroundColor: '#333333',
} as Partial<UIState>,
},
);
await waitFor(() => {
const frame = stdout.lastFrameRaw();
expect(frame).not.toContain('▀');
expect(frame).not.toContain('▄');
// It SHOULD have horizontal fallback lines
expect(frame).toContain('─');
});
unmount();
});
it('should fallback to lines if getUseBackgroundColor returns false', async () => {
props.config.getUseBackgroundColor = () => false;
const { stdout, unmount } = await renderWithProviders(
<TestInputPrompt {...props} />,
);
await waitFor(() => {
const frame = stdout.lastFrameRaw();
expect(frame).not.toContain('▀');
expect(frame).not.toContain('▄');
// It SHOULD have horizontal fallback lines
expect(frame).toContain('─');
});
unmount();
});
});
@@ -0,0 +1,193 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders, cleanup } from '../../test-utils/render.js';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
setupInputPromptTest,
TestInputPrompt,
type TestInputPromptProps,
} from './InputPrompt.test.helpers.js';
import { type TextBuffer } from './shared/text-buffer.js';
import '../../test-utils/customMatchers.js';
vi.mock('../utils/terminalUtils.js', () => ({
isLowColorDepth: vi.fn(() => false),
}));
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
Text: vi.fn(({ children, ...props }) => (
<actual.Text {...props}>{children}</actual.Text>
)),
};
});
describe('InputPrompt - Highlighting and Cursor Display', () => {
let props: TestInputPromptProps;
let mockBuffer: TextBuffer;
beforeEach(() => {
const setup = setupInputPromptTest();
props = setup.props;
mockBuffer = setup.mockBuffer;
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
cleanup();
});
describe('single-line scenarios', () => {
it.each([
{
name: 'at end of text',
text: 'hello',
visualCursor: [0, 5],
},
{
name: 'at beginning of text',
text: 'hello',
visualCursor: [0, 0],
},
{
name: 'in middle of text',
text: 'hello',
visualCursor: [0, 2],
},
{
name: 'empty string',
text: '',
visualCursor: [0, 0],
},
])(
'should display cursor correctly $name',
async ({ text, visualCursor }) => {
mockBuffer.text = text;
mockBuffer.lines = [text];
mockBuffer.allVisualLines = [text];
mockBuffer.viewportVisualLines = [text];
mockBuffer.visualCursor = visualCursor as [number, number];
mockBuffer.visualToLogicalMap = [[0, 0]];
props.config.getUseBackgroundColor = () => true;
const renderResult = await renderWithProviders(
<TestInputPrompt {...props} />,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
},
);
it('should display cursor correctly over full-width character', async () => {
const text = 'こんにちは';
mockBuffer.text = text;
mockBuffer.lines = [text];
mockBuffer.allVisualLines = [text];
mockBuffer.viewportVisualLines = [text];
mockBuffer.visualCursor = [0, 1]; // On 'ん'
mockBuffer.visualToLogicalMap = [[0, 0]];
props.config.getUseBackgroundColor = () => true;
const renderResult = await renderWithProviders(
<TestInputPrompt {...props} />,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
it('should display cursor correctly over emoji', async () => {
const text = 'hello🚀world';
mockBuffer.text = text;
mockBuffer.lines = [text];
mockBuffer.allVisualLines = [text];
mockBuffer.viewportVisualLines = [text];
mockBuffer.visualCursor = [0, 5]; // On '🚀'
mockBuffer.visualToLogicalMap = [[0, 0]];
props.config.getUseBackgroundColor = () => true;
const renderResult = await renderWithProviders(
<TestInputPrompt {...props} />,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
});
describe('multi-line scenarios', () => {
it.each([
{
name: 'at end of first line',
text: 'line 1\nline 2',
visualCursor: [0, 6],
visualToLogicalMap: [
[0, 0],
[1, 0],
],
},
{
name: 'at beginning of second line',
text: 'line 1\nline 2',
visualCursor: [1, 0],
visualToLogicalMap: [
[0, 0],
[1, 0],
],
},
])(
'should display cursor correctly $name in a multiline block',
async ({ text, visualCursor, visualToLogicalMap }) => {
mockBuffer.text = text;
mockBuffer.lines = text.split('\n');
mockBuffer.allVisualLines = text.split('\n');
mockBuffer.viewportVisualLines = text.split('\n');
mockBuffer.visualCursor = visualCursor as [number, number];
mockBuffer.visualToLogicalMap = visualToLogicalMap as Array<
[number, number]
>;
props.config.getUseBackgroundColor = () => true;
const renderResult = await renderWithProviders(
<TestInputPrompt {...props} />,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
},
);
it('should display cursor on a blank line in a multiline block', async () => {
const text = 'first line\n\nthird line';
mockBuffer.text = text;
mockBuffer.lines = text.split('\n');
mockBuffer.allVisualLines = text.split('\n');
mockBuffer.viewportVisualLines = text.split('\n');
mockBuffer.visualCursor = [1, 0]; // cursor on the blank line
mockBuffer.visualToLogicalMap = [
[0, 0],
[1, 0],
[2, 0],
];
props.config.getUseBackgroundColor = () => true;
const renderResult = await renderWithProviders(
<TestInputPrompt {...props} />,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
});
});
@@ -0,0 +1,137 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders, cleanup } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
setupInputPromptTest,
TestInputPrompt,
type TestInputPromptProps,
} from './InputPrompt.test.helpers.js';
import { type TextBuffer } from './shared/text-buffer.js';
import '../../test-utils/customMatchers.js';
vi.mock('../utils/terminalUtils.js', () => ({
isLowColorDepth: vi.fn(() => false),
}));
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
Text: vi.fn(({ children, ...props }) => (
<actual.Text {...props}>{children}</actual.Text>
)),
};
});
describe('InputPrompt - IME Cursor Support', () => {
let props: TestInputPromptProps;
let mockBuffer: TextBuffer;
beforeEach(() => {
const setup = setupInputPromptTest();
props = setup.props;
mockBuffer = setup.mockBuffer;
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
cleanup();
});
describe('terminalCursorPosition', () => {
it.each([
{
name: 'empty buffer',
text: '',
visualCursor: [0, 0],
expectedCol: 0,
},
{
name: 'ASCII text at start',
text: 'hello',
visualCursor: [0, 0],
expectedCol: 0,
},
{
name: 'ASCII text at end',
text: 'hello',
visualCursor: [0, 5],
expectedCol: 5,
},
{
name: 'Japanese text with full-width characters (こんにちは)',
text: 'こんにちは',
visualCursor: [0, 5],
expectedCol: 5,
},
{
name: 'Mixed ASCII and CJK text (aこbんc)',
text: 'aこbんc',
visualCursor: [0, 5],
expectedCol: 5,
},
{
name: 'Emoji (👩‍💻)',
text: '👩‍💻',
visualCursor: [0, 5],
expectedCol: 5,
},
{
name: 'Korean text (テスト)',
text: 'テスト',
visualCursor: [0, 3],
expectedCol: 3,
},
])(
'should set terminalCursorPosition correctly for $name',
async ({ text, visualCursor, expectedCol }) => {
mockBuffer.text = text;
mockBuffer.lines = [text];
mockBuffer.allVisualLines = [text];
mockBuffer.viewportVisualLines = [text];
mockBuffer.visualCursor = visualCursor as [number, number];
mockBuffer.visualToLogicalMap = [[0, 0]];
// Avoid rendering backgrounds which might complicate the snapshot/component tree
props.config.getUseBackgroundColor = () => false;
const { stdout, unmount } = await renderWithProviders(
<TestInputPrompt {...props} />,
);
// We can't easily extract props from the rendered ink tree in tests without a custom test renderer.
// However, if the component passes terminalCursorPosition down, Ink will handle it.
// In our mock, we just render <Text>.
// To verify the prop was passed, we need to inspect the mocked Text component's calls.
// Since we mock 'ink' at the top level, we can check if Text was called with terminalCursorPosition.
await waitFor(() => {
// Ensure it rendered
expect(stdout.lastFrameRaw()).not.toBe('');
});
const ink = await import('ink');
const textMock = ink.Text as unknown as ReturnType<typeof vi.fn>;
// Find the call to Text that has terminalCursorFocus: true
const focusCall = textMock.mock.calls.find(
(call) => call[0].terminalCursorFocus === true,
);
expect(focusCall).toBeDefined();
if (focusCall) {
expect(focusCall[0].terminalCursorPosition).toBe(expectedCol);
}
unmount();
},
);
});
});
@@ -0,0 +1,122 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders, cleanup } from '../../test-utils/render.js';
import { type UIState } from '../contexts/UIStateContext.js';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
setupInputPromptTest,
TestInputPrompt,
type TestInputPromptProps,
} from './InputPrompt.test.helpers.js';
import '../../test-utils/customMatchers.js';
import { ApprovalMode } from '@google/gemini-cli-core';
vi.mock('../utils/terminalUtils.js', () => ({
isLowColorDepth: vi.fn(() => false),
}));
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
Text: vi.fn(({ children, ...props }) => (
<actual.Text {...props}>{children}</actual.Text>
)),
};
});
describe('InputPrompt - Snapshots', () => {
let props: TestInputPromptProps;
beforeEach(() => {
const setup = setupInputPromptTest();
props = setup.props;
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
cleanup();
});
describe('snapshots', () => {
it.each([
{ name: 'dark', color: '#123456', mode: ApprovalMode.DEFAULT },
{ name: 'light', color: '#fff', mode: ApprovalMode.DEFAULT },
{ name: 'plan', color: 'black', mode: ApprovalMode.PLAN },
{ name: 'yolo', color: 'black', mode: ApprovalMode.YOLO },
])('should render correctly for $name mode', async ({ color, mode }) => {
props.approvalMode = mode;
const renderResult = await renderWithProviders(
<TestInputPrompt {...props} />,
{
uiState: {
terminalBackgroundColor: color,
} as Partial<UIState>,
},
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
it('renders with multiline text', async () => {
props.buffer.text = 'line1\nline2';
props.buffer.lines = ['line1', 'line2'];
props.buffer.allVisualLines = ['line1', 'line2'];
props.buffer.viewportVisualLines = ['line1', 'line2'];
props.buffer.visualCursor = [1, 5];
props.buffer.visualToLogicalMap = [
[0, 0],
[1, 0],
];
const renderResult = await renderWithProviders(
<TestInputPrompt {...props} />,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
it('renders correctly with shell mode active', async () => {
props.shellModeActive = true;
props.buffer.text = 'npm run dev';
props.buffer.lines = ['npm run dev'];
props.buffer.allVisualLines = ['npm run dev'];
props.buffer.viewportVisualLines = ['npm run dev'];
props.buffer.visualCursor = [0, 11];
const renderResult = await renderWithProviders(
<TestInputPrompt {...props} />,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
it('renders correctly with copy mode enabled', async () => {
props.copyModeEnabled = true;
props.buffer.text = 'copy me';
props.buffer.lines = ['copy me'];
props.buffer.allVisualLines = ['copy me'];
props.buffer.viewportVisualLines = ['copy me'];
props.buffer.visualCursor = [0, 7];
const renderResult = await renderWithProviders(
<TestInputPrompt {...props} />,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
});
});
@@ -0,0 +1,266 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useMemo } from 'react';
import { vi } from 'vitest';
import { ApprovalMode, coreEvents, type Config } from '@google/gemini-cli-core';
import * as path from 'node:path';
import { CommandKind, type SlashCommand } from '../commands/types.js';
import { StreamingState } from '../types.js';
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
import { type TextBuffer } from './shared/text-buffer.js';
import { cpLen } from '../utils/textUtils.js';
import { InputContext } from '../contexts/InputContext.js';
import { InputPrompt, type InputPromptProps } from './InputPrompt.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
export type TestInputPromptProps = InputPromptProps & {
buffer: TextBuffer;
userMessages: string[];
shellModeActive: boolean;
copyModeEnabled?: boolean;
showEscapePrompt?: boolean;
inputWidth: number;
suggestionsWidth: number;
};
export const TestInputPrompt = (props: TestInputPromptProps) => {
const contextValue = useMemo(
() => ({
buffer: props.buffer,
userMessages: props.userMessages,
shellModeActive: props.shellModeActive,
copyModeEnabled: props.copyModeEnabled,
showEscapePrompt: props.showEscapePrompt || false,
inputWidth: props.inputWidth,
suggestionsWidth: props.suggestionsWidth,
}),
[
props.buffer,
props.userMessages,
props.shellModeActive,
props.copyModeEnabled,
props.showEscapePrompt,
props.inputWidth,
props.suggestionsWidth,
],
);
return (
<InputContext.Provider value={contextValue}>
<InputPrompt {...props} />
</InputContext.Provider>
);
};
export const mockSlashCommands: SlashCommand[] = [
{
name: 'stats',
description: 'Check stats',
kind: CommandKind.BUILT_IN,
isSafeConcurrent: true,
},
{
name: 'clear',
kind: CommandKind.BUILT_IN,
description: 'Clear screen',
action: vi.fn(),
},
{
name: 'memory',
kind: CommandKind.BUILT_IN,
description: 'Manage memory',
subCommands: [
{
name: 'show',
kind: CommandKind.BUILT_IN,
description: 'Show memory',
action: vi.fn(),
},
{
name: 'add',
kind: CommandKind.BUILT_IN,
description: 'Add to memory',
action: vi.fn(),
},
{
name: 'refresh',
kind: CommandKind.BUILT_IN,
description: 'Refresh memory',
action: vi.fn(),
},
],
},
{
name: 'chat',
description: 'Manage chats',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'resume',
description: 'Resume a chat',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
completion: async () => ['fix-foo', 'fix-bar'],
},
],
},
{
name: 'resume',
description: 'Browse and resume sessions',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
},
];
export function setupInputPromptTest() {
vi.resetAllMocks();
coreEvents.removeAllListeners();
vi.spyOn(terminalCapabilityManager, 'isKittyProtocolEnabled').mockReturnValue(
true,
);
const mockCommandContext = createMockCommandContext();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const mockBuffer = {
text: '',
cursor: [0, 0],
lines: [''],
setText: vi.fn(
(newText: string, cursorPosition?: 'start' | 'end' | number) => {
mockBuffer.text = newText;
mockBuffer.lines = newText.split('\n');
let col = 0;
if (typeof cursorPosition === 'number') {
col = cursorPosition;
} else if (cursorPosition === 'start') {
col = 0;
} else {
col = newText.length;
}
mockBuffer.cursor = [0, col];
mockBuffer.allVisualLines = newText.split('\n');
mockBuffer.viewportVisualLines = newText.split('\n');
mockBuffer.visualToLogicalMap = newText
.split('\n')
.map((_, i) => [i, 0] as [number, number]);
mockBuffer.visualCursor = [0, col];
mockBuffer.visualScrollRow = 0;
mockBuffer.viewportHeight = 10;
mockBuffer.visualToTransformedMap = newText
.split('\n')
.map((_, i) => i);
mockBuffer.transformationsByLine = newText.split('\n').map(() => []);
},
),
replaceRangeByOffset: vi.fn(),
viewportVisualLines: [''],
allVisualLines: [''],
visualCursor: [0, 0],
visualScrollRow: 0,
viewportHeight: 10,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
handleInput: vi.fn((key: any) => {
if (key.name === 'c' && key.ctrl) {
if (mockBuffer.text.length > 0) {
mockBuffer.setText('');
return true;
}
return false;
}
return false;
}),
move: vi.fn((dir: string) => {
if (dir === 'home') {
mockBuffer.visualCursor = [mockBuffer.visualCursor[0], 0];
} else if (dir === 'end') {
const line =
mockBuffer.allVisualLines[mockBuffer.visualCursor[0]] || '';
mockBuffer.visualCursor = [mockBuffer.visualCursor[0], cpLen(line)];
}
}),
moveToOffset: vi.fn((offset: number) => {
mockBuffer.cursor = [0, offset];
}),
moveToVisualPosition: vi.fn(),
killLineRight: vi.fn(),
killLineLeft: vi.fn(),
openInExternalEditor: vi.fn(),
newline: vi.fn(),
undo: vi.fn(),
redo: vi.fn(),
backspace: vi.fn(),
preferredCol: null,
selectionAnchor: null,
insert: vi.fn(),
del: vi.fn(),
replaceRange: vi.fn(),
deleteWordLeft: vi.fn(),
deleteWordRight: vi.fn(),
visualToLogicalMap: [[0, 0]],
visualToTransformedMap: [0],
transformationsByLine: [],
getOffset: vi.fn().mockReturnValue(0),
pastedContent: {},
} as unknown as TextBuffer;
const props: TestInputPromptProps = {
onQueueMessage: vi.fn(),
buffer: mockBuffer,
onSubmit: vi.fn(),
userMessages: [],
onClearScreen: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
config: {
getProjectRoot: () => path.join('test', 'project'),
getTargetDir: () => path.join('test', 'project', 'src'),
getVimMode: () => false,
getUseBackgroundColor: () => true,
getUseTerminalBuffer: () => false,
getTerminalBackground: () => undefined,
getWorkspaceContext: () => ({
getDirectories: () => ['/test/project/src'],
onDirectoriesChanged: vi.fn(),
}),
} as unknown as Config,
slashCommands: mockSlashCommands,
commandContext: mockCommandContext,
shellModeActive: false,
setShellModeActive: vi.fn(),
approvalMode: ApprovalMode.DEFAULT,
inputWidth: 80,
suggestionsWidth: 80,
focus: true,
setQueueErrorMessage: vi.fn(),
streamingState: StreamingState.Idle,
setBannerVisible: vi.fn(),
};
const uiActions = {
setEmbeddedShellFocused: vi.fn(),
setCleanUiDetailsVisible: vi.fn(),
toggleCleanUiDetailsVisible: vi.fn(),
revealCleanUiDetailsTemporarily: vi.fn(),
addMessage: vi.fn(),
};
return {
mockBuffer,
props,
uiActions,
mockCommandContext,
};
}
export function createMockMocks() {
return {
mockSetEmbeddedShellFocused: vi.fn(),
mockSetCleanUiDetailsVisible: vi.fn(),
mockToggleCleanUiDetailsVisible: vi.fn(),
mockRevealCleanUiDetailsTemporarily: vi.fn(),
};
}
File diff suppressed because it is too large Load Diff
@@ -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>
);
};
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="88" viewBox="0 0 920 88">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="88" fill="#000000" />
<g transform="translate(10, 10)">
<rect x="0" y="0" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="2" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="17" width="9" height="17" fill="#1f1f1f" />
<rect x="9" y="17" width="18" height="17" fill="#1f1f1f" />
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="17" width="54" height="17" fill="#1f1f1f" />
<text x="27" y="19" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line 1</text>
<rect x="81" y="17" width="819" height="17" fill="#1f1f1f" />
<rect x="0" y="34" width="27" height="17" fill="#1f1f1f" />
<rect x="27" y="34" width="9" height="17" fill="#ffffff" />
<text x="27" y="36" fill="#1f1f1f" textLength="9" lengthAdjust="spacingAndGlyphs">l</text>
<rect x="36" y="34" width="45" height="17" fill="#1f1f1f" />
<text x="36" y="36" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">ine 2</text>
<rect x="81" y="34" width="819" height="17" fill="#1f1f1f" />
<rect x="0" y="51" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="53" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="88" viewBox="0 0 920 88">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="88" fill="#000000" />
<g transform="translate(10, 10)">
<rect x="0" y="0" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="2" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="17" width="9" height="17" fill="#1f1f1f" />
<rect x="9" y="17" width="18" height="17" fill="#1f1f1f" />
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="17" width="54" height="17" fill="#1f1f1f" />
<text x="27" y="19" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line 1</text>
<rect x="81" y="17" width="9" height="17" fill="#ffffff" />
<rect x="90" y="17" width="810" height="17" fill="#1f1f1f" />
<rect x="0" y="34" width="27" height="17" fill="#1f1f1f" />
<rect x="27" y="34" width="54" height="17" fill="#1f1f1f" />
<text x="27" y="36" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line 2</text>
<rect x="81" y="34" width="819" height="17" fill="#1f1f1f" />
<rect x="0" y="51" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="53" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="105" viewBox="0 0 920 105">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="105" fill="#000000" />
<g transform="translate(10, 10)">
<rect x="0" y="0" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="2" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="17" width="9" height="17" fill="#1f1f1f" />
<rect x="9" y="17" width="18" height="17" fill="#1f1f1f" />
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="17" width="90" height="17" fill="#1f1f1f" />
<text x="27" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
<rect x="117" y="17" width="783" height="17" fill="#1f1f1f" />
<rect x="0" y="34" width="27" height="17" fill="#1f1f1f" />
<rect x="27" y="34" width="9" height="17" fill="#ffffff" />
<rect x="36" y="34" width="864" height="17" fill="#1f1f1f" />
<rect x="0" y="51" width="27" height="17" fill="#1f1f1f" />
<rect x="27" y="51" width="90" height="17" fill="#1f1f1f" />
<text x="27" y="53" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">third line</text>
<rect x="117" y="51" width="783" height="17" fill="#1f1f1f" />
<rect x="0" y="68" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="70" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<rect x="0" y="0" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="2" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="17" width="9" height="17" fill="#1f1f1f" />
<rect x="9" y="17" width="18" height="17" fill="#1f1f1f" />
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="17" width="9" height="17" fill="#ffffff" />
<text x="27" y="19" fill="#1f1f1f" textLength="9" lengthAdjust="spacingAndGlyphs">h</text>
<rect x="36" y="17" width="36" height="17" fill="#1f1f1f" />
<text x="36" y="19" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">ello</text>
<rect x="72" y="17" width="828" height="17" fill="#1f1f1f" />
<rect x="0" y="34" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="36" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<rect x="0" y="0" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="2" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="17" width="9" height="17" fill="#1f1f1f" />
<rect x="9" y="17" width="18" height="17" fill="#1f1f1f" />
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="17" width="45" height="17" fill="#1f1f1f" />
<text x="27" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">hello</text>
<rect x="72" y="17" width="9" height="17" fill="#ffffff" />
<rect x="81" y="17" width="819" height="17" fill="#1f1f1f" />
<rect x="0" y="34" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="36" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<rect x="0" y="0" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="2" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="17" width="9" height="17" fill="#1f1f1f" />
<rect x="9" y="17" width="18" height="17" fill="#1f1f1f" />
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="17" width="9" height="17" fill="#ffffff" />
<rect x="36" y="17" width="315" height="17" fill="#1f1f1f" />
<text x="36" y="19" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs"> Type your message or @path/to/file</text>
<rect x="351" y="17" width="549" height="17" fill="#1f1f1f" />
<rect x="0" y="34" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="36" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<rect x="0" y="0" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="2" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="17" width="9" height="17" fill="#1f1f1f" />
<rect x="9" y="17" width="18" height="17" fill="#1f1f1f" />
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="17" width="18" height="17" fill="#1f1f1f" />
<text x="27" y="19" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">he</text>
<rect x="45" y="17" width="9" height="17" fill="#ffffff" />
<text x="45" y="19" fill="#1f1f1f" textLength="9" lengthAdjust="spacingAndGlyphs">l</text>
<rect x="54" y="17" width="18" height="17" fill="#1f1f1f" />
<text x="54" y="19" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">lo</text>
<rect x="72" y="17" width="828" height="17" fill="#1f1f1f" />
<rect x="0" y="34" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="36" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<rect x="0" y="0" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="2" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="17" width="9" height="17" fill="#1f1f1f" />
<rect x="9" y="17" width="18" height="17" fill="#1f1f1f" />
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="17" width="45" height="17" fill="#1f1f1f" />
<text x="27" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">hello</text>
<rect x="72" y="17" width="9" height="17" fill="#ffffff" />
<text x="72" y="19" fill="#1f1f1f" textLength="9" lengthAdjust="spacingAndGlyphs">🚀</text>
<rect x="81" y="17" width="45" height="17" fill="#1f1f1f" />
<text x="81" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">world</text>
<rect x="126" y="17" width="765" height="17" fill="#1f1f1f" />
<rect x="0" y="34" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="36" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<rect x="0" y="0" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="2" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="17" width="9" height="17" fill="#1f1f1f" />
<rect x="9" y="17" width="18" height="17" fill="#1f1f1f" />
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="17" width="18" height="17" fill="#1f1f1f" />
<text x="27" y="19" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs"></text>
<rect x="45" y="17" width="18" height="17" fill="#ffffff" />
<text x="45" y="19" fill="#1f1f1f" textLength="18" lengthAdjust="spacingAndGlyphs"></text>
<rect x="63" y="17" width="54" height="17" fill="#1f1f1f" />
<text x="63" y="19" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">にちは</text>
<rect x="117" y="17" width="783" height="17" fill="#1f1f1f" />
<rect x="0" y="34" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="36" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,59 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`InputPrompt - Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'at beginning of second line' in a multiline block 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> line 1
line 2
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt - Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'at end of first line' in a multiline block 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> line 1
line 2
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt - Highlighting and Cursor Display > multi-line scenarios > should display cursor on a blank line in a multiline block 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> first line
third line
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt - Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at beginning of text' 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> hello
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt - Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at end of text' 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> hello
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt - Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'empty string' 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt - Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'in middle of text' 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> hello
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt - Highlighting and Cursor Display > single-line scenarios > should display cursor correctly over emoji 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> hello🚀world
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt - Highlighting and Cursor Display > single-line scenarios > should display cursor correctly over full-width character 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> こんにちは
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<rect x="0" y="0" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="2" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="17" width="9" height="17" fill="#1f1f1f" />
<rect x="9" y="17" width="18" height="17" fill="#1f1f1f" />
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="17" width="63" height="17" fill="#1f1f1f" />
<text x="27" y="19" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs">copy me</text>
<rect x="90" y="17" width="810" height="17" fill="#1f1f1f" />
<rect x="0" y="34" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="36" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<rect x="0" y="0" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="2" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="17" width="9" height="17" fill="#1f1f1f" />
<rect x="9" y="17" width="18" height="17" fill="#1f1f1f" />
<text x="9" y="19" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">! </text>
<rect x="27" y="17" width="99" height="17" fill="#1f1f1f" />
<text x="27" y="19" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs">npm run dev</text>
<rect x="126" y="17" width="9" height="17" fill="#ffffff" />
<rect x="135" y="17" width="765" height="17" fill="#1f1f1f" />
<rect x="0" y="34" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="36" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="88" viewBox="0 0 920 88">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="88" fill="#000000" />
<g transform="translate(10, 10)">
<rect x="0" y="0" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="2" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="17" width="9" height="17" fill="#1f1f1f" />
<rect x="9" y="17" width="18" height="17" fill="#1f1f1f" />
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="17" width="45" height="17" fill="#1f1f1f" />
<text x="27" y="19" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">line1</text>
<rect x="72" y="17" width="828" height="17" fill="#1f1f1f" />
<rect x="0" y="34" width="27" height="17" fill="#1f1f1f" />
<rect x="27" y="34" width="45" height="17" fill="#1f1f1f" />
<text x="27" y="36" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">line2</text>
<rect x="72" y="34" width="9" height="17" fill="#ffffff" />
<rect x="81" y="34" width="819" height="17" fill="#1f1f1f" />
<rect x="0" y="51" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="53" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<rect x="0" y="0" width="900" height="17" fill="#38526b" />
<text x="0" y="2" fill="#123456" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="17" width="9" height="17" fill="#38526b" />
<rect x="9" y="17" width="18" height="17" fill="#38526b" />
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="17" width="9" height="17" fill="#ffffff" />
<rect x="36" y="17" width="315" height="17" fill="#38526b" />
<text x="36" y="19" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs"> Type your message or @path/to/file</text>
<rect x="351" y="17" width="549" height="17" fill="#38526b" />
<rect x="0" y="34" width="900" height="17" fill="#38526b" />
<text x="0" y="36" fill="#123456" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<rect x="0" y="0" width="900" height="17" fill="#f0f0f0" />
<text x="0" y="2" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="17" width="9" height="17" fill="#f0f0f0" />
<rect x="9" y="17" width="18" height="17" fill="#f0f0f0" />
<text x="9" y="19" fill="#aa0d91" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="17" width="9" height="17" fill="#ffffff" />
<rect x="36" y="17" width="315" height="17" fill="#f0f0f0" />
<text x="36" y="19" fill="#c0c0c0" textLength="315" lengthAdjust="spacingAndGlyphs"> Type your message or @path/to/file</text>
<rect x="351" y="17" width="549" height="17" fill="#f0f0f0" />
<rect x="0" y="34" width="900" height="17" fill="#f0f0f0" />
<text x="0" y="36" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<rect x="0" y="0" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="2" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="17" width="9" height="17" fill="#1f1f1f" />
<rect x="9" y="17" width="18" height="17" fill="#1f1f1f" />
<text x="9" y="19" fill="#d7ffd7" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="17" width="9" height="17" fill="#ffffff" />
<rect x="36" y="17" width="315" height="17" fill="#1f1f1f" />
<text x="36" y="19" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs"> Type your message or @path/to/file</text>
<rect x="351" y="17" width="549" height="17" fill="#1f1f1f" />
<rect x="0" y="34" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="36" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<rect x="0" y="0" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="2" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="17" width="9" height="17" fill="#1f1f1f" />
<rect x="9" y="17" width="18" height="17" fill="#1f1f1f" />
<text x="9" y="19" fill="#ff87af" textLength="18" lengthAdjust="spacingAndGlyphs">* </text>
<rect x="27" y="17" width="9" height="17" fill="#ffffff" />
<rect x="36" y="17" width="315" height="17" fill="#1f1f1f" />
<text x="36" y="19" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs"> Type your message or @path/to/file</text>
<rect x="351" y="17" width="549" height="17" fill="#1f1f1f" />
<rect x="0" y="34" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="36" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,44 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`InputPrompt - Snapshots > snapshots > renders correctly with copy mode enabled 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> copy me
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt - Snapshots > snapshots > renders correctly with shell mode active 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
! npm run dev
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt - Snapshots > snapshots > renders with multiline text 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> line1
line2
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt - Snapshots > snapshots > should render correctly for 'dark' mode 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt - Snapshots > snapshots > should render correctly for 'light' mode 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt - Snapshots > snapshots > should render correctly for 'plan' mode 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt - Snapshots > snapshots > should render correctly for 'yolo' mode 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
* Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
@@ -1,95 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'at the beginning of a line' in a multiline block 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > first line │
│ second line │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'at the end of a line' in a multiline block 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > first line │
│ second line │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'in the middle of a line' in a multiline block 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > first line │
│ second line │
│ third line │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor on a blank line in a multiline block 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > first line │
│ │
│ third line │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'after multi-byte unicode characters' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > 👍A │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the beginning of the line' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > hello │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of a line with unicode cha…' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > hello 👍 │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of a short line with unico…' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > 👍 │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of the line' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > hello │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'for multi-byte unicode characters' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > hello 👍 world │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'mid-word' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > hello world │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on a highlighted token' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > run @path/to/file │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on a space between words' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > hello world │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on an empty line' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > Type your message or @path/to/file │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > History Navigation and Completion Suppression > should not render suggestions during history navigation 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> second message
@@ -175,30 +85,3 @@ exports[`InputPrompt > multiline rendering > should correctly render multiline i
│ world │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
! Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
* Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
@@ -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;
+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(
+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,
);
}
}
@@ -170,6 +170,43 @@ function buildSystemPrompt(skillsDir: string): string {
'Naming: kebab-case (e.g., fix-lint-errors, run-migrations).',
'',
'============================================================',
'UPDATING EXISTING SKILLS (PATCHES)',
'============================================================',
'',
'You can ONLY write files inside your skills directory. However, existing skills',
'may live outside it (global or workspace locations).',
'',
'NEVER patch builtin or extension skills. They are managed externally and',
'overwritten on updates. Patches targeting these paths will be rejected.',
'',
'To propose an update to an existing skill that lives OUTSIDE your directory:',
'',
'1. Read the original file(s) using read_file (paths are listed in "Existing Skills").',
'2. Write a unified diff patch file to:',
` ${skillsDir}/<skill-name>.patch`,
'',
'Patch format (strict unified diff):',
'',
' --- /absolute/path/to/original/SKILL.md',
' +++ /absolute/path/to/original/SKILL.md',
' @@ -<start>,<count> +<start>,<count> @@',
' <context line>',
' -<removed line>',
' +<added line>',
' <context line>',
'',
'Rules for patches:',
'- Use the EXACT absolute file path in BOTH --- and +++ headers (NO a/ or b/ prefixes).',
'- Include 3 lines of context around each change (standard unified diff).',
'- A single .patch file can contain hunks for multiple files in the same skill.',
'- For new files, use `/dev/null` as the --- source.',
'- Line counts in @@ headers MUST be accurate.',
'- Do NOT create a patch if you can create or update a skill in your own directory instead.',
'- Patches will be validated by parsing and dry-run applying them. Invalid patches are discarded.',
'',
'The same quality bar applies: only propose updates backed by evidence from sessions.',
'',
'============================================================',
'QUALITY RULES (STRICT)',
'============================================================',
'',
@@ -192,7 +229,8 @@ function buildSystemPrompt(skillsDir: string): string {
'5. For promising patterns, use read_file on the session file paths to inspect the full',
' conversation. Confirm the workflow was actually repeated and validated.',
'6. For each confirmed skill, verify it meets ALL criteria (repeatable, procedural, high-leverage).',
'7. Write new SKILL.md files or update existing ones using write_file.',
'7. Write new SKILL.md files or update existing ones in your directory using write_file.',
' For skills that live OUTSIDE your directory, write a .patch file instead (see UPDATING EXISTING SKILLS).',
'8. Write COMPLETE files — never partially update a SKILL.md.',
'',
'IMPORTANT: Do NOT read every session. Only read sessions whose summaries suggest a',
@@ -41,7 +41,7 @@ const DEFAULT_ACTIONS: ModelPolicyActionMap = {
unknown: 'prompt',
};
const SILENT_ACTIONS: ModelPolicyActionMap = {
export const SILENT_ACTIONS: ModelPolicyActionMap = {
terminal: 'silent',
transient: 'silent',
not_found: 'silent',
@@ -10,7 +10,7 @@ import {
buildFallbackPolicyContext,
applyModelSelection,
} from './policyHelpers.js';
import { createDefaultPolicy } from './policyCatalog.js';
import { createDefaultPolicy, SILENT_ACTIONS } from './policyCatalog.js';
import type { Config } from '../config/config.js';
import {
DEFAULT_GEMINI_FLASH_LITE_MODEL,
@@ -21,6 +21,7 @@ import {
import { AuthType } from '../core/contentGenerator.js';
import { ModelConfigService } from '../services/modelConfigService.js';
import { DEFAULT_MODEL_CONFIGS } from '../config/defaultModelConfigs.js';
import { ApprovalMode } from '../policy/types.js';
const createMockConfig = (overrides: Partial<Config> = {}): Config => {
const config = {
@@ -164,6 +165,18 @@ describe('policyHelpers', () => {
expect(chain[0]?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL);
expect(chain[1]?.model).toBe('gemini-3-flash-preview');
});
it('applies SILENT_ACTIONS when ApprovalMode is PLAN', () => {
const config = createMockConfig({
getApprovalMode: () => ApprovalMode.PLAN,
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
});
const chain = resolvePolicyChain(config);
expect(chain).toHaveLength(2);
expect(chain[0]?.actions).toEqual(SILENT_ACTIONS);
expect(chain[1]?.actions).toEqual(SILENT_ACTIONS);
});
});
describe('resolvePolicyChain behavior is identical between dynamic and legacy implementations', () => {
+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.`,
};
}
+9
View File
@@ -699,6 +699,7 @@ export interface ConfigParameters {
experimentalJitContext?: boolean;
autoDistillation?: boolean;
experimentalMemoryManager?: boolean;
experimentalContextManagementConfig?: string;
experimentalAgentHistoryTruncation?: boolean;
experimentalAgentHistoryTruncationThreshold?: number;
experimentalAgentHistoryRetainedMessages?: number;
@@ -939,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;
@@ -1150,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,
@@ -2434,6 +2438,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.experimentalMemoryManager;
}
getExperimentalContextManagementConfig(): string | undefined {
return this.experimentalContextManagementConfig;
}
getContextManagementConfig(): ContextManagementConfig {
return this.contextManagement;
}
@@ -3544,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 },
},
},
};
}
+46
View File
@@ -0,0 +1,46 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ContextProcessor, AsyncContextProcessor } from '../pipeline.js';
export type PipelineTrigger =
| 'new_message'
| 'retained_exceeded'
| 'gc_backstop'
| 'nodes_added'
| 'nodes_aged_out'
| { type: 'timer'; intervalMs: number };
export interface PipelineDef {
name: string;
triggers: PipelineTrigger[];
processors: ContextProcessor[];
}
export interface AsyncPipelineDef {
name: string;
triggers: PipelineTrigger[];
processors: AsyncContextProcessor[];
}
export interface ContextBudget {
retainedTokens: number;
maxTokens: number;
}
/**
* The Data-Driven Schema for the Context Manager.
*/
export interface ContextManagementConfig {
/** Defines the token ceilings and limits for the pipeline. */
budget: ContextBudget;
/**
* Dynamic hyperparameter overrides for individual ContextProcessors and AsyncProcessors.
* Keys are named identifiers (e.g. "gentleTruncation").
*/
processorOptions?: Record<string, { type: string; options: unknown }>;
}
@@ -0,0 +1,82 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { testTruncateProfile } from './testing/testProfile.js';
import {
createSyntheticHistory,
createMockContextConfig,
setupContextComponentTest,
} from './testing/contextTestUtils.js';
describe('ContextManager Sync Pressure Barrier Tests', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should instantly truncate history when maxTokens is exceeded using truncate strategy', async () => {
// 1. Setup
const config = createMockContextConfig();
const { chatHistory, contextManager } = setupContextComponentTest(
config,
testTruncateProfile,
);
// 2. Add System Prompt (Episode 0 - Protected)
chatHistory.set([
{ role: 'user', parts: [{ text: 'System prompt' }] },
{ role: 'model', parts: [{ text: 'Understood.' }] },
]);
// 3. Add massive history that blows past the 150k maxTokens limit
// 20 turns * 10,000 tokens/turn = ~200,000 tokens
const massiveHistory = createSyntheticHistory(20, 35000);
chatHistory.set([...chatHistory.get(), ...massiveHistory]);
// 4. Add the Latest Turn (Protected)
chatHistory.set([
...chatHistory.get(),
{ role: 'user', parts: [{ text: 'Final question.' }] },
{ role: 'model', parts: [{ text: 'Final answer.' }] },
]);
const rawHistoryLength = chatHistory.get().length;
// 5. Project History (Triggers Sync Barrier)
const projection = await contextManager.renderHistory();
// 6. Assertions
// The barrier should have dropped several older episodes to get under 150k.
expect(projection.length).toBeLessThan(rawHistoryLength);
// Verify Episode 0 (System) is perfectly preserved at the front
expect(projection[0].role).toBe('user');
expect(projection[0].parts![0].text).toBe('System prompt');
// Filter out synthetic Yield nodes (they are model responses without actual tool/text bodies)
const contentNodes = projection.filter(
(p) =>
p.parts && p.parts.some((part) => part.text && part.text !== 'Yield'),
);
// Verify the latest turn is perfectly preserved at the back
const lastUser = contentNodes[contentNodes.length - 2];
const lastModel = contentNodes[contentNodes.length - 1];
expect(lastUser.role).toBe('user');
expect(lastUser.parts![0].text).toBe('Final question.');
expect(lastModel.role).toBe('model');
expect(lastModel.parts![0].text).toBe('Final answer.');
});
});
+170
View File
@@ -0,0 +1,170 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content } from '@google/genai';
import type { AgentChatHistory } from '../core/agentChatHistory.js';
import type { ConcreteNode } from './graph/types.js';
import type { ContextEventBus } from './eventBus.js';
import type { ContextTracer } from './tracer.js';
import type { ContextEnvironment } from './pipeline/environment.js';
import type { ContextProfile } from './config/profiles.js';
import type { PipelineOrchestrator } from './pipeline/orchestrator.js';
import { HistoryObserver } from './historyObserver.js';
import { render } from './graph/render.js';
import { ContextWorkingBufferImpl } from './pipeline/contextWorkingBuffer.js';
export class ContextManager {
// The master state containing the pristine graph and current active graph.
private buffer: ContextWorkingBufferImpl =
ContextWorkingBufferImpl.initialize([]);
private readonly eventBus: ContextEventBus;
// Internal sub-components
private readonly orchestrator: PipelineOrchestrator;
private readonly historyObserver: HistoryObserver;
constructor(
private readonly sidecar: ContextProfile,
private readonly env: ContextEnvironment,
private readonly tracer: ContextTracer,
orchestrator: PipelineOrchestrator,
chatHistory: AgentChatHistory,
) {
this.eventBus = env.eventBus;
this.orchestrator = orchestrator;
this.historyObserver = new HistoryObserver(
chatHistory,
this.env.eventBus,
this.tracer,
this.env.tokenCalculator,
this.env.graphMapper,
);
this.historyObserver.start();
this.eventBus.onPristineHistoryUpdated((event) => {
const existingIds = new Set(this.buffer.nodes.map((n) => n.id));
const newIds = new Set(event.nodes.map((n) => n.id));
const addedNodes = event.nodes.filter((n) => !existingIds.has(n.id));
// Prune any pristine nodes that were dropped from the upstream history
this.buffer = this.buffer.prunePristineNodes(newIds);
if (addedNodes.length > 0) {
this.buffer = this.buffer.appendPristineNodes(addedNodes);
}
this.evaluateTriggers(event.newNodes);
});
}
/**
* Safely stops background async pipelines and clears event listeners.
*/
shutdown() {
this.orchestrator.shutdown();
this.historyObserver.stop();
}
/**
* Evaluates if the current working buffer exceeds configured budget thresholds,
* firing consolidation events if necessary.
*/
private evaluateTriggers(newNodes: Set<string>) {
if (!this.sidecar.config.budget) return;
if (newNodes.size > 0) {
this.eventBus.emitChunkReceived({
nodes: this.buffer.nodes,
targetNodeIds: newNodes,
});
}
const currentTokens = this.env.tokenCalculator.calculateConcreteListTokens(
this.buffer.nodes,
);
if (currentTokens > this.sidecar.config.budget.retainedTokens) {
const agedOutNodes = new Set<string>();
let rollingTokens = 0;
// Walk backwards finding nodes that fall out of the retained budget
for (let i = this.buffer.nodes.length - 1; i >= 0; i--) {
const node = this.buffer.nodes[i];
rollingTokens += this.env.tokenCalculator.calculateConcreteListTokens([
node,
]);
if (rollingTokens > this.sidecar.config.budget.retainedTokens) {
agedOutNodes.add(node.id);
}
}
if (agedOutNodes.size > 0) {
this.env.tokenCalculator.garbageCollectCache(
new Set(this.buffer.nodes.map((n) => n.id)),
);
this.eventBus.emitConsolidationNeeded({
nodes: this.buffer.nodes,
targetDeficit:
currentTokens - this.sidecar.config.budget.retainedTokens,
targetNodeIds: agedOutNodes,
});
}
}
}
/**
* Retrieves the raw, uncompressed Episodic Context Graph graph.
* Useful for internal tool rendering (like the trace viewer).
* Note: This is an expensive, deep clone operation.
*/
getPristineGraph(): readonly ConcreteNode[] {
const pristineSet = new Map<string, ConcreteNode>();
for (const node of this.buffer.nodes) {
const roots = this.buffer.getPristineNodes(node.id);
for (const root of roots) {
pristineSet.set(root.id, root);
}
}
// We sort them by timestamp to ensure they are returned in chronological order
return Array.from(pristineSet.values()).sort(
(a, b) => a.timestamp - b.timestamp,
);
}
/**
* Generates a virtual view of the pristine graph, substituting in variants
* up to the configured token budget.
* This is the view that will eventually be projected back to the LLM.
*/
getNodes(): readonly ConcreteNode[] {
return [...this.buffer.nodes];
}
/**
* Executes the final 'gc_backstop' pipeline if necessary, enforcing the token budget,
* and maps the Episodic Context Graph back into a raw Gemini Content[] array for transmission.
* This is the primary method called by the agent framework before sending a request.
*/
async renderHistory(
activeTaskIds: Set<string> = new Set(),
): Promise<Content[]> {
this.tracer.logEvent('ContextManager', 'Starting rendering of LLM context');
// Apply final GC Backstop pressure barrier synchronously before mapping
const finalHistory = await render(
this.buffer.nodes,
this.orchestrator,
this.sidecar,
this.tracer,
this.env,
activeTaskIds,
);
this.tracer.logEvent('ContextManager', 'Finished rendering');
return finalHistory;
}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { EventEmitter } from 'node:events';
import type { ConcreteNode } from './graph/types.js';
export interface PristineHistoryUpdatedEvent {
nodes: readonly ConcreteNode[];
newNodes: Set<string>;
}
export interface ContextConsolidationEvent {
nodes: readonly ConcreteNode[];
targetDeficit: number;
targetNodeIds: Set<string>;
}
export interface ChunkReceivedEvent {
nodes: readonly ConcreteNode[];
targetNodeIds: Set<string>;
}
export class ContextEventBus extends EventEmitter {
emitPristineHistoryUpdated(event: PristineHistoryUpdatedEvent) {
this.emit('PRISTINE_HISTORY_UPDATED', event);
}
onPristineHistoryUpdated(
listener: (event: PristineHistoryUpdatedEvent) => void,
) {
this.on('PRISTINE_HISTORY_UPDATED', listener);
}
emitChunkReceived(event: ChunkReceivedEvent) {
this.emit('IR_CHUNK_RECEIVED', event);
}
onChunkReceived(listener: (event: ChunkReceivedEvent) => void) {
this.on('IR_CHUNK_RECEIVED', listener);
}
emitConsolidationNeeded(event: ContextConsolidationEvent) {
this.emit('BUDGET_RETAINED_CROSSED', event);
}
onConsolidationNeeded(listener: (event: ContextConsolidationEvent) => void) {
this.on('BUDGET_RETAINED_CROSSED', listener);
}
}
@@ -0,0 +1,43 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content, Part } from '@google/genai';
import type { ConcreteNode } from './types.js';
export interface NodeSerializationWriter {
appendContent(content: Content): void;
appendModelPart(part: Part): void;
appendUserPart(part: Part): void;
flushModelParts(): void;
}
export interface NodeBehavior<T extends ConcreteNode = ConcreteNode> {
readonly type: T['type'];
/** Serializes the node into the Gemini Content structure. */
serialize(node: T, writer: NodeSerializationWriter): void;
/**
* Generates a structural representation of the node for the purpose
* of estimating its token cost.
*/
getEstimatableParts(node: T): Part[];
}
export class NodeBehaviorRegistry {
private readonly behaviors = new Map<string, NodeBehavior<ConcreteNode>>();
register<T extends ConcreteNode>(behavior: NodeBehavior<T>) {
this.behaviors.set(behavior.type, behavior);
}
get(type: string): NodeBehavior<ConcreteNode> {
const behavior = this.behaviors.get(type);
if (!behavior) {
throw new Error(`Unregistered Node type: ${type}`);
}
return behavior;
}
}

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