Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7df8c88d96 | |||
| 166845d933 | |||
| 55620235c0 | |||
| 366f9e4766 | |||
| 06e7621b26 | |||
| 8d05bdbe49 | |||
| 5b1f7375a3 | |||
| d613dd05db | |||
| a6d43cba2d | |||
| 161ba28966 | |||
| 05aa1465fe | |||
| 8f6edc50c1 | |||
| 88ddcab616 | |||
| 02792264ed | |||
| 059d9175eb | |||
| 212edf31ed | |||
| 1bb41262b0 | |||
| daf5006237 | |||
| 706d4d4707 | |||
| 24f9ec51d2 | |||
| 050c30330e | |||
| a172b328e2 | |||
| a4318f22ec | |||
| 82e8d67a78 | |||
| 95944ec5af | |||
| ea36ccb567 | |||
| a05c5ed56a | |||
| 0d6d5d90b9 | |||
| b91d177bde | |||
| 36dca862cc | |||
| a5f7b453ca | |||
| 26f04c9d9a | |||
| 5d8bd41937 |
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -87,11 +87,23 @@ Gemini CLI comes with the following built-in subagents:
|
||||
|
||||
### Generalist Agent
|
||||
|
||||
- **Name:** `generalist_agent`
|
||||
- **Purpose:** Route tasks to the appropriate specialized subagent.
|
||||
- **When to use:** Implicitly used by the main agent for routing. Not directly
|
||||
invoked by the user.
|
||||
- **Configuration:** Enabled by default. No specific configuration options.
|
||||
- **Name:** `generalist`
|
||||
- **Purpose:** A general, all-purpose subagent that uses the inherited tool
|
||||
access and configurations from the main agent. Useful for executing broad,
|
||||
resource-heavy subtasks in an isolated conversation, optimizing your main
|
||||
agent's context by returning only the final result of that given task.
|
||||
- **When to use:** Use this agent when a task requires many steps, handles large
|
||||
volumes of information, or requires the same full capabilities as the main
|
||||
agent. It is ideal for:
|
||||
- **Multi-file modifications:** Applying refactors or fixing errors across
|
||||
several files at once.
|
||||
- **High-volume execution:** Running commands or tests that produce extensive
|
||||
terminal output.
|
||||
- **Action-oriented research:** Investigations where the agent needs to both
|
||||
search code and run commands or make edits to find a solution. By delegating
|
||||
these tasks, you prevent your main conversation from becoming cluttered or
|
||||
slow. You can invoke it explicitly using `@generalist`.
|
||||
- **Configuration:** Enabled by default.
|
||||
|
||||
### Browser Agent (experimental)
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ multiple layers in the following order of precedence (highest to lowest):
|
||||
Hooks are executed with a sanitized environment.
|
||||
|
||||
- `GEMINI_PROJECT_DIR`: The absolute path to the project root.
|
||||
- `GEMINI_PLANS_DIR`: The absolute path to the plans directory.
|
||||
- `GEMINI_SESSION_ID`: The unique ID for the current session.
|
||||
- `GEMINI_CWD`: The current working directory.
|
||||
- `CLAUDE_PROJECT_DIR`: (Alias) Provided for compatibility.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -602,6 +602,7 @@ const mockUIActions: UIActions = {
|
||||
|
||||
import { type TextBuffer } from '../ui/components/shared/text-buffer.js';
|
||||
import { InputContext, type InputState } from '../ui/contexts/InputContext.js';
|
||||
import { QuotaContext, type QuotaState } from '../ui/contexts/QuotaContext.js';
|
||||
|
||||
let capturedOverflowState: OverflowState | undefined;
|
||||
let capturedOverflowActions: OverflowActions | undefined;
|
||||
@@ -619,6 +620,7 @@ export const renderWithProviders = async (
|
||||
shellFocus = true,
|
||||
settings = mockSettings,
|
||||
uiState: providedUiState,
|
||||
quotaState: providedQuotaState,
|
||||
inputState: providedInputState,
|
||||
width,
|
||||
mouseEventsEnabled = false,
|
||||
@@ -631,6 +633,7 @@ export const renderWithProviders = async (
|
||||
shellFocus?: boolean;
|
||||
settings?: LoadedSettings;
|
||||
uiState?: Partial<UIState>;
|
||||
quotaState?: Partial<QuotaState>;
|
||||
inputState?: Partial<InputState>;
|
||||
width?: number;
|
||||
mouseEventsEnabled?: boolean;
|
||||
@@ -666,6 +669,16 @@ export const renderWithProviders = async (
|
||||
},
|
||||
) as UIState;
|
||||
|
||||
const quotaState: QuotaState = {
|
||||
userTier: undefined,
|
||||
stats: undefined,
|
||||
proQuotaRequest: null,
|
||||
validationRequest: null,
|
||||
overageMenuRequest: null,
|
||||
emptyWalletRequest: null,
|
||||
...providedQuotaState,
|
||||
};
|
||||
|
||||
const inputState = {
|
||||
buffer: { text: '' } as unknown as TextBuffer,
|
||||
userMessages: [],
|
||||
@@ -727,65 +740,67 @@ export const renderWithProviders = async (
|
||||
<AppContext.Provider value={appState}>
|
||||
<ConfigContext.Provider value={config}>
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<InputContext.Provider value={inputState}>
|
||||
<UIStateContext.Provider value={finalUiState}>
|
||||
<VimModeProvider>
|
||||
<ShellFocusContext.Provider value={shellFocus}>
|
||||
<SessionStatsProvider sessionId={config.getSessionId()}>
|
||||
<StreamingContext.Provider
|
||||
value={finalUiState.streamingState}
|
||||
>
|
||||
<UIActionsContext.Provider value={finalUIActions}>
|
||||
<OverflowProvider>
|
||||
<ToolActionsProvider
|
||||
config={config}
|
||||
toolCalls={allToolCalls}
|
||||
isExpanded={
|
||||
toolActions?.isExpanded ??
|
||||
vi.fn().mockReturnValue(false)
|
||||
}
|
||||
toggleExpansion={
|
||||
toolActions?.toggleExpansion ?? vi.fn()
|
||||
}
|
||||
toggleAllExpansion={
|
||||
toolActions?.toggleAllExpansion ?? vi.fn()
|
||||
}
|
||||
>
|
||||
<AskUserActionsProvider
|
||||
request={null}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
<QuotaContext.Provider value={quotaState}>
|
||||
<InputContext.Provider value={inputState}>
|
||||
<UIStateContext.Provider value={finalUiState}>
|
||||
<VimModeProvider>
|
||||
<ShellFocusContext.Provider value={shellFocus}>
|
||||
<SessionStatsProvider sessionId={config.getSessionId()}>
|
||||
<StreamingContext.Provider
|
||||
value={finalUiState.streamingState}
|
||||
>
|
||||
<UIActionsContext.Provider value={finalUIActions}>
|
||||
<OverflowProvider>
|
||||
<ToolActionsProvider
|
||||
config={config}
|
||||
toolCalls={allToolCalls}
|
||||
isExpanded={
|
||||
toolActions?.isExpanded ??
|
||||
vi.fn().mockReturnValue(false)
|
||||
}
|
||||
toggleExpansion={
|
||||
toolActions?.toggleExpansion ?? vi.fn()
|
||||
}
|
||||
toggleAllExpansion={
|
||||
toolActions?.toggleAllExpansion ?? vi.fn()
|
||||
}
|
||||
>
|
||||
<KeypressProvider>
|
||||
<MouseProvider
|
||||
mouseEventsEnabled={mouseEventsEnabled}
|
||||
>
|
||||
<TerminalProvider>
|
||||
<ScrollProvider>
|
||||
<ContextCapture>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{comp}
|
||||
</Box>
|
||||
</ContextCapture>
|
||||
</ScrollProvider>
|
||||
</TerminalProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</AskUserActionsProvider>
|
||||
</ToolActionsProvider>
|
||||
</OverflowProvider>
|
||||
</UIActionsContext.Provider>
|
||||
</StreamingContext.Provider>
|
||||
</SessionStatsProvider>
|
||||
</ShellFocusContext.Provider>
|
||||
</VimModeProvider>
|
||||
</UIStateContext.Provider>
|
||||
</InputContext.Provider>
|
||||
<AskUserActionsProvider
|
||||
request={null}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
>
|
||||
<KeypressProvider>
|
||||
<MouseProvider
|
||||
mouseEventsEnabled={mouseEventsEnabled}
|
||||
>
|
||||
<TerminalProvider>
|
||||
<ScrollProvider>
|
||||
<ContextCapture>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{comp}
|
||||
</Box>
|
||||
</ContextCapture>
|
||||
</ScrollProvider>
|
||||
</TerminalProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</AskUserActionsProvider>
|
||||
</ToolActionsProvider>
|
||||
</OverflowProvider>
|
||||
</UIActionsContext.Provider>
|
||||
</StreamingContext.Provider>
|
||||
</SessionStatsProvider>
|
||||
</ShellFocusContext.Provider>
|
||||
</VimModeProvider>
|
||||
</UIStateContext.Provider>
|
||||
</InputContext.Provider>
|
||||
</QuotaContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
</AppContext.Provider>
|
||||
|
||||
@@ -123,16 +123,19 @@ vi.mock('ink', async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { InputContext, type InputState } from './contexts/InputContext.js';
|
||||
import { QuotaContext, type QuotaState } from './contexts/QuotaContext.js';
|
||||
|
||||
// Helper component will read the context values provided by AppContainer
|
||||
// so we can assert against them in our tests.
|
||||
let capturedUIState: UIState;
|
||||
let capturedInputState: InputState;
|
||||
let capturedQuotaState: QuotaState;
|
||||
let capturedUIActions: UIActions;
|
||||
let capturedOverflowActions: OverflowActions;
|
||||
function TestContextConsumer() {
|
||||
capturedUIState = useContext(UIStateContext)!;
|
||||
capturedInputState = useContext(InputContext)!;
|
||||
capturedQuotaState = useContext(QuotaContext)!;
|
||||
capturedUIActions = useContext(UIActionsContext)!;
|
||||
capturedOverflowActions = useOverflowActions()!;
|
||||
return null;
|
||||
@@ -1309,15 +1312,15 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('Quota and Fallback Integration', () => {
|
||||
it('passes a null proQuotaRequest to UIStateContext by default', async () => {
|
||||
it('passes a null proQuotaRequest to QuotaContext by default', async () => {
|
||||
// The default mock from beforeEach already sets proQuotaRequest to null
|
||||
const { unmount } = await act(async () => renderAppContainer());
|
||||
// Assert that the context value is as expected
|
||||
expect(capturedUIState.quota.proQuotaRequest).toBeNull();
|
||||
expect(capturedQuotaState.proQuotaRequest).toBeNull();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('passes a valid proQuotaRequest to UIStateContext when provided by the hook', async () => {
|
||||
it('passes a valid proQuotaRequest to QuotaContext when provided by the hook', async () => {
|
||||
// Arrange: Create a mock request object that a UI dialog would receive
|
||||
const mockRequest = {
|
||||
failedModel: 'gemini-pro',
|
||||
@@ -1332,7 +1335,7 @@ describe('AppContainer State Management', () => {
|
||||
// Act: Render the container
|
||||
const { unmount } = await act(async () => renderAppContainer());
|
||||
// Assert: The mock request is correctly passed through the context
|
||||
expect(capturedUIState.quota.proQuotaRequest).toEqual(mockRequest);
|
||||
expect(capturedQuotaState.proQuotaRequest).toEqual(mockRequest);
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { App } from './App.js';
|
||||
import { AppContext } from './contexts/AppContext.js';
|
||||
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
|
||||
import { QuotaContext } from './contexts/QuotaContext.js';
|
||||
import {
|
||||
UIActionsContext,
|
||||
type UIActions,
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -1836,7 +1836,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
height={Math.min(buffer.viewportHeight, scrollableData.length)}
|
||||
width="100%"
|
||||
>
|
||||
{isAlternateBuffer ? (
|
||||
{config.getUseTerminalBuffer() ? (
|
||||
<ScrollableList
|
||||
ref={listRef}
|
||||
hasFocus={focus}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import { Box, Static } from 'ink';
|
||||
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useAppContext } from '../contexts/AppContext.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
|
||||
@@ -22,7 +21,6 @@ import { useMemo, memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
|
||||
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
|
||||
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
|
||||
import { isTopicTool } from './messages/TopicMessage.js';
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
|
||||
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
|
||||
@@ -82,35 +80,6 @@ export const MainContent = () => {
|
||||
return -1;
|
||||
}, [uiState.history]);
|
||||
|
||||
const settings = useSettings();
|
||||
const topicUpdateNarrationEnabled =
|
||||
settings.merged.experimental?.topicUpdateNarration === true;
|
||||
|
||||
const suppressNarrationFlags = useMemo(() => {
|
||||
const combinedHistory = [...uiState.history, ...pendingHistoryItems];
|
||||
const flags = new Array<boolean>(combinedHistory.length).fill(false);
|
||||
|
||||
if (topicUpdateNarrationEnabled) {
|
||||
let toolGroupInTurn = false;
|
||||
for (let i = combinedHistory.length - 1; i >= 0; i--) {
|
||||
const item = combinedHistory[i];
|
||||
if (item.type === 'user' || item.type === 'user_shell') {
|
||||
toolGroupInTurn = false;
|
||||
} else if (item.type === 'tool_group') {
|
||||
toolGroupInTurn = item.tools.some((t) => isTopicTool(t.name));
|
||||
} else if (
|
||||
(item.type === 'thinking' ||
|
||||
item.type === 'gemini' ||
|
||||
item.type === 'gemini_content') &&
|
||||
toolGroupInTurn
|
||||
) {
|
||||
flags[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
}, [uiState.history, pendingHistoryItems, topicUpdateNarrationEnabled]);
|
||||
|
||||
const augmentedHistory = useMemo(
|
||||
() =>
|
||||
uiState.history.map((item, i) => {
|
||||
@@ -129,10 +98,9 @@ export const MainContent = () => {
|
||||
isFirstThinking,
|
||||
isFirstAfterThinking,
|
||||
isToolGroupBoundary,
|
||||
suppressNarration: suppressNarrationFlags[i] ?? false,
|
||||
};
|
||||
}),
|
||||
[uiState.history, lastUserPromptIndex, suppressNarrationFlags],
|
||||
[uiState.history, lastUserPromptIndex],
|
||||
);
|
||||
|
||||
const historyItems = useMemo(
|
||||
@@ -144,7 +112,6 @@ export const MainContent = () => {
|
||||
isFirstThinking,
|
||||
isFirstAfterThinking,
|
||||
isToolGroupBoundary,
|
||||
suppressNarration,
|
||||
}) => (
|
||||
<MemoizedHistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
@@ -162,7 +129,6 @@ export const MainContent = () => {
|
||||
isFirstThinking={isFirstThinking}
|
||||
isFirstAfterThinking={isFirstAfterThinking}
|
||||
isToolGroupBoundary={isToolGroupBoundary}
|
||||
suppressNarration={suppressNarration}
|
||||
/>
|
||||
),
|
||||
),
|
||||
@@ -201,9 +167,6 @@ export const MainContent = () => {
|
||||
(item.type !== 'tool_group' && prevType === 'tool_group') ||
|
||||
(item.type === 'tool_group' && prevType !== 'tool_group');
|
||||
|
||||
const suppressNarration =
|
||||
suppressNarrationFlags[uiState.history.length + i] ?? false;
|
||||
|
||||
return (
|
||||
<HistoryItemDisplay
|
||||
key={`pending-${i}`}
|
||||
@@ -217,7 +180,6 @@ export const MainContent = () => {
|
||||
isFirstThinking={isFirstThinking}
|
||||
isFirstAfterThinking={isFirstAfterThinking}
|
||||
isToolGroupBoundary={isToolGroupBoundary}
|
||||
suppressNarration={suppressNarration}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -237,7 +199,6 @@ export const MainContent = () => {
|
||||
showConfirmationQueue,
|
||||
confirmingTool,
|
||||
uiState.history,
|
||||
suppressNarrationFlags,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -5,12 +5,16 @@
|
||||
*/
|
||||
|
||||
import { act } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Config, InboxSkill } from '@google/gemini-cli-core';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Config, InboxSkill, InboxPatch } from '@google/gemini-cli-core';
|
||||
import {
|
||||
dismissInboxSkill,
|
||||
listInboxSkills,
|
||||
listInboxPatches,
|
||||
moveInboxSkill,
|
||||
applyInboxPatch,
|
||||
dismissInboxPatch,
|
||||
isProjectSkillPatchTarget,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
@@ -24,7 +28,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
...original,
|
||||
dismissInboxSkill: vi.fn(),
|
||||
listInboxSkills: vi.fn(),
|
||||
listInboxPatches: vi.fn(),
|
||||
moveInboxSkill: vi.fn(),
|
||||
applyInboxPatch: vi.fn(),
|
||||
dismissInboxPatch: vi.fn(),
|
||||
isProjectSkillPatchTarget: vi.fn(),
|
||||
getErrorMessage: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error),
|
||||
),
|
||||
@@ -32,20 +40,108 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
});
|
||||
|
||||
const mockListInboxSkills = vi.mocked(listInboxSkills);
|
||||
const mockListInboxPatches = vi.mocked(listInboxPatches);
|
||||
const mockMoveInboxSkill = vi.mocked(moveInboxSkill);
|
||||
const mockDismissInboxSkill = vi.mocked(dismissInboxSkill);
|
||||
const mockApplyInboxPatch = vi.mocked(applyInboxPatch);
|
||||
const mockDismissInboxPatch = vi.mocked(dismissInboxPatch);
|
||||
const mockIsProjectSkillPatchTarget = vi.mocked(isProjectSkillPatchTarget);
|
||||
|
||||
const inboxSkill: InboxSkill = {
|
||||
dirName: 'inbox-skill',
|
||||
name: 'Inbox Skill',
|
||||
description: 'A test skill',
|
||||
content:
|
||||
'---\nname: Inbox Skill\ndescription: A test skill\n---\n\n## Procedure\n1. Do the thing\n',
|
||||
extractedAt: '2025-01-15T10:00:00Z',
|
||||
};
|
||||
|
||||
const inboxPatch: InboxPatch = {
|
||||
fileName: 'update-docs.patch',
|
||||
name: 'update-docs',
|
||||
entries: [
|
||||
{
|
||||
targetPath: '/home/user/.gemini/skills/docs-writer/SKILL.md',
|
||||
diffContent: [
|
||||
'--- /home/user/.gemini/skills/docs-writer/SKILL.md',
|
||||
'+++ /home/user/.gemini/skills/docs-writer/SKILL.md',
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' line1',
|
||||
' line2',
|
||||
'+line2.5',
|
||||
' line3',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
extractedAt: '2025-01-20T14:00:00Z',
|
||||
};
|
||||
|
||||
const workspacePatch: InboxPatch = {
|
||||
fileName: 'workspace-update.patch',
|
||||
name: 'workspace-update',
|
||||
entries: [
|
||||
{
|
||||
targetPath: '/repo/.gemini/skills/docs-writer/SKILL.md',
|
||||
diffContent: [
|
||||
'--- /repo/.gemini/skills/docs-writer/SKILL.md',
|
||||
'+++ /repo/.gemini/skills/docs-writer/SKILL.md',
|
||||
'@@ -1,1 +1,2 @@',
|
||||
' line1',
|
||||
'+line2',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const multiSectionPatch: InboxPatch = {
|
||||
fileName: 'multi-section.patch',
|
||||
name: 'multi-section',
|
||||
entries: [
|
||||
{
|
||||
targetPath: '/home/user/.gemini/skills/docs-writer/SKILL.md',
|
||||
diffContent: [
|
||||
'--- /home/user/.gemini/skills/docs-writer/SKILL.md',
|
||||
'+++ /home/user/.gemini/skills/docs-writer/SKILL.md',
|
||||
'@@ -1,1 +1,2 @@',
|
||||
' line1',
|
||||
'+line2',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
targetPath: '/home/user/.gemini/skills/docs-writer/SKILL.md',
|
||||
diffContent: [
|
||||
'--- /home/user/.gemini/skills/docs-writer/SKILL.md',
|
||||
'+++ /home/user/.gemini/skills/docs-writer/SKILL.md',
|
||||
'@@ -3,1 +4,2 @@',
|
||||
' line3',
|
||||
'+line4',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const windowsGlobalPatch: InboxPatch = {
|
||||
fileName: 'windows-update.patch',
|
||||
name: 'windows-update',
|
||||
entries: [
|
||||
{
|
||||
targetPath: 'C:\\Users\\sandy\\.gemini\\skills\\docs-writer\\SKILL.md',
|
||||
diffContent: [
|
||||
'--- C:\\Users\\sandy\\.gemini\\skills\\docs-writer\\SKILL.md',
|
||||
'+++ C:\\Users\\sandy\\.gemini\\skills\\docs-writer\\SKILL.md',
|
||||
'@@ -1,1 +1,2 @@',
|
||||
' line1',
|
||||
'+line2',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('SkillInboxDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockListInboxSkills.mockResolvedValue([inboxSkill]);
|
||||
mockListInboxPatches.mockResolvedValue([]);
|
||||
mockMoveInboxSkill.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Moved "inbox-skill" to ~/.gemini/skills.',
|
||||
@@ -54,6 +150,30 @@ describe('SkillInboxDialog', () => {
|
||||
success: true,
|
||||
message: 'Dismissed "inbox-skill" from inbox.',
|
||||
});
|
||||
mockApplyInboxPatch.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Applied patch to 1 file.',
|
||||
});
|
||||
mockDismissInboxPatch.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Dismissed "update-docs.patch" from inbox.',
|
||||
});
|
||||
mockIsProjectSkillPatchTarget.mockImplementation(
|
||||
async (targetPath: string, config: Config) => {
|
||||
const projectSkillsDir = config.storage
|
||||
?.getProjectSkillsDir?.()
|
||||
?.replaceAll('\\', '/')
|
||||
?.replace(/\/+$/, '');
|
||||
|
||||
return projectSkillsDir
|
||||
? targetPath.replaceAll('\\', '/').startsWith(projectSkillsDir)
|
||||
: false;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('disables the project destination when the workspace is untrusted', async () => {
|
||||
@@ -75,6 +195,17 @@ describe('SkillInboxDialog', () => {
|
||||
expect(lastFrame()).toContain('Inbox Skill');
|
||||
});
|
||||
|
||||
// Select skill → lands on preview
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Review new skill');
|
||||
});
|
||||
|
||||
// Select "Move" → lands on destination chooser
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
@@ -86,22 +217,6 @@ describe('SkillInboxDialog', () => {
|
||||
expect(frame).toContain('unavailable until this workspace is trusted');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x1b[B');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDismissInboxSkill).toHaveBeenCalledWith(config, 'inbox-skill');
|
||||
});
|
||||
expect(mockMoveInboxSkill).not.toHaveBeenCalled();
|
||||
expect(onReloadSkills).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -125,11 +240,19 @@ describe('SkillInboxDialog', () => {
|
||||
expect(lastFrame()).toContain('Inbox Skill');
|
||||
});
|
||||
|
||||
// Select skill → preview
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
// Select "Move" → destination chooser
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
// Select "Global" → triggers move
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
@@ -165,11 +288,19 @@ describe('SkillInboxDialog', () => {
|
||||
expect(lastFrame()).toContain('Inbox Skill');
|
||||
});
|
||||
|
||||
// Select skill → preview
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
// Select "Move" → destination chooser
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
// Select "Global" → triggers move
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
@@ -184,4 +315,346 @@ describe('SkillInboxDialog', () => {
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('patch support', () => {
|
||||
it('shows patches alongside skills with section headers', async () => {
|
||||
mockListInboxPatches.mockResolvedValue([inboxPatch]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
const { lastFrame, unmount } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('New Skills');
|
||||
expect(frame).toContain('Inbox Skill');
|
||||
expect(frame).toContain('Skill Updates');
|
||||
expect(frame).toContain('update-docs');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows diff preview when a patch is selected', async () => {
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxPatches.mockResolvedValue([inboxPatch]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('update-docs');
|
||||
});
|
||||
|
||||
// Select the patch
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Review changes before applying');
|
||||
expect(frame).toContain('Apply');
|
||||
expect(frame).toContain('Dismiss');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('applies a patch when Apply is selected', async () => {
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxPatches.mockResolvedValue([inboxPatch]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
|
||||
const { stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={onReloadSkills}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockListInboxPatches).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Select the patch
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
// Select "Apply"
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApplyInboxPatch).toHaveBeenCalledWith(
|
||||
config,
|
||||
'update-docs.patch',
|
||||
);
|
||||
});
|
||||
expect(onReloadSkills).toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('disables Apply for workspace patches in an untrusted workspace', async () => {
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxPatches.mockResolvedValue([workspacePatch]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(false),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('workspace-update');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Apply');
|
||||
expect(frame).toContain(
|
||||
'.gemini/skills — unavailable until this workspace is trusted',
|
||||
);
|
||||
});
|
||||
expect(mockApplyInboxPatch).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('uses canonical project-scope checks before enabling Apply', async () => {
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxPatches.mockResolvedValue([workspacePatch]);
|
||||
mockIsProjectSkillPatchTarget.mockResolvedValue(true);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(false),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi
|
||||
.fn()
|
||||
.mockReturnValue('/symlinked/workspace/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('workspace-update');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain(
|
||||
'.gemini/skills — unavailable until this workspace is trusted',
|
||||
);
|
||||
});
|
||||
expect(mockIsProjectSkillPatchTarget).toHaveBeenCalledWith(
|
||||
'/repo/.gemini/skills/docs-writer/SKILL.md',
|
||||
config,
|
||||
);
|
||||
expect(mockApplyInboxPatch).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('dismisses a patch when Dismiss is selected', async () => {
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxPatches.mockResolvedValue([inboxPatch]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
|
||||
const { stdin, unmount, waitUntilReady } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={onReloadSkills}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockListInboxPatches).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Select the patch
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
// Move down to "Dismiss" and select
|
||||
await act(async () => {
|
||||
stdin.write('\x1b[B');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDismissInboxPatch).toHaveBeenCalledWith(
|
||||
config,
|
||||
'update-docs.patch',
|
||||
);
|
||||
});
|
||||
expect(onReloadSkills).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows Windows patch entries with a basename and origin tag', async () => {
|
||||
vi.stubEnv('USERPROFILE', 'C:\\Users\\sandy');
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxPatches.mockResolvedValue([windowsGlobalPatch]);
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi
|
||||
.fn()
|
||||
.mockReturnValue('C:\\repo\\.gemini\\skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
const { lastFrame, unmount } = await act(async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('[Global]');
|
||||
expect(frame).toContain('SKILL.md');
|
||||
expect(frame).not.toContain('C:\\Users\\sandy\\.gemini\\skills');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders multi-section patches without duplicate React keys', async () => {
|
||||
mockListInboxSkills.mockResolvedValue([]);
|
||||
mockListInboxPatches.mockResolvedValue([multiSectionPatch]);
|
||||
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const config = {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
|
||||
},
|
||||
} as unknown as Config;
|
||||
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
|
||||
async () =>
|
||||
renderWithProviders(
|
||||
<SkillInboxDialog
|
||||
config={config}
|
||||
onClose={vi.fn()}
|
||||
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('multi-section');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r');
|
||||
await waitUntilReady();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Review changes before applying');
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('Encountered two children with the same key'),
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import type React from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Box, Text, useStdout } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
@@ -14,25 +15,42 @@ import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
import { BaseSelectionList } from './shared/BaseSelectionList.js';
|
||||
import type { SelectionListItem } from '../hooks/useSelectionList.js';
|
||||
import { DialogFooter } from './shared/DialogFooter.js';
|
||||
import { DiffRenderer } from './messages/DiffRenderer.js';
|
||||
import {
|
||||
type Config,
|
||||
type InboxSkill,
|
||||
type InboxPatch,
|
||||
type InboxSkillDestination,
|
||||
getErrorMessage,
|
||||
listInboxSkills,
|
||||
listInboxPatches,
|
||||
moveInboxSkill,
|
||||
dismissInboxSkill,
|
||||
applyInboxPatch,
|
||||
dismissInboxPatch,
|
||||
isProjectSkillPatchTarget,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
type Phase = 'list' | 'action';
|
||||
type Phase = 'list' | 'skill-preview' | 'skill-action' | 'patch-preview';
|
||||
|
||||
type InboxItem =
|
||||
| { type: 'skill'; skill: InboxSkill }
|
||||
| { type: 'patch'; patch: InboxPatch; targetsProjectSkills: boolean }
|
||||
| { type: 'header'; label: string };
|
||||
|
||||
interface DestinationChoice {
|
||||
destination: InboxSkillDestination | 'dismiss';
|
||||
destination: InboxSkillDestination;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const DESTINATION_CHOICES: DestinationChoice[] = [
|
||||
interface PatchAction {
|
||||
action: 'apply' | 'dismiss';
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SKILL_DESTINATION_CHOICES: DestinationChoice[] = [
|
||||
{
|
||||
destination: 'global',
|
||||
label: 'Global',
|
||||
@@ -43,13 +61,105 @@ const DESTINATION_CHOICES: DestinationChoice[] = [
|
||||
label: 'Project',
|
||||
description: '.gemini/skills — available in this workspace',
|
||||
},
|
||||
];
|
||||
|
||||
interface SkillPreviewAction {
|
||||
action: 'move' | 'dismiss';
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SKILL_PREVIEW_CHOICES: SkillPreviewAction[] = [
|
||||
{
|
||||
destination: 'dismiss',
|
||||
action: 'move',
|
||||
label: 'Move',
|
||||
description: 'Choose where to install this skill',
|
||||
},
|
||||
{
|
||||
action: 'dismiss',
|
||||
label: 'Dismiss',
|
||||
description: 'Delete from inbox',
|
||||
},
|
||||
];
|
||||
|
||||
const PATCH_ACTION_CHOICES: PatchAction[] = [
|
||||
{
|
||||
action: 'apply',
|
||||
label: 'Apply',
|
||||
description: 'Apply patch and delete from inbox',
|
||||
},
|
||||
{
|
||||
action: 'dismiss',
|
||||
label: 'Dismiss',
|
||||
description: 'Delete from inbox without applying',
|
||||
},
|
||||
];
|
||||
|
||||
function normalizePathForUi(filePath: string): string {
|
||||
return path.posix.normalize(filePath.replaceAll('\\', '/'));
|
||||
}
|
||||
|
||||
function getPathBasename(filePath: string): string {
|
||||
const normalizedPath = normalizePathForUi(filePath);
|
||||
const basename = path.posix.basename(normalizedPath);
|
||||
return basename === '.' ? filePath : basename;
|
||||
}
|
||||
|
||||
async function patchTargetsProjectSkills(
|
||||
patch: InboxPatch,
|
||||
config: Config,
|
||||
): Promise<boolean> {
|
||||
const entryTargetsProjectSkills = await Promise.all(
|
||||
patch.entries.map((entry) =>
|
||||
isProjectSkillPatchTarget(entry.targetPath, config),
|
||||
),
|
||||
);
|
||||
return entryTargetsProjectSkills.some(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a bracketed origin tag from a skill file path,
|
||||
* matching the existing [Built-in] convention in SkillsList.
|
||||
*/
|
||||
function getSkillOriginTag(filePath: string): string {
|
||||
const normalizedPath = normalizePathForUi(filePath);
|
||||
|
||||
if (normalizedPath.includes('/bundle/')) {
|
||||
return 'Built-in';
|
||||
}
|
||||
if (normalizedPath.includes('/extensions/')) {
|
||||
return 'Extension';
|
||||
}
|
||||
if (normalizedPath.includes('/.gemini/skills/')) {
|
||||
const homeDirs = [process.env['HOME'], process.env['USERPROFILE']]
|
||||
.filter((homeDir): homeDir is string => Boolean(homeDir))
|
||||
.map(normalizePathForUi);
|
||||
if (
|
||||
homeDirs.some((homeDir) =>
|
||||
normalizedPath.startsWith(`${homeDir}/.gemini/skills/`),
|
||||
)
|
||||
) {
|
||||
return 'Global';
|
||||
}
|
||||
return 'Workspace';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a unified diff string representing a new file.
|
||||
*/
|
||||
function newFileDiff(filename: string, content: string): string {
|
||||
const lines = content.split('\n');
|
||||
const hunkLines = lines.map((l) => `+${l}`).join('\n');
|
||||
return [
|
||||
`--- /dev/null`,
|
||||
`+++ ${filename}`,
|
||||
`@@ -0,0 +1,${lines.length} @@`,
|
||||
hunkLines,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function formatDate(isoString: string): string {
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
@@ -75,29 +185,57 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
onReloadSkills,
|
||||
}) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const { stdout } = useStdout();
|
||||
const terminalWidth = stdout?.columns ?? 80;
|
||||
const isTrustedFolder = config.isTrustedFolder();
|
||||
const [phase, setPhase] = useState<Phase>('list');
|
||||
const [skills, setSkills] = useState<InboxSkill[]>([]);
|
||||
const [items, setItems] = useState<InboxItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedSkill, setSelectedSkill] = useState<InboxSkill | null>(null);
|
||||
const [selectedItem, setSelectedItem] = useState<InboxItem | null>(null);
|
||||
const [feedback, setFeedback] = useState<{
|
||||
text: string;
|
||||
isError: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// Load inbox skills on mount
|
||||
// Load inbox skills and patches on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await listInboxSkills(config);
|
||||
const [skills, patches] = await Promise.all([
|
||||
listInboxSkills(config),
|
||||
listInboxPatches(config),
|
||||
]);
|
||||
const patchItems = await Promise.all(
|
||||
patches.map(async (patch): Promise<InboxItem> => {
|
||||
let targetsProjectSkills = false;
|
||||
try {
|
||||
targetsProjectSkills = await patchTargetsProjectSkills(
|
||||
patch,
|
||||
config,
|
||||
);
|
||||
} catch {
|
||||
targetsProjectSkills = false;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'patch',
|
||||
patch,
|
||||
targetsProjectSkills,
|
||||
};
|
||||
}),
|
||||
);
|
||||
if (!cancelled) {
|
||||
setSkills(result);
|
||||
const combined: InboxItem[] = [
|
||||
...skills.map((skill): InboxItem => ({ type: 'skill', skill })),
|
||||
...patchItems,
|
||||
];
|
||||
setItems(combined);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setSkills([]);
|
||||
setItems([]);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
@@ -107,18 +245,56 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
const skillItems: Array<SelectionListItem<InboxSkill>> = useMemo(
|
||||
() =>
|
||||
skills.map((skill) => ({
|
||||
key: skill.dirName,
|
||||
value: skill,
|
||||
})),
|
||||
[skills],
|
||||
const getItemKey = useCallback(
|
||||
(item: InboxItem): string =>
|
||||
item.type === 'skill'
|
||||
? `skill:${item.skill.dirName}`
|
||||
: item.type === 'patch'
|
||||
? `patch:${item.patch.fileName}`
|
||||
: `header:${item.label}`,
|
||||
[],
|
||||
);
|
||||
|
||||
const listItems: Array<SelectionListItem<InboxItem>> = useMemo(() => {
|
||||
const skills = items.filter((i) => i.type === 'skill');
|
||||
const patches = items.filter((i) => i.type === 'patch');
|
||||
const result: Array<SelectionListItem<InboxItem>> = [];
|
||||
|
||||
// Only show section headers when both types are present
|
||||
const showHeaders = skills.length > 0 && patches.length > 0;
|
||||
|
||||
if (showHeaders) {
|
||||
const header: InboxItem = { type: 'header', label: 'New Skills' };
|
||||
result.push({
|
||||
key: 'header:new-skills',
|
||||
value: header,
|
||||
disabled: true,
|
||||
hideNumber: true,
|
||||
});
|
||||
}
|
||||
for (const item of skills) {
|
||||
result.push({ key: getItemKey(item), value: item });
|
||||
}
|
||||
|
||||
if (showHeaders) {
|
||||
const header: InboxItem = { type: 'header', label: 'Skill Updates' };
|
||||
result.push({
|
||||
key: 'header:skill-updates',
|
||||
value: header,
|
||||
disabled: true,
|
||||
hideNumber: true,
|
||||
});
|
||||
}
|
||||
for (const item of patches) {
|
||||
result.push({ key: getItemKey(item), value: item });
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [items, getItemKey]);
|
||||
|
||||
const destinationItems: Array<SelectionListItem<DestinationChoice>> = useMemo(
|
||||
() =>
|
||||
DESTINATION_CHOICES.map((choice) => {
|
||||
SKILL_DESTINATION_CHOICES.map((choice) => {
|
||||
if (choice.destination === 'project' && !isTrustedFolder) {
|
||||
return {
|
||||
key: choice.destination,
|
||||
@@ -139,15 +315,103 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
[isTrustedFolder],
|
||||
);
|
||||
|
||||
const handleSelectSkill = useCallback((skill: InboxSkill) => {
|
||||
setSelectedSkill(skill);
|
||||
const selectedPatchTargetsProjectSkills = useMemo(() => {
|
||||
if (!selectedItem || selectedItem.type !== 'patch') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return selectedItem.targetsProjectSkills;
|
||||
}, [selectedItem]);
|
||||
|
||||
const patchActionItems: Array<SelectionListItem<PatchAction>> = useMemo(
|
||||
() =>
|
||||
PATCH_ACTION_CHOICES.map((choice) => {
|
||||
if (
|
||||
choice.action === 'apply' &&
|
||||
selectedPatchTargetsProjectSkills &&
|
||||
!isTrustedFolder
|
||||
) {
|
||||
return {
|
||||
key: choice.action,
|
||||
value: {
|
||||
...choice,
|
||||
description:
|
||||
'.gemini/skills — unavailable until this workspace is trusted',
|
||||
},
|
||||
disabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: choice.action,
|
||||
value: choice,
|
||||
};
|
||||
}),
|
||||
[isTrustedFolder, selectedPatchTargetsProjectSkills],
|
||||
);
|
||||
|
||||
const skillPreviewItems: Array<SelectionListItem<SkillPreviewAction>> =
|
||||
useMemo(
|
||||
() =>
|
||||
SKILL_PREVIEW_CHOICES.map((choice) => ({
|
||||
key: choice.action,
|
||||
value: choice,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSelectItem = useCallback((item: InboxItem) => {
|
||||
setSelectedItem(item);
|
||||
setFeedback(null);
|
||||
setPhase('action');
|
||||
setPhase(item.type === 'skill' ? 'skill-preview' : 'patch-preview');
|
||||
}, []);
|
||||
|
||||
const removeItem = useCallback(
|
||||
(item: InboxItem) => {
|
||||
setItems((prev) =>
|
||||
prev.filter((i) => getItemKey(i) !== getItemKey(item)),
|
||||
);
|
||||
},
|
||||
[getItemKey],
|
||||
);
|
||||
|
||||
const handleSkillPreviewAction = useCallback(
|
||||
(choice: SkillPreviewAction) => {
|
||||
if (!selectedItem || selectedItem.type !== 'skill') return;
|
||||
|
||||
if (choice.action === 'move') {
|
||||
setFeedback(null);
|
||||
setPhase('skill-action');
|
||||
return;
|
||||
}
|
||||
|
||||
// Dismiss
|
||||
setFeedback(null);
|
||||
const skill = selectedItem.skill;
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await dismissInboxSkill(config, skill.dirName);
|
||||
setFeedback({ text: result.message, isError: !result.success });
|
||||
if (result.success) {
|
||||
removeItem(selectedItem);
|
||||
setSelectedItem(null);
|
||||
setPhase('list');
|
||||
}
|
||||
} catch (error) {
|
||||
setFeedback({
|
||||
text: `Failed to dismiss skill: ${getErrorMessage(error)}`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
})();
|
||||
},
|
||||
[config, selectedItem, removeItem],
|
||||
);
|
||||
|
||||
const handleSelectDestination = useCallback(
|
||||
(choice: DestinationChoice) => {
|
||||
if (!selectedSkill) return;
|
||||
if (!selectedItem || selectedItem.type !== 'skill') return;
|
||||
const skill = selectedItem.skill;
|
||||
|
||||
if (choice.destination === 'project' && !config.isTrustedFolder()) {
|
||||
setFeedback({
|
||||
@@ -161,16 +425,11 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
let result: { success: boolean; message: string };
|
||||
if (choice.destination === 'dismiss') {
|
||||
result = await dismissInboxSkill(config, selectedSkill.dirName);
|
||||
} else {
|
||||
result = await moveInboxSkill(
|
||||
config,
|
||||
selectedSkill.dirName,
|
||||
choice.destination,
|
||||
);
|
||||
}
|
||||
const result = await moveInboxSkill(
|
||||
config,
|
||||
skill.dirName,
|
||||
choice.destination,
|
||||
);
|
||||
|
||||
setFeedback({ text: result.message, isError: !result.success });
|
||||
|
||||
@@ -178,17 +437,10 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the skill from the local list.
|
||||
setSkills((prev) =>
|
||||
prev.filter((skill) => skill.dirName !== selectedSkill.dirName),
|
||||
);
|
||||
setSelectedSkill(null);
|
||||
removeItem(selectedItem);
|
||||
setSelectedItem(null);
|
||||
setPhase('list');
|
||||
|
||||
if (choice.destination === 'dismiss') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onReloadSkills();
|
||||
} catch (error) {
|
||||
@@ -197,11 +449,68 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setFeedback({
|
||||
text: `Failed to install skill: ${getErrorMessage(error)}`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
})();
|
||||
},
|
||||
[config, selectedItem, onReloadSkills, removeItem],
|
||||
);
|
||||
|
||||
const handleSelectPatchAction = useCallback(
|
||||
(choice: PatchAction) => {
|
||||
if (!selectedItem || selectedItem.type !== 'patch') return;
|
||||
const patch = selectedItem.patch;
|
||||
|
||||
if (
|
||||
choice.action === 'apply' &&
|
||||
!config.isTrustedFolder() &&
|
||||
selectedItem.targetsProjectSkills
|
||||
) {
|
||||
setFeedback({
|
||||
text: 'Project skill patches are unavailable until this workspace is trusted.',
|
||||
isError: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(null);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
let result: { success: boolean; message: string };
|
||||
if (choice.action === 'apply') {
|
||||
result = await applyInboxPatch(config, patch.fileName);
|
||||
} else {
|
||||
result = await dismissInboxPatch(config, patch.fileName);
|
||||
}
|
||||
|
||||
setFeedback({ text: result.message, isError: !result.success });
|
||||
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
removeItem(selectedItem);
|
||||
setSelectedItem(null);
|
||||
setPhase('list');
|
||||
|
||||
if (choice.action === 'apply') {
|
||||
try {
|
||||
await onReloadSkills();
|
||||
} catch (error) {
|
||||
setFeedback({
|
||||
text: `${result.message} Failed to reload skills: ${getErrorMessage(error)}`,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const operation =
|
||||
choice.destination === 'dismiss'
|
||||
? 'dismiss skill'
|
||||
: 'install skill';
|
||||
choice.action === 'apply' ? 'apply patch' : 'dismiss patch';
|
||||
setFeedback({
|
||||
text: `Failed to ${operation}: ${getErrorMessage(error)}`,
|
||||
isError: true,
|
||||
@@ -209,15 +518,18 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
}
|
||||
})();
|
||||
},
|
||||
[config, selectedSkill, onReloadSkills],
|
||||
[config, selectedItem, onReloadSkills, removeItem],
|
||||
);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
if (phase === 'action') {
|
||||
if (phase === 'skill-action') {
|
||||
setPhase('skill-preview');
|
||||
setFeedback(null);
|
||||
} else if (phase !== 'list') {
|
||||
setPhase('list');
|
||||
setSelectedSkill(null);
|
||||
setSelectedItem(null);
|
||||
setFeedback(null);
|
||||
} else {
|
||||
onClose();
|
||||
@@ -243,7 +555,7 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (skills.length === 0 && !feedback) {
|
||||
if (items.length === 0 && !feedback) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
@@ -252,17 +564,18 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
<Text bold>Skill Inbox</Text>
|
||||
<Text bold>Memory Inbox</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
No extracted skills in inbox.
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>No items in inbox.</Text>
|
||||
</Box>
|
||||
<DialogFooter primaryAction="Esc to close" cancelAction="" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Border + paddingX account for 6 chars of width
|
||||
const contentWidth = terminalWidth - 6;
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
@@ -272,41 +585,87 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
paddingY={1}
|
||||
width="100%"
|
||||
>
|
||||
{phase === 'list' ? (
|
||||
{phase === 'list' && (
|
||||
<>
|
||||
<Text bold>
|
||||
Skill Inbox ({skills.length} skill{skills.length !== 1 ? 's' : ''})
|
||||
Memory Inbox ({items.length} item{items.length !== 1 ? 's' : ''})
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Skills extracted from past sessions. Select one to move or dismiss.
|
||||
Extracted from past sessions. Select one to review.
|
||||
</Text>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<BaseSelectionList<InboxSkill>
|
||||
items={skillItems}
|
||||
onSelect={handleSelectSkill}
|
||||
<BaseSelectionList<InboxItem>
|
||||
items={listItems}
|
||||
onSelect={handleSelectItem}
|
||||
isFocused={true}
|
||||
showNumbers={true}
|
||||
showNumbers={false}
|
||||
showScrollArrows={true}
|
||||
maxItemsToShow={8}
|
||||
renderItem={(item, { titleColor }) => (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Text color={titleColor} bold>
|
||||
{item.value.name}
|
||||
</Text>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{item.value.description}
|
||||
</Text>
|
||||
{item.value.extractedAt && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' · '}
|
||||
{formatDate(item.value.extractedAt)}
|
||||
renderItem={(item, { titleColor }) => {
|
||||
if (item.value.type === 'header') {
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary} bold>
|
||||
{item.value.label}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (item.value.type === 'skill') {
|
||||
const skill = item.value.skill;
|
||||
return (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Text color={titleColor} bold>
|
||||
{skill.name}
|
||||
</Text>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{skill.description}
|
||||
</Text>
|
||||
{skill.extractedAt && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' · '}
|
||||
{formatDate(skill.extractedAt)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const patch = item.value.patch;
|
||||
const fileNames = patch.entries.map((e) =>
|
||||
getPathBasename(e.targetPath),
|
||||
);
|
||||
const origin = getSkillOriginTag(
|
||||
patch.entries[0]?.targetPath ?? '',
|
||||
);
|
||||
return (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Box flexDirection="row">
|
||||
<Text color={titleColor} bold>
|
||||
{patch.name}
|
||||
</Text>
|
||||
{origin && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{` [${origin}]`}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary}>
|
||||
{fileNames.join(', ')}
|
||||
</Text>
|
||||
{patch.extractedAt && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' · '}
|
||||
{formatDate(patch.extractedAt)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -328,9 +687,73 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
cancelAction="Esc to close"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{phase === 'skill-preview' && selectedItem?.type === 'skill' && (
|
||||
<>
|
||||
<Text bold>Move "{selectedSkill?.name}"</Text>
|
||||
<Text bold>{selectedItem.skill.name}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Review new skill before installing.
|
||||
</Text>
|
||||
|
||||
{selectedItem.skill.content && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.secondary} bold>
|
||||
SKILL.md
|
||||
</Text>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiff(
|
||||
'SKILL.md',
|
||||
selectedItem.skill.content,
|
||||
)}
|
||||
filename="SKILL.md"
|
||||
terminalWidth={contentWidth}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<BaseSelectionList<SkillPreviewAction>
|
||||
items={skillPreviewItems}
|
||||
onSelect={handleSkillPreviewAction}
|
||||
isFocused={true}
|
||||
showNumbers={true}
|
||||
renderItem={(item, { titleColor }) => (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Text color={titleColor} bold>
|
||||
{item.value.label}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{item.value.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{feedback && (
|
||||
<Box marginTop={1}>
|
||||
<Text
|
||||
color={
|
||||
feedback.isError ? theme.status.error : theme.status.success
|
||||
}
|
||||
>
|
||||
{feedback.isError ? '✗ ' : '✓ '}
|
||||
{feedback.text}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<DialogFooter
|
||||
primaryAction="Enter to confirm"
|
||||
cancelAction="Esc to go back"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'skill-action' && selectedItem?.type === 'skill' && (
|
||||
<>
|
||||
<Text bold>Move "{selectedItem.skill.name}"</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Choose where to install this skill.
|
||||
</Text>
|
||||
@@ -373,6 +796,81 @@ export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'patch-preview' && selectedItem?.type === 'patch' && (
|
||||
<>
|
||||
<Text bold>{selectedItem.patch.name}</Text>
|
||||
<Box flexDirection="row">
|
||||
<Text color={theme.text.secondary}>
|
||||
Review changes before applying.
|
||||
</Text>
|
||||
{(() => {
|
||||
const origin = getSkillOriginTag(
|
||||
selectedItem.patch.entries[0]?.targetPath ?? '',
|
||||
);
|
||||
return origin ? (
|
||||
<Text color={theme.text.secondary}>{` [${origin}]`}</Text>
|
||||
) : null;
|
||||
})()}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{selectedItem.patch.entries.map((entry, index) => (
|
||||
<Box
|
||||
key={`${selectedItem.patch.fileName}:${entry.targetPath}:${index}`}
|
||||
flexDirection="column"
|
||||
marginBottom={1}
|
||||
>
|
||||
<Text color={theme.text.secondary} bold>
|
||||
{entry.targetPath}
|
||||
</Text>
|
||||
<DiffRenderer
|
||||
diffContent={entry.diffContent}
|
||||
filename={entry.targetPath}
|
||||
terminalWidth={contentWidth}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<BaseSelectionList<PatchAction>
|
||||
items={patchActionItems}
|
||||
onSelect={handleSelectPatchAction}
|
||||
isFocused={true}
|
||||
showNumbers={true}
|
||||
renderItem={(item, { titleColor }) => (
|
||||
<Box flexDirection="column" minHeight={2}>
|
||||
<Text color={titleColor} bold>
|
||||
{item.value.label}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{item.value.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{feedback && (
|
||||
<Box marginTop={1}>
|
||||
<Text
|
||||
color={
|
||||
feedback.isError ? theme.status.error : theme.status.success
|
||||
}
|
||||
>
|
||||
{feedback.isError ? '✗ ' : '✓ '}
|
||||
{feedback.text}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<DialogFooter
|
||||
primaryAction="Enter to confirm"
|
||||
cancelAction="Esc to go back"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">> </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">> </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">> </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">> </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">> </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">> </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">> </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">> </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">> </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">> </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">> </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">> </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">> </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">> </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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -101,24 +101,6 @@ export function contentPartsToGeminiParts(content: ContentPart[]): Part[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ToolCallResponseInfo.resultDisplay value into ContentPart[].
|
||||
* Handles string, object-valued (FileDiff, SubagentProgress, etc.),
|
||||
* and undefined resultDisplay consistently.
|
||||
*/
|
||||
export function toolResultDisplayToContentParts(
|
||||
resultDisplay: unknown,
|
||||
): ContentPart[] | undefined {
|
||||
if (resultDisplay === undefined || resultDisplay === null) {
|
||||
return undefined;
|
||||
}
|
||||
const text =
|
||||
typeof resultDisplay === 'string'
|
||||
? resultDisplay
|
||||
: JSON.stringify(resultDisplay);
|
||||
return [{ type: 'text', text }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the data record for a tool_response AgentEvent, preserving
|
||||
* all available metadata from the ToolCallResponseInfo.
|
||||
|
||||
@@ -155,9 +155,10 @@ describe('translateEvent', () => {
|
||||
expect(resp.content).toEqual([
|
||||
{ type: 'text', text: 'Permission denied to write' },
|
||||
]);
|
||||
expect(resp.displayContent).toEqual([
|
||||
{ type: 'text', text: 'Permission denied' },
|
||||
]);
|
||||
expect(resp.display?.result).toEqual({
|
||||
type: 'text',
|
||||
text: 'Permission denied',
|
||||
});
|
||||
expect(resp.data).toEqual({ errorType: 'permission_denied' });
|
||||
});
|
||||
|
||||
@@ -200,9 +201,12 @@ describe('translateEvent', () => {
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
const resp = result[0] as AgentEvent<'tool_response'>;
|
||||
expect(resp.displayContent).toEqual([
|
||||
{ type: 'text', text: JSON.stringify(objectDisplay) },
|
||||
]);
|
||||
expect(resp.display?.result).toEqual({
|
||||
type: 'diff',
|
||||
path: '/tmp/test.txt',
|
||||
beforeText: 'a',
|
||||
afterText: 'b',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes through string resultDisplay as-is', () => {
|
||||
@@ -220,9 +224,10 @@ describe('translateEvent', () => {
|
||||
};
|
||||
const result = translateEvent(event, state);
|
||||
const resp = result[0] as AgentEvent<'tool_response'>;
|
||||
expect(resp.displayContent).toEqual([
|
||||
{ type: 'text', text: 'Command output text' },
|
||||
]);
|
||||
expect(resp.display?.result).toEqual({
|
||||
type: 'text',
|
||||
text: 'Command output text',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves outputFile and contentLength in data', () => {
|
||||
|
||||
@@ -25,12 +25,13 @@ import type {
|
||||
ErrorData,
|
||||
Usage,
|
||||
AgentEventType,
|
||||
ToolDisplay,
|
||||
} from './types.js';
|
||||
import {
|
||||
geminiPartsToContentParts,
|
||||
toolResultDisplayToContentParts,
|
||||
buildToolResponseData,
|
||||
} from './content-utils.js';
|
||||
import { toolResultDisplayToDisplayContent } from './tool-display-utils.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Translation State
|
||||
@@ -241,10 +242,14 @@ export function translateEvent(
|
||||
|
||||
case GeminiEventType.ToolCallResponse: {
|
||||
ensureStreamStart(state, out);
|
||||
const displayContent = toolResultDisplayToContentParts(
|
||||
event.value.resultDisplay,
|
||||
);
|
||||
const data = buildToolResponseData(event.value);
|
||||
const display: ToolDisplay | undefined = event.value.resultDisplay
|
||||
? {
|
||||
result: toolResultDisplayToDisplayContent(
|
||||
event.value.resultDisplay,
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
out.push(
|
||||
makeEvent('tool_response', state, {
|
||||
requestId: event.value.callId,
|
||||
@@ -253,7 +258,7 @@ export function translateEvent(
|
||||
? [{ type: 'text', text: event.value.error.message }]
|
||||
: geminiPartsToContentParts(event.value.responseParts),
|
||||
isError: event.value.error !== undefined,
|
||||
...(displayContent ? { displayContent } : {}),
|
||||
...(display ? { display } : {}),
|
||||
...(data ? { data } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -489,9 +489,10 @@ describe('LegacyAgentSession', () => {
|
||||
expect(toolResp?.content).toEqual([
|
||||
{ type: 'text', text: 'Permission denied' },
|
||||
]);
|
||||
expect(toolResp?.displayContent).toEqual([
|
||||
{ type: 'text', text: 'Error display' },
|
||||
]);
|
||||
expect(toolResp?.display?.result).toEqual({
|
||||
type: 'text',
|
||||
text: 'Error display',
|
||||
});
|
||||
});
|
||||
|
||||
it('stops on STOP_EXECUTION tool error', async () => {
|
||||
|
||||
@@ -23,8 +23,8 @@ import {
|
||||
buildToolResponseData,
|
||||
contentPartsToGeminiParts,
|
||||
geminiPartsToContentParts,
|
||||
toolResultDisplayToContentParts,
|
||||
} from './content-utils.js';
|
||||
import { populateToolDisplay } from './tool-display-utils.js';
|
||||
import { AgentSession } from './agent-session.js';
|
||||
import {
|
||||
createTranslationState,
|
||||
@@ -262,9 +262,12 @@ export class LegacyAgentProtocol implements AgentProtocol {
|
||||
const content: ContentPart[] = response.error
|
||||
? [{ type: 'text', text: response.error.message }]
|
||||
: geminiPartsToContentParts(response.responseParts);
|
||||
const displayContent = toolResultDisplayToContentParts(
|
||||
response.resultDisplay,
|
||||
);
|
||||
const display = populateToolDisplay({
|
||||
name: request.name,
|
||||
invocation: 'invocation' in tc ? tc.invocation : undefined,
|
||||
resultDisplay: response.resultDisplay,
|
||||
displayName: 'tool' in tc ? tc.tool?.displayName : undefined,
|
||||
});
|
||||
const data = buildToolResponseData(response);
|
||||
|
||||
this._emit([
|
||||
@@ -273,7 +276,7 @@ export class LegacyAgentProtocol implements AgentProtocol {
|
||||
name: request.name,
|
||||
content,
|
||||
isError: response.error !== undefined,
|
||||
...(displayContent ? { displayContent } : {}),
|
||||
...(display ? { display } : {}),
|
||||
...(data ? { data } : {}),
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
ToolInvocation,
|
||||
ToolResult,
|
||||
ToolResultDisplay,
|
||||
} from '../tools/tools.js';
|
||||
import type { DisplayContent } from './types.js';
|
||||
import {
|
||||
populateToolDisplay,
|
||||
renderDisplayDiff,
|
||||
displayContentToString,
|
||||
} from './tool-display-utils.js';
|
||||
|
||||
describe('tool-display-utils', () => {
|
||||
describe('populateToolDisplay', () => {
|
||||
it('uses displayName if provided', () => {
|
||||
const mockInvocation = {
|
||||
getDescription: () => 'Doing something...',
|
||||
} as unknown as ToolInvocation<object, ToolResult>;
|
||||
|
||||
const display = populateToolDisplay({
|
||||
name: 'raw-name',
|
||||
invocation: mockInvocation,
|
||||
displayName: 'Custom Display Name',
|
||||
});
|
||||
expect(display.name).toBe('Custom Display Name');
|
||||
expect(display.description).toBe('Doing something...');
|
||||
});
|
||||
|
||||
it('falls back to raw name if no displayName provided', () => {
|
||||
const mockInvocation = {
|
||||
getDescription: () => 'Doing something...',
|
||||
} as unknown as ToolInvocation<object, ToolResult>;
|
||||
|
||||
const display = populateToolDisplay({
|
||||
name: 'raw-name',
|
||||
invocation: mockInvocation,
|
||||
});
|
||||
expect(display.name).toBe('raw-name');
|
||||
});
|
||||
|
||||
it('populates result from resultDisplay', () => {
|
||||
const display = populateToolDisplay({
|
||||
name: 'test',
|
||||
resultDisplay: 'hello world',
|
||||
});
|
||||
expect(display.result).toEqual({ type: 'text', text: 'hello world' });
|
||||
});
|
||||
|
||||
it('translates FileDiff to DisplayDiff', () => {
|
||||
const fileDiff = {
|
||||
fileDiff: '@@ ...',
|
||||
fileName: 'test.ts',
|
||||
filePath: 'src/test.ts',
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
} as unknown as ToolResultDisplay;
|
||||
const display = populateToolDisplay({
|
||||
name: 'test',
|
||||
resultDisplay: fileDiff,
|
||||
});
|
||||
expect(display.result).toEqual({
|
||||
type: 'diff',
|
||||
path: 'src/test.ts',
|
||||
beforeText: 'old',
|
||||
afterText: 'new',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderDisplayDiff', () => {
|
||||
it('renders a universal diff', () => {
|
||||
const diff = {
|
||||
type: 'diff' as const,
|
||||
path: 'test.ts',
|
||||
beforeText: 'line 1\nline 2',
|
||||
afterText: 'line 1\nline 2 modified',
|
||||
};
|
||||
const rendered = renderDisplayDiff(diff);
|
||||
expect(rendered).toContain('--- test.ts\tOriginal');
|
||||
expect(rendered).toContain('+++ test.ts\tModified');
|
||||
expect(rendered).toContain('-line 2');
|
||||
expect(rendered).toContain('+line 2 modified');
|
||||
});
|
||||
});
|
||||
|
||||
describe('displayContentToString', () => {
|
||||
it('returns undefined for undefined input', () => {
|
||||
expect(displayContentToString(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns text for text input', () => {
|
||||
expect(displayContentToString({ type: 'text', text: 'hello' })).toBe(
|
||||
'hello',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a diff for diff input', () => {
|
||||
const diff = {
|
||||
type: 'diff' as const,
|
||||
path: 'test.ts',
|
||||
beforeText: 'old',
|
||||
afterText: 'new',
|
||||
};
|
||||
const rendered = displayContentToString(diff);
|
||||
expect(rendered).toContain('--- test.ts\tOriginal');
|
||||
expect(rendered).toContain('+++ test.ts\tModified');
|
||||
});
|
||||
|
||||
it('stringifies unknown structured objects', () => {
|
||||
const unknown = {
|
||||
type: 'something_else',
|
||||
data: 123,
|
||||
} as unknown as DisplayContent;
|
||||
expect(displayContentToString(unknown)).toBe(JSON.stringify(unknown));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as Diff from 'diff';
|
||||
import type {
|
||||
ToolInvocation,
|
||||
ToolResult,
|
||||
ToolResultDisplay,
|
||||
} from '../tools/tools.js';
|
||||
import type { ToolDisplay, DisplayContent, DisplayDiff } from './types.js';
|
||||
|
||||
/**
|
||||
* Populates a ToolDisplay object from a tool invocation and its result.
|
||||
* This serves as a centralized bridge during the migration to tool-controlled display.
|
||||
*/
|
||||
export function populateToolDisplay({
|
||||
name,
|
||||
invocation,
|
||||
resultDisplay,
|
||||
displayName,
|
||||
}: {
|
||||
name: string;
|
||||
invocation?: ToolInvocation<object, ToolResult>;
|
||||
resultDisplay?: ToolResultDisplay;
|
||||
displayName?: string;
|
||||
}): ToolDisplay {
|
||||
const display: ToolDisplay = {
|
||||
name: displayName || name,
|
||||
description: invocation?.getDescription?.(),
|
||||
};
|
||||
|
||||
if (resultDisplay) {
|
||||
display.result = toolResultDisplayToDisplayContent(resultDisplay);
|
||||
}
|
||||
|
||||
return display;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a legacy ToolResultDisplay into the new DisplayContent format.
|
||||
*/
|
||||
export function toolResultDisplayToDisplayContent(
|
||||
resultDisplay: ToolResultDisplay,
|
||||
): DisplayContent {
|
||||
if (typeof resultDisplay === 'string') {
|
||||
return { type: 'text', text: resultDisplay };
|
||||
}
|
||||
|
||||
// Handle FileDiff -> DisplayDiff
|
||||
if (
|
||||
typeof resultDisplay === 'object' &&
|
||||
resultDisplay !== null &&
|
||||
'fileDiff' in resultDisplay &&
|
||||
'newContent' in resultDisplay
|
||||
) {
|
||||
return {
|
||||
type: 'diff',
|
||||
path: resultDisplay.filePath || resultDisplay.fileName,
|
||||
beforeText: resultDisplay.originalContent ?? '',
|
||||
afterText: resultDisplay.newContent,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for other structured types (LsTool, GrepTool, etc.)
|
||||
// These will be fully migrated in Step 5.
|
||||
return {
|
||||
type: 'text',
|
||||
text: JSON.stringify(resultDisplay),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a universal diff string from a DisplayDiff object.
|
||||
*/
|
||||
export function renderDisplayDiff(diff: DisplayDiff): string {
|
||||
return Diff.createPatch(
|
||||
diff.path || 'file',
|
||||
diff.beforeText,
|
||||
diff.afterText,
|
||||
'Original',
|
||||
'Modified',
|
||||
{ context: 3 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a DisplayContent object into a string representation.
|
||||
* Useful for fallback displays or non-interactive environments.
|
||||
*/
|
||||
export function displayContentToString(
|
||||
display: DisplayContent | undefined,
|
||||
): string | undefined {
|
||||
if (!display) {
|
||||
return undefined;
|
||||
}
|
||||
if (display.type === 'text') {
|
||||
return display.text;
|
||||
}
|
||||
if (display.type === 'diff') {
|
||||
return renderDisplayDiff(display);
|
||||
}
|
||||
return JSON.stringify(display);
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export interface AgentEvents {
|
||||
/** Updates configuration about the current session/agent. */
|
||||
session_update: SessionUpdate;
|
||||
/** Message content provided by user, agent, or developer. */
|
||||
message: Message;
|
||||
message: AgentMessage;
|
||||
/** Event indicating the start of agent activity on a stream. */
|
||||
agent_start: AgentStart;
|
||||
/** Event indicating the end of agent activity on a stream. */
|
||||
@@ -170,17 +170,35 @@ export type ContentPart =
|
||||
) &
|
||||
WithMeta;
|
||||
|
||||
export interface Message {
|
||||
export interface AgentMessage {
|
||||
role: 'user' | 'agent' | 'developer';
|
||||
content: ContentPart[];
|
||||
}
|
||||
|
||||
export type DisplayText = { type: 'text'; text: string };
|
||||
export type DisplayDiff = {
|
||||
type: 'diff';
|
||||
path?: string;
|
||||
beforeText: string;
|
||||
afterText: string;
|
||||
};
|
||||
export type DisplayContent = DisplayText | DisplayDiff;
|
||||
|
||||
export interface ToolDisplay {
|
||||
name?: string;
|
||||
description?: string;
|
||||
resultSummary?: string;
|
||||
result?: DisplayContent;
|
||||
}
|
||||
|
||||
export interface ToolRequest {
|
||||
/** A unique identifier for this tool request to be correlated by the response. */
|
||||
requestId: string;
|
||||
/** The name of the tool being requested. */
|
||||
name: string;
|
||||
/** The arguments for the tool. */
|
||||
/** Tool-controlled display information. */
|
||||
display?: ToolDisplay;
|
||||
args: Record<string, unknown>;
|
||||
/** UI specific metadata */
|
||||
_meta?: {
|
||||
@@ -201,7 +219,8 @@ export interface ToolRequest {
|
||||
*/
|
||||
export interface ToolUpdate {
|
||||
requestId: string;
|
||||
displayContent?: ContentPart[];
|
||||
/** Tool-controlled display information. */
|
||||
display?: ToolDisplay;
|
||||
content?: ContentPart[];
|
||||
data?: Record<string, unknown>;
|
||||
/** UI specific metadata */
|
||||
@@ -221,8 +240,8 @@ export interface ToolUpdate {
|
||||
export interface ToolResponse {
|
||||
requestId: string;
|
||||
name: string;
|
||||
/** Content representing the tool call's outcome to be presented to the user. */
|
||||
displayContent?: ContentPart[];
|
||||
/** Tool-controlled display information. */
|
||||
display?: ToolDisplay;
|
||||
/** Multi-part content to be sent to the model. */
|
||||
content?: ContentPart[];
|
||||
/** Structured data to be sent to the model. */
|
||||
|
||||
@@ -493,6 +493,42 @@ Body`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert mcp_servers with auth block in local agent (google-credentials)', () => {
|
||||
const markdown = {
|
||||
kind: 'local' as const,
|
||||
name: 'spanner-test-agent',
|
||||
description: 'An agent to test Spanner MCP with auth',
|
||||
mcp_servers: {
|
||||
spanner: {
|
||||
url: 'https://spanner.googleapis.com/mcp',
|
||||
type: 'http' as const,
|
||||
auth: {
|
||||
type: 'google-credentials' as const,
|
||||
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
|
||||
},
|
||||
timeout: 30000,
|
||||
},
|
||||
},
|
||||
system_prompt: 'You are a Spanner test agent.',
|
||||
};
|
||||
|
||||
const result = markdownToAgentDefinition(
|
||||
markdown,
|
||||
) as LocalAgentDefinition;
|
||||
expect(result.kind).toBe('local');
|
||||
expect(result.mcpServers).toBeDefined();
|
||||
expect(result.mcpServers!['spanner']).toMatchObject({
|
||||
url: 'https://spanner.googleapis.com/mcp',
|
||||
type: 'http',
|
||||
authProviderType: 'google_credentials',
|
||||
oauth: {
|
||||
enabled: true,
|
||||
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through unknown model names (e.g. auto)', () => {
|
||||
const markdown = {
|
||||
kind: 'local' as const,
|
||||
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
DEFAULT_MAX_TIME_MINUTES,
|
||||
} from './types.js';
|
||||
import type { A2AAuthConfig } from './auth-provider/types.js';
|
||||
import { MCPServerConfig } from '../config/config.js';
|
||||
import {
|
||||
MCPServerConfig,
|
||||
AuthProviderType,
|
||||
type MCPOAuthConfig,
|
||||
} from '../config/config.js';
|
||||
import { isValidToolName } from '../tools/tool-names.js';
|
||||
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
@@ -62,6 +66,22 @@ const mcpServerSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
include_tools: z.array(z.string()).optional(),
|
||||
exclude_tools: z.array(z.string()).optional(),
|
||||
auth: z
|
||||
.union([
|
||||
z.object({
|
||||
type: z.literal('google-credentials'),
|
||||
scopes: z.array(z.string()).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('oauth'),
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
scopes: z.array(z.string()).optional(),
|
||||
authorization_url: z.string().url().optional(),
|
||||
token_url: z.string().url().optional(),
|
||||
}),
|
||||
])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const localAgentSchema = z
|
||||
@@ -74,9 +94,12 @@ const localAgentSchema = z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.refine((val) => isValidToolName(val, { allowWildcards: true }), {
|
||||
message: 'Invalid tool name',
|
||||
}),
|
||||
.refine(
|
||||
(val: string) => isValidToolName(val, { allowWildcards: true }),
|
||||
{
|
||||
message: 'Invalid tool name',
|
||||
},
|
||||
),
|
||||
)
|
||||
.optional(),
|
||||
mcp_servers: z.record(mcpServerSchema).optional(),
|
||||
@@ -191,7 +214,7 @@ const remoteAgentJsonSchema = baseRemoteAgentSchema
|
||||
.extend({
|
||||
agent_card_url: z.undefined().optional(),
|
||||
agent_card_json: z.string().refine(
|
||||
(val) => {
|
||||
(val: string) => {
|
||||
try {
|
||||
JSON.parse(val);
|
||||
return true;
|
||||
@@ -511,6 +534,28 @@ export function markdownToAgentDefinition(
|
||||
const mcpServers: Record<string, MCPServerConfig> = {};
|
||||
if (markdown.mcp_servers) {
|
||||
for (const [name, config] of Object.entries(markdown.mcp_servers)) {
|
||||
let authProviderType: AuthProviderType | undefined = undefined;
|
||||
let oauth: MCPOAuthConfig | undefined = undefined;
|
||||
|
||||
if (config.auth) {
|
||||
if (config.auth.type === 'google-credentials') {
|
||||
authProviderType = AuthProviderType.GOOGLE_CREDENTIALS;
|
||||
oauth = {
|
||||
enabled: true,
|
||||
scopes: config.auth.scopes,
|
||||
};
|
||||
} else if (config.auth.type === 'oauth') {
|
||||
oauth = {
|
||||
enabled: true,
|
||||
clientId: config.auth.client_id,
|
||||
clientSecret: config.auth.client_secret,
|
||||
scopes: config.auth.scopes,
|
||||
authorizationUrl: config.auth.authorization_url,
|
||||
tokenUrl: config.auth.token_url,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
mcpServers[name] = new MCPServerConfig(
|
||||
config.command,
|
||||
config.args,
|
||||
@@ -526,6 +571,9 @@ export function markdownToAgentDefinition(
|
||||
config.description,
|
||||
config.include_tools,
|
||||
config.exclude_tools,
|
||||
undefined, // extension
|
||||
oauth,
|
||||
authProviderType,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,43 @@ function buildSystemPrompt(skillsDir: string): string {
|
||||
'Naming: kebab-case (e.g., fix-lint-errors, run-migrations).',
|
||||
'',
|
||||
'============================================================',
|
||||
'UPDATING EXISTING SKILLS (PATCHES)',
|
||||
'============================================================',
|
||||
'',
|
||||
'You can ONLY write files inside your skills directory. However, existing skills',
|
||||
'may live outside it (global or workspace locations).',
|
||||
'',
|
||||
'NEVER patch builtin or extension skills. They are managed externally and',
|
||||
'overwritten on updates. Patches targeting these paths will be rejected.',
|
||||
'',
|
||||
'To propose an update to an existing skill that lives OUTSIDE your directory:',
|
||||
'',
|
||||
'1. Read the original file(s) using read_file (paths are listed in "Existing Skills").',
|
||||
'2. Write a unified diff patch file to:',
|
||||
` ${skillsDir}/<skill-name>.patch`,
|
||||
'',
|
||||
'Patch format (strict unified diff):',
|
||||
'',
|
||||
' --- /absolute/path/to/original/SKILL.md',
|
||||
' +++ /absolute/path/to/original/SKILL.md',
|
||||
' @@ -<start>,<count> +<start>,<count> @@',
|
||||
' <context line>',
|
||||
' -<removed line>',
|
||||
' +<added line>',
|
||||
' <context line>',
|
||||
'',
|
||||
'Rules for patches:',
|
||||
'- Use the EXACT absolute file path in BOTH --- and +++ headers (NO a/ or b/ prefixes).',
|
||||
'- Include 3 lines of context around each change (standard unified diff).',
|
||||
'- A single .patch file can contain hunks for multiple files in the same skill.',
|
||||
'- For new files, use `/dev/null` as the --- source.',
|
||||
'- Line counts in @@ headers MUST be accurate.',
|
||||
'- Do NOT create a patch if you can create or update a skill in your own directory instead.',
|
||||
'- Patches will be validated by parsing and dry-run applying them. Invalid patches are discarded.',
|
||||
'',
|
||||
'The same quality bar applies: only propose updates backed by evidence from sessions.',
|
||||
'',
|
||||
'============================================================',
|
||||
'QUALITY RULES (STRICT)',
|
||||
'============================================================',
|
||||
'',
|
||||
@@ -192,7 +229,8 @@ function buildSystemPrompt(skillsDir: string): string {
|
||||
'5. For promising patterns, use read_file on the session file paths to inspect the full',
|
||||
' conversation. Confirm the workflow was actually repeated and validated.',
|
||||
'6. For each confirmed skill, verify it meets ALL criteria (repeatable, procedural, high-leverage).',
|
||||
'7. Write new SKILL.md files or update existing ones using write_file.',
|
||||
'7. Write new SKILL.md files or update existing ones in your directory using write_file.',
|
||||
' For skills that live OUTSIDE your directory, write a .patch file instead (see UPDATING EXISTING SKILLS).',
|
||||
'8. Write COMPLETE files — never partially update a SKILL.md.',
|
||||
'',
|
||||
'IMPORTANT: Do NOT read every session. Only read sessions whose summaries suggest a',
|
||||
|
||||
@@ -41,7 +41,7 @@ const DEFAULT_ACTIONS: ModelPolicyActionMap = {
|
||||
unknown: 'prompt',
|
||||
};
|
||||
|
||||
const SILENT_ACTIONS: ModelPolicyActionMap = {
|
||||
export const SILENT_ACTIONS: ModelPolicyActionMap = {
|
||||
terminal: 'silent',
|
||||
transient: 'silent',
|
||||
not_found: 'silent',
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
buildFallbackPolicyContext,
|
||||
applyModelSelection,
|
||||
} from './policyHelpers.js';
|
||||
import { createDefaultPolicy } from './policyCatalog.js';
|
||||
import { createDefaultPolicy, SILENT_ACTIONS } from './policyCatalog.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { AuthType } from '../core/contentGenerator.js';
|
||||
import { ModelConfigService } from '../services/modelConfigService.js';
|
||||
import { DEFAULT_MODEL_CONFIGS } from '../config/defaultModelConfigs.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
|
||||
const createMockConfig = (overrides: Partial<Config> = {}): Config => {
|
||||
const config = {
|
||||
@@ -164,6 +165,18 @@ describe('policyHelpers', () => {
|
||||
expect(chain[0]?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL);
|
||||
expect(chain[1]?.model).toBe('gemini-3-flash-preview');
|
||||
});
|
||||
|
||||
it('applies SILENT_ACTIONS when ApprovalMode is PLAN', () => {
|
||||
const config = createMockConfig({
|
||||
getApprovalMode: () => ApprovalMode.PLAN,
|
||||
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
|
||||
});
|
||||
const chain = resolvePolicyChain(config);
|
||||
|
||||
expect(chain).toHaveLength(2);
|
||||
expect(chain[0]?.actions).toEqual(SILENT_ACTIONS);
|
||||
expect(chain[1]?.actions).toEqual(SILENT_ACTIONS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePolicyChain behavior is identical between dynamic and legacy implementations', () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
createSingleModelChain,
|
||||
getModelPolicyChain,
|
||||
getFlashLitePolicyChain,
|
||||
SILENT_ACTIONS,
|
||||
} from './policyCatalog.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
} from '../config/models.js';
|
||||
import type { ModelSelectionResult } from './modelAvailabilityService.js';
|
||||
import type { ModelConfigKey } from '../services/modelConfigService.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
|
||||
/**
|
||||
* Resolves the active policy chain for the given config, ensuring the
|
||||
@@ -43,7 +45,7 @@ export function resolvePolicyChain(
|
||||
preferredModel ?? config.getActiveModel?.() ?? config.getModel();
|
||||
const configuredModel = config.getModel();
|
||||
|
||||
let chain;
|
||||
let chain: ModelPolicyChain | undefined;
|
||||
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
|
||||
const useGemini31FlashLite =
|
||||
config.getGemini31FlashLiteLaunchedSync?.() ?? false;
|
||||
@@ -103,45 +105,55 @@ export function resolvePolicyChain(
|
||||
// No matching modelChains found, default to single model chain
|
||||
chain = createSingleModelChain(modelFromConfig);
|
||||
}
|
||||
return applyDynamicSlicing(chain, resolvedModel, wrapsAround);
|
||||
}
|
||||
|
||||
// --- LEGACY PATH ---
|
||||
|
||||
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
|
||||
chain = getFlashLitePolicyChain();
|
||||
} else if (
|
||||
isGemini3Model(resolvedModel, config) ||
|
||||
isAutoPreferred ||
|
||||
isAutoConfigured
|
||||
) {
|
||||
if (hasAccessToPreview) {
|
||||
const previewEnabled =
|
||||
isGemini3Model(resolvedModel, config) ||
|
||||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
|
||||
chain = getModelPolicyChain({
|
||||
previewEnabled,
|
||||
userTier: config.getUserTier(),
|
||||
useGemini31,
|
||||
useGemini31FlashLite,
|
||||
useCustomToolModel,
|
||||
});
|
||||
} else {
|
||||
// User requested Gemini 3 but has no access. Proactively downgrade
|
||||
// to the stable Gemini 2.5 chain.
|
||||
chain = getModelPolicyChain({
|
||||
previewEnabled: false,
|
||||
userTier: config.getUserTier(),
|
||||
useGemini31,
|
||||
useGemini31FlashLite,
|
||||
useCustomToolModel,
|
||||
});
|
||||
}
|
||||
chain = applyDynamicSlicing(chain, resolvedModel, wrapsAround);
|
||||
} else {
|
||||
chain = createSingleModelChain(modelFromConfig);
|
||||
// --- LEGACY PATH ---
|
||||
|
||||
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
|
||||
chain = getFlashLitePolicyChain();
|
||||
} else if (
|
||||
isGemini3Model(resolvedModel, config) ||
|
||||
isAutoPreferred ||
|
||||
isAutoConfigured
|
||||
) {
|
||||
if (hasAccessToPreview) {
|
||||
const previewEnabled =
|
||||
isGemini3Model(resolvedModel, config) ||
|
||||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
|
||||
chain = getModelPolicyChain({
|
||||
previewEnabled,
|
||||
userTier: config.getUserTier(),
|
||||
useGemini31,
|
||||
useGemini31FlashLite,
|
||||
useCustomToolModel,
|
||||
});
|
||||
} else {
|
||||
// User requested Gemini 3 but has no access. Proactively downgrade
|
||||
// to the stable Gemini 2.5 chain.
|
||||
chain = getModelPolicyChain({
|
||||
previewEnabled: false,
|
||||
userTier: config.getUserTier(),
|
||||
useGemini31,
|
||||
useGemini31FlashLite,
|
||||
useCustomToolModel,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
chain = createSingleModelChain(modelFromConfig);
|
||||
}
|
||||
chain = applyDynamicSlicing(chain, resolvedModel, wrapsAround);
|
||||
}
|
||||
return applyDynamicSlicing(chain, resolvedModel, wrapsAround);
|
||||
|
||||
// Apply Unified Silent Injection for Plan Mode with defensive checks
|
||||
if (config?.getApprovalMode?.() === ApprovalMode.PLAN) {
|
||||
return chain.map((policy) => ({
|
||||
...policy,
|
||||
actions: { ...SILENT_ACTIONS },
|
||||
}));
|
||||
}
|
||||
|
||||
return chain;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
addMemory,
|
||||
dismissInboxSkill,
|
||||
listInboxSkills,
|
||||
listInboxPatches,
|
||||
applyInboxPatch,
|
||||
dismissInboxPatch,
|
||||
listMemoryFiles,
|
||||
moveInboxSkill,
|
||||
refreshMemory,
|
||||
@@ -528,4 +531,709 @@ describe('memory commands', () => {
|
||||
expect(result.message).toBe('Invalid skill name.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listInboxPatches', () => {
|
||||
let tmpDir: string;
|
||||
let skillsDir: string;
|
||||
let memoryTempDir: string;
|
||||
let patchConfig: Config;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'patch-list-test-'));
|
||||
skillsDir = path.join(tmpDir, 'skills-memory');
|
||||
memoryTempDir = path.join(tmpDir, 'memory-temp');
|
||||
await fs.mkdir(skillsDir, { recursive: true });
|
||||
await fs.mkdir(memoryTempDir, { recursive: true });
|
||||
|
||||
patchConfig = {
|
||||
storage: {
|
||||
getProjectSkillsMemoryDir: () => skillsDir,
|
||||
getProjectMemoryTempDir: () => memoryTempDir,
|
||||
},
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should return empty array when no patches exist', async () => {
|
||||
const result = await listInboxPatches(patchConfig);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when directory does not exist', async () => {
|
||||
const badConfig = {
|
||||
storage: {
|
||||
getProjectSkillsMemoryDir: () => path.join(tmpDir, 'nonexistent-dir'),
|
||||
getProjectMemoryTempDir: () => memoryTempDir,
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
const result = await listInboxPatches(badConfig);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return parsed patch entries', async () => {
|
||||
const targetFile = path.join(tmpDir, 'target.md');
|
||||
const patchContent = [
|
||||
`--- ${targetFile}`,
|
||||
`+++ ${targetFile}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' line1',
|
||||
' line2',
|
||||
'+line2.5',
|
||||
' line3',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(skillsDir, 'update-skill.patch'),
|
||||
patchContent,
|
||||
);
|
||||
|
||||
const result = await listInboxPatches(patchConfig);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].fileName).toBe('update-skill.patch');
|
||||
expect(result[0].name).toBe('update-skill');
|
||||
expect(result[0].entries).toHaveLength(1);
|
||||
expect(result[0].entries[0].targetPath).toBe(targetFile);
|
||||
expect(result[0].entries[0].diffContent).toContain('+line2.5');
|
||||
});
|
||||
|
||||
it('should use each patch file mtime for extractedAt', async () => {
|
||||
const firstTarget = path.join(tmpDir, 'first.md');
|
||||
const secondTarget = path.join(tmpDir, 'second.md');
|
||||
const firstTimestamp = new Date('2025-01-15T10:00:00.000Z');
|
||||
const secondTimestamp = new Date('2025-01-16T12:00:00.000Z');
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(memoryTempDir, '.extraction-state.json'),
|
||||
JSON.stringify({
|
||||
runs: [
|
||||
{
|
||||
runAt: '2025-02-01T00:00:00Z',
|
||||
sessionIds: ['later-run'],
|
||||
skillsCreated: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(skillsDir, 'first.patch'),
|
||||
[
|
||||
`--- ${firstTarget}`,
|
||||
`+++ ${firstTarget}`,
|
||||
'@@ -1,1 +1,1 @@',
|
||||
'-before',
|
||||
'+after',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(skillsDir, 'second.patch'),
|
||||
[
|
||||
`--- ${secondTarget}`,
|
||||
`+++ ${secondTarget}`,
|
||||
'@@ -1,1 +1,1 @@',
|
||||
'-before',
|
||||
'+after',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
await fs.utimes(
|
||||
path.join(skillsDir, 'first.patch'),
|
||||
firstTimestamp,
|
||||
firstTimestamp,
|
||||
);
|
||||
await fs.utimes(
|
||||
path.join(skillsDir, 'second.patch'),
|
||||
secondTimestamp,
|
||||
secondTimestamp,
|
||||
);
|
||||
|
||||
const result = await listInboxPatches(patchConfig);
|
||||
const firstPatch = result.find(
|
||||
(patch) => patch.fileName === 'first.patch',
|
||||
);
|
||||
const secondPatch = result.find(
|
||||
(patch) => patch.fileName === 'second.patch',
|
||||
);
|
||||
|
||||
expect(firstPatch?.extractedAt).toBe(firstTimestamp.toISOString());
|
||||
expect(secondPatch?.extractedAt).toBe(secondTimestamp.toISOString());
|
||||
});
|
||||
|
||||
it('should skip patches with no hunks', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(skillsDir, 'empty.patch'),
|
||||
'not a valid patch',
|
||||
);
|
||||
|
||||
const result = await listInboxPatches(patchConfig);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyInboxPatch', () => {
|
||||
let tmpDir: string;
|
||||
let skillsDir: string;
|
||||
let memoryTempDir: string;
|
||||
let globalSkillsDir: string;
|
||||
let projectSkillsDir: string;
|
||||
let applyConfig: Config;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'patch-apply-test-'));
|
||||
skillsDir = path.join(tmpDir, 'skills-memory');
|
||||
memoryTempDir = path.join(tmpDir, 'memory-temp');
|
||||
globalSkillsDir = path.join(tmpDir, 'global-skills');
|
||||
projectSkillsDir = path.join(tmpDir, 'project-skills');
|
||||
await fs.mkdir(skillsDir, { recursive: true });
|
||||
await fs.mkdir(memoryTempDir, { recursive: true });
|
||||
await fs.mkdir(globalSkillsDir, { recursive: true });
|
||||
await fs.mkdir(projectSkillsDir, { recursive: true });
|
||||
|
||||
applyConfig = {
|
||||
storage: {
|
||||
getProjectSkillsMemoryDir: () => skillsDir,
|
||||
getProjectMemoryTempDir: () => memoryTempDir,
|
||||
getProjectSkillsDir: () => projectSkillsDir,
|
||||
},
|
||||
isTrustedFolder: () => true,
|
||||
} as unknown as Config;
|
||||
|
||||
vi.mocked(Storage.getUserSkillsDir).mockReturnValue(globalSkillsDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should apply a valid patch and delete it', async () => {
|
||||
const targetFile = path.join(projectSkillsDir, 'target.md');
|
||||
await fs.writeFile(targetFile, 'line1\nline2\nline3\n');
|
||||
|
||||
const patchContent = [
|
||||
`--- ${targetFile}`,
|
||||
`+++ ${targetFile}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' line1',
|
||||
' line2',
|
||||
'+line2.5',
|
||||
' line3',
|
||||
'',
|
||||
].join('\n');
|
||||
const patchPath = path.join(skillsDir, 'good.patch');
|
||||
await fs.writeFile(patchPath, patchContent);
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, 'good.patch');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Applied patch to 1 file');
|
||||
|
||||
// Verify target was modified
|
||||
const modified = await fs.readFile(targetFile, 'utf-8');
|
||||
expect(modified).toContain('line2.5');
|
||||
|
||||
// Verify patch was deleted
|
||||
await expect(fs.access(patchPath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should apply a multi-file patch', async () => {
|
||||
const file1 = path.join(globalSkillsDir, 'file1.md');
|
||||
const file2 = path.join(projectSkillsDir, 'file2.md');
|
||||
await fs.writeFile(file1, 'aaa\nbbb\nccc\n');
|
||||
await fs.writeFile(file2, 'xxx\nyyy\nzzz\n');
|
||||
|
||||
const patchContent = [
|
||||
`--- ${file1}`,
|
||||
`+++ ${file1}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' aaa',
|
||||
' bbb',
|
||||
'+bbb2',
|
||||
' ccc',
|
||||
`--- ${file2}`,
|
||||
`+++ ${file2}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' xxx',
|
||||
' yyy',
|
||||
'+yyy2',
|
||||
' zzz',
|
||||
'',
|
||||
].join('\n');
|
||||
await fs.writeFile(path.join(skillsDir, 'multi.patch'), patchContent);
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, 'multi.patch');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('2 files');
|
||||
|
||||
expect(await fs.readFile(file1, 'utf-8')).toContain('bbb2');
|
||||
expect(await fs.readFile(file2, 'utf-8')).toContain('yyy2');
|
||||
});
|
||||
|
||||
it('should apply repeated file blocks against the cumulative patched content', async () => {
|
||||
const targetFile = path.join(projectSkillsDir, 'target.md');
|
||||
await fs.writeFile(targetFile, 'alpha\nbeta\ngamma\ndelta\n');
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(skillsDir, 'multi-section.patch'),
|
||||
[
|
||||
`--- ${targetFile}`,
|
||||
`+++ ${targetFile}`,
|
||||
'@@ -1,4 +1,5 @@',
|
||||
' alpha',
|
||||
' beta',
|
||||
'+beta2',
|
||||
' gamma',
|
||||
' delta',
|
||||
`--- ${targetFile}`,
|
||||
`+++ ${targetFile}`,
|
||||
'@@ -2,4 +2,5 @@',
|
||||
' beta',
|
||||
' beta2',
|
||||
' gamma',
|
||||
'+gamma2',
|
||||
' delta',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, 'multi-section.patch');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Applied patch to 1 file');
|
||||
expect(await fs.readFile(targetFile, 'utf-8')).toBe(
|
||||
'alpha\nbeta\nbeta2\ngamma\ngamma2\ndelta\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject /dev/null patches that target an existing skill file', async () => {
|
||||
const targetFile = path.join(projectSkillsDir, 'existing-skill.md');
|
||||
await fs.writeFile(targetFile, 'original content\n');
|
||||
|
||||
const patchPath = path.join(skillsDir, 'bad-new-file.patch');
|
||||
await fs.writeFile(
|
||||
patchPath,
|
||||
[
|
||||
'--- /dev/null',
|
||||
`+++ ${targetFile}`,
|
||||
'@@ -0,0 +1 @@',
|
||||
'+replacement content',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, 'bad-new-file.patch');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('target already exists');
|
||||
expect(await fs.readFile(targetFile, 'utf-8')).toBe('original content\n');
|
||||
await expect(fs.access(patchPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should fail when patch does not exist', async () => {
|
||||
const result = await applyInboxPatch(applyConfig, 'missing.patch');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('not found');
|
||||
});
|
||||
|
||||
it('should reject invalid patch file names', async () => {
|
||||
const outsidePatch = path.join(tmpDir, 'outside.patch');
|
||||
await fs.writeFile(outsidePatch, 'outside patch content');
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, '../outside.patch');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('Invalid patch file name.');
|
||||
await expect(fs.access(outsidePatch)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should fail when target file does not exist', async () => {
|
||||
const missingFile = path.join(projectSkillsDir, 'missing-target.md');
|
||||
const patchContent = [
|
||||
`--- ${missingFile}`,
|
||||
`+++ ${missingFile}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' a',
|
||||
' b',
|
||||
'+c',
|
||||
' d',
|
||||
'',
|
||||
].join('\n');
|
||||
await fs.writeFile(
|
||||
path.join(skillsDir, 'bad-target.patch'),
|
||||
patchContent,
|
||||
);
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, 'bad-target.patch');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Target file not found');
|
||||
});
|
||||
|
||||
it('should reject targets outside the global and workspace skill roots', async () => {
|
||||
const outsideFile = path.join(tmpDir, 'outside.md');
|
||||
await fs.writeFile(outsideFile, 'line1\nline2\nline3\n');
|
||||
|
||||
const patchContent = [
|
||||
`--- ${outsideFile}`,
|
||||
`+++ ${outsideFile}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' line1',
|
||||
' line2',
|
||||
'+line2.5',
|
||||
' line3',
|
||||
'',
|
||||
].join('\n');
|
||||
const patchPath = path.join(skillsDir, 'outside.patch');
|
||||
await fs.writeFile(patchPath, patchContent);
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, 'outside.patch');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain(
|
||||
'outside the global/workspace skill directories',
|
||||
);
|
||||
expect(await fs.readFile(outsideFile, 'utf-8')).not.toContain('line2.5');
|
||||
await expect(fs.access(patchPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject targets that escape the skill root through a symlinked parent', async () => {
|
||||
const outsideDir = path.join(tmpDir, 'outside-dir');
|
||||
const linkDir = path.join(projectSkillsDir, 'linked');
|
||||
await fs.mkdir(outsideDir, { recursive: true });
|
||||
await fs.symlink(
|
||||
outsideDir,
|
||||
linkDir,
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
|
||||
const outsideFile = path.join(outsideDir, 'escaped.md');
|
||||
await fs.writeFile(outsideFile, 'line1\nline2\nline3\n');
|
||||
|
||||
const patchPath = path.join(skillsDir, 'symlink.patch');
|
||||
await fs.writeFile(
|
||||
patchPath,
|
||||
[
|
||||
`--- ${path.join(linkDir, 'escaped.md')}`,
|
||||
`+++ ${path.join(linkDir, 'escaped.md')}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' line1',
|
||||
' line2',
|
||||
'+line2.5',
|
||||
' line3',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, 'symlink.patch');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain(
|
||||
'outside the global/workspace skill directories',
|
||||
);
|
||||
expect(await fs.readFile(outsideFile, 'utf-8')).not.toContain('line2.5');
|
||||
await expect(fs.access(patchPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject patches that contain no hunks', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(skillsDir, 'empty.patch'),
|
||||
[
|
||||
`--- ${path.join(projectSkillsDir, 'target.md')}`,
|
||||
`+++ ${path.join(projectSkillsDir, 'target.md')}`,
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, 'empty.patch');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('contains no valid hunks');
|
||||
});
|
||||
|
||||
it('should reject project-scope patches when the workspace is untrusted', async () => {
|
||||
const targetFile = path.join(projectSkillsDir, 'target.md');
|
||||
await fs.writeFile(targetFile, 'line1\nline2\nline3\n');
|
||||
|
||||
const patchPath = path.join(skillsDir, 'workspace.patch');
|
||||
await fs.writeFile(
|
||||
patchPath,
|
||||
[
|
||||
`--- ${targetFile}`,
|
||||
`+++ ${targetFile}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' line1',
|
||||
' line2',
|
||||
'+line2.5',
|
||||
' line3',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const untrustedConfig = {
|
||||
storage: applyConfig.storage,
|
||||
isTrustedFolder: () => false,
|
||||
} as Config;
|
||||
const result = await applyInboxPatch(untrustedConfig, 'workspace.patch');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain(
|
||||
'Project skill patches are unavailable until this workspace is trusted.',
|
||||
);
|
||||
expect(await fs.readFile(targetFile, 'utf-8')).toBe(
|
||||
'line1\nline2\nline3\n',
|
||||
);
|
||||
await expect(fs.access(patchPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject project-scope patches through a symlinked project skills root when the workspace is untrusted', async () => {
|
||||
const realProjectSkillsDir = path.join(tmpDir, 'project-skills-real');
|
||||
const symlinkedProjectSkillsDir = path.join(
|
||||
tmpDir,
|
||||
'project-skills-link',
|
||||
);
|
||||
await fs.mkdir(realProjectSkillsDir, { recursive: true });
|
||||
await fs.symlink(
|
||||
realProjectSkillsDir,
|
||||
symlinkedProjectSkillsDir,
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
);
|
||||
projectSkillsDir = symlinkedProjectSkillsDir;
|
||||
|
||||
const targetFile = path.join(realProjectSkillsDir, 'target.md');
|
||||
await fs.writeFile(targetFile, 'line1\nline2\nline3\n');
|
||||
|
||||
const patchPath = path.join(skillsDir, 'workspace-symlink.patch');
|
||||
await fs.writeFile(
|
||||
patchPath,
|
||||
[
|
||||
`--- ${targetFile}`,
|
||||
`+++ ${targetFile}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' line1',
|
||||
' line2',
|
||||
'+line2.5',
|
||||
' line3',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const untrustedConfig = {
|
||||
storage: applyConfig.storage,
|
||||
isTrustedFolder: () => false,
|
||||
} as Config;
|
||||
const result = await applyInboxPatch(
|
||||
untrustedConfig,
|
||||
'workspace-symlink.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain(
|
||||
'Project skill patches are unavailable until this workspace is trusted.',
|
||||
);
|
||||
expect(await fs.readFile(targetFile, 'utf-8')).toBe(
|
||||
'line1\nline2\nline3\n',
|
||||
);
|
||||
await expect(fs.access(patchPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject patches with mismatched diff headers', async () => {
|
||||
const sourceFile = path.join(projectSkillsDir, 'source.md');
|
||||
const targetFile = path.join(projectSkillsDir, 'target.md');
|
||||
await fs.writeFile(sourceFile, 'aaa\nbbb\nccc\n');
|
||||
await fs.writeFile(targetFile, 'xxx\nyyy\nzzz\n');
|
||||
|
||||
const patchPath = path.join(skillsDir, 'mismatched-headers.patch');
|
||||
await fs.writeFile(
|
||||
patchPath,
|
||||
[
|
||||
`--- ${sourceFile}`,
|
||||
`+++ ${targetFile}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' xxx',
|
||||
' yyy',
|
||||
'+yyy2',
|
||||
' zzz',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxPatch(
|
||||
applyConfig,
|
||||
'mismatched-headers.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('invalid diff headers');
|
||||
expect(await fs.readFile(sourceFile, 'utf-8')).toBe('aaa\nbbb\nccc\n');
|
||||
expect(await fs.readFile(targetFile, 'utf-8')).toBe('xxx\nyyy\nzzz\n');
|
||||
await expect(fs.access(patchPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should strip git-style a/ and b/ prefixes and apply successfully', async () => {
|
||||
const targetFile = path.join(projectSkillsDir, 'prefixed.md');
|
||||
await fs.writeFile(targetFile, 'line1\nline2\nline3\n');
|
||||
|
||||
const patchPath = path.join(skillsDir, 'git-prefix.patch');
|
||||
await fs.writeFile(
|
||||
patchPath,
|
||||
[
|
||||
`--- a/${targetFile}`,
|
||||
`+++ b/${targetFile}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' line1',
|
||||
' line2',
|
||||
'+line2.5',
|
||||
' line3',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, 'git-prefix.patch');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Applied patch to 1 file');
|
||||
expect(await fs.readFile(targetFile, 'utf-8')).toBe(
|
||||
'line1\nline2\nline2.5\nline3\n',
|
||||
);
|
||||
await expect(fs.access(patchPath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should not write any files if one patch in a multi-file set fails', async () => {
|
||||
const file1 = path.join(projectSkillsDir, 'file1.md');
|
||||
await fs.writeFile(file1, 'aaa\nbbb\nccc\n');
|
||||
const missingFile = path.join(projectSkillsDir, 'missing.md');
|
||||
|
||||
const patchContent = [
|
||||
`--- ${file1}`,
|
||||
`+++ ${file1}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' aaa',
|
||||
' bbb',
|
||||
'+bbb2',
|
||||
' ccc',
|
||||
`--- ${missingFile}`,
|
||||
`+++ ${missingFile}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' x',
|
||||
' y',
|
||||
'+z',
|
||||
' w',
|
||||
'',
|
||||
].join('\n');
|
||||
await fs.writeFile(path.join(skillsDir, 'partial.patch'), patchContent);
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, 'partial.patch');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
// Verify file1 was NOT modified (dry-run failed)
|
||||
const content = await fs.readFile(file1, 'utf-8');
|
||||
expect(content).not.toContain('bbb2');
|
||||
});
|
||||
|
||||
it('should roll back earlier file updates if a later commit step fails', async () => {
|
||||
const file1 = path.join(projectSkillsDir, 'file1.md');
|
||||
await fs.writeFile(file1, 'aaa\nbbb\nccc\n');
|
||||
|
||||
const conflictPath = path.join(projectSkillsDir, 'conflict');
|
||||
const nestedNewFile = path.join(conflictPath, 'nested.md');
|
||||
|
||||
const patchPath = path.join(skillsDir, 'rollback.patch');
|
||||
await fs.writeFile(
|
||||
patchPath,
|
||||
[
|
||||
`--- ${file1}`,
|
||||
`+++ ${file1}`,
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' aaa',
|
||||
' bbb',
|
||||
'+bbb2',
|
||||
' ccc',
|
||||
'--- /dev/null',
|
||||
`+++ ${conflictPath}`,
|
||||
'@@ -0,0 +1 @@',
|
||||
'+new file content',
|
||||
'--- /dev/null',
|
||||
`+++ ${nestedNewFile}`,
|
||||
'@@ -0,0 +1 @@',
|
||||
'+nested new file content',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const result = await applyInboxPatch(applyConfig, 'rollback.patch');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('could not be applied atomically');
|
||||
expect(await fs.readFile(file1, 'utf-8')).toBe('aaa\nbbb\nccc\n');
|
||||
expect((await fs.stat(conflictPath)).isDirectory()).toBe(true);
|
||||
await expect(fs.access(nestedNewFile)).rejects.toThrow();
|
||||
await expect(fs.access(patchPath)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dismissInboxPatch', () => {
|
||||
let tmpDir: string;
|
||||
let skillsDir: string;
|
||||
let dismissPatchConfig: Config;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'patch-dismiss-test-'));
|
||||
skillsDir = path.join(tmpDir, 'skills-memory');
|
||||
await fs.mkdir(skillsDir, { recursive: true });
|
||||
|
||||
dismissPatchConfig = {
|
||||
storage: {
|
||||
getProjectSkillsMemoryDir: () => skillsDir,
|
||||
},
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should delete the patch file and return success', async () => {
|
||||
const patchPath = path.join(skillsDir, 'old.patch');
|
||||
await fs.writeFile(patchPath, 'some patch content');
|
||||
|
||||
const result = await dismissInboxPatch(dismissPatchConfig, 'old.patch');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Dismissed');
|
||||
await expect(fs.access(patchPath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should return error when patch does not exist', async () => {
|
||||
const result = await dismissInboxPatch(
|
||||
dismissPatchConfig,
|
||||
'nonexistent.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('not found');
|
||||
});
|
||||
|
||||
it('should reject invalid patch file names', async () => {
|
||||
const outsidePatch = path.join(tmpDir, 'outside.patch');
|
||||
await fs.writeFile(outsidePatch, 'outside patch content');
|
||||
|
||||
const result = await dismissInboxPatch(
|
||||
dismissPatchConfig,
|
||||
'../outside.patch',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('Invalid patch file name.');
|
||||
await expect(fs.access(outsidePatch)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,12 +4,22 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as Diff from 'diff';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { flattenMemory } from '../config/memory.js';
|
||||
import { loadSkillFromFile, loadSkillsFromDir } from '../skills/skillLoader.js';
|
||||
import {
|
||||
type AppliedSkillPatchTarget,
|
||||
applyParsedSkillPatches,
|
||||
hasParsedPatchHunks,
|
||||
isProjectSkillPatchTarget,
|
||||
validateParsedSkillPatchHeaders,
|
||||
} from '../services/memoryPatchUtils.js';
|
||||
import { readExtractionState } from '../services/memoryService.js';
|
||||
import { refreshServerHierarchicalMemory } from '../utils/memoryDiscovery.js';
|
||||
import type { MessageActionReturn, ToolActionReturn } from './types.js';
|
||||
@@ -111,6 +121,8 @@ export interface InboxSkill {
|
||||
name: string;
|
||||
/** Skill description from SKILL.md frontmatter. */
|
||||
description: string;
|
||||
/** Raw SKILL.md content for preview. */
|
||||
content: string;
|
||||
/** When the skill was extracted (ISO string), if known. */
|
||||
extractedAt?: string;
|
||||
}
|
||||
@@ -153,10 +165,18 @@ export async function listInboxSkills(config: Config): Promise<InboxSkill[]> {
|
||||
const skillDef = await loadSkillFromFile(skillPath);
|
||||
if (!skillDef) continue;
|
||||
|
||||
let content = '';
|
||||
try {
|
||||
content = await fs.readFile(skillPath, 'utf-8');
|
||||
} catch {
|
||||
// Best-effort — preview will be empty
|
||||
}
|
||||
|
||||
skills.push({
|
||||
dirName: dir.name,
|
||||
name: skillDef.name,
|
||||
description: skillDef.description,
|
||||
content,
|
||||
extractedAt: skillDateMap.get(dir.name),
|
||||
});
|
||||
}
|
||||
@@ -176,6 +196,16 @@ function isValidInboxSkillDirName(dirName: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isValidInboxPatchFileName(fileName: string): boolean {
|
||||
return (
|
||||
fileName.length > 0 &&
|
||||
fileName !== '.' &&
|
||||
fileName !== '..' &&
|
||||
!fileName.includes('/') &&
|
||||
!fileName.includes('\\')
|
||||
);
|
||||
}
|
||||
|
||||
async function getSkillNameForConflictCheck(
|
||||
skillDir: string,
|
||||
fallbackName: string,
|
||||
@@ -283,3 +313,448 @@ export async function dismissInboxSkill(
|
||||
message: `Dismissed "${dirName}" from inbox.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A parsed patch entry from a unified diff, representing changes to a single file.
|
||||
*/
|
||||
export interface InboxPatchEntry {
|
||||
/** Absolute path to the target file (or '/dev/null' for new files). */
|
||||
targetPath: string;
|
||||
/** The unified diff text for this single file. */
|
||||
diffContent: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a .patch file found in the extraction inbox.
|
||||
*/
|
||||
export interface InboxPatch {
|
||||
/** The .patch filename (e.g. "update-docs-writer.patch"). */
|
||||
fileName: string;
|
||||
/** Display name (filename without .patch extension). */
|
||||
name: string;
|
||||
/** Per-file entries parsed from the patch. */
|
||||
entries: InboxPatchEntry[];
|
||||
/** When the patch was extracted (ISO string), if known. */
|
||||
extractedAt?: string;
|
||||
}
|
||||
|
||||
interface StagedInboxPatchTarget {
|
||||
targetPath: string;
|
||||
tempPath: string;
|
||||
original: string;
|
||||
isNewFile: boolean;
|
||||
mode?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstructs a unified diff string for a single ParsedDiff entry.
|
||||
*/
|
||||
function formatParsedDiff(parsed: Diff.StructuredPatch): string {
|
||||
const lines: string[] = [];
|
||||
if (parsed.oldFileName) {
|
||||
lines.push(`--- ${parsed.oldFileName}`);
|
||||
}
|
||||
if (parsed.newFileName) {
|
||||
lines.push(`+++ ${parsed.newFileName}`);
|
||||
}
|
||||
for (const hunk of parsed.hunks) {
|
||||
lines.push(
|
||||
`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,
|
||||
);
|
||||
for (const line of hunk.lines) {
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function patchTargetsProjectSkills(
|
||||
targetPaths: string[],
|
||||
config: Config,
|
||||
) {
|
||||
for (const targetPath of targetPaths) {
|
||||
if (await isProjectSkillPatchTarget(targetPath, config)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function getPatchExtractedAt(
|
||||
patchPath: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const stats = await fs.stat(patchPath);
|
||||
return stats.mtime.toISOString();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function findNearestExistingDirectory(
|
||||
startPath: string,
|
||||
): Promise<string> {
|
||||
let currentPath = path.resolve(startPath);
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const stats = await fs.stat(currentPath);
|
||||
if (stats.isDirectory()) {
|
||||
return currentPath;
|
||||
}
|
||||
} catch {
|
||||
// Keep walking upward until we find an existing directory.
|
||||
}
|
||||
|
||||
const parentPath = path.dirname(currentPath);
|
||||
if (parentPath === currentPath) {
|
||||
return currentPath;
|
||||
}
|
||||
currentPath = parentPath;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeExclusiveFile(
|
||||
filePath: string,
|
||||
content: string,
|
||||
mode?: number,
|
||||
): Promise<void> {
|
||||
const handle = await fs.open(filePath, 'wx');
|
||||
try {
|
||||
await handle.writeFile(content, 'utf-8');
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
if (mode !== undefined) {
|
||||
await fs.chmod(filePath, mode);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupStagedInboxPatchTargets(
|
||||
stagedTargets: StagedInboxPatchTarget[],
|
||||
): Promise<void> {
|
||||
await Promise.allSettled(
|
||||
stagedTargets.map(async ({ tempPath }) => {
|
||||
try {
|
||||
await fs.unlink(tempPath);
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function restoreCommittedInboxPatchTarget(
|
||||
stagedTarget: StagedInboxPatchTarget,
|
||||
): Promise<void> {
|
||||
if (stagedTarget.isNewFile) {
|
||||
try {
|
||||
await fs.unlink(stagedTarget.targetPath);
|
||||
} catch {
|
||||
// Best-effort rollback.
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const restoreDir = await findNearestExistingDirectory(
|
||||
path.dirname(stagedTarget.targetPath),
|
||||
);
|
||||
const restorePath = path.join(
|
||||
restoreDir,
|
||||
`.${path.basename(stagedTarget.targetPath)}.${randomUUID()}.rollback`,
|
||||
);
|
||||
|
||||
await writeExclusiveFile(
|
||||
restorePath,
|
||||
stagedTarget.original,
|
||||
stagedTarget.mode,
|
||||
);
|
||||
await fs.rename(restorePath, stagedTarget.targetPath);
|
||||
}
|
||||
|
||||
async function stageInboxPatchTargets(
|
||||
targets: AppliedSkillPatchTarget[],
|
||||
): Promise<StagedInboxPatchTarget[]> {
|
||||
const stagedTargets: StagedInboxPatchTarget[] = [];
|
||||
|
||||
try {
|
||||
for (const target of targets) {
|
||||
let mode: number | undefined;
|
||||
if (!target.isNewFile) {
|
||||
await fs.access(target.targetPath, fsConstants.W_OK);
|
||||
mode = (await fs.stat(target.targetPath)).mode;
|
||||
}
|
||||
|
||||
const tempDir = await findNearestExistingDirectory(
|
||||
path.dirname(target.targetPath),
|
||||
);
|
||||
const tempPath = path.join(
|
||||
tempDir,
|
||||
`.${path.basename(target.targetPath)}.${randomUUID()}.patch-tmp`,
|
||||
);
|
||||
|
||||
await writeExclusiveFile(tempPath, target.patched, mode);
|
||||
stagedTargets.push({
|
||||
targetPath: target.targetPath,
|
||||
tempPath,
|
||||
original: target.original,
|
||||
isNewFile: target.isNewFile,
|
||||
mode,
|
||||
});
|
||||
}
|
||||
|
||||
for (const target of stagedTargets) {
|
||||
if (!target.isNewFile) {
|
||||
continue;
|
||||
}
|
||||
await fs.mkdir(path.dirname(target.targetPath), { recursive: true });
|
||||
}
|
||||
|
||||
return stagedTargets;
|
||||
} catch (error) {
|
||||
await cleanupStagedInboxPatchTargets(stagedTargets);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the skill extraction inbox for .patch files and returns
|
||||
* structured data for each valid patch.
|
||||
*/
|
||||
export async function listInboxPatches(config: Config): Promise<InboxPatch[]> {
|
||||
const skillsDir = config.storage.getProjectSkillsMemoryDir();
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(skillsDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const patchFiles = entries.filter((e) => e.endsWith('.patch'));
|
||||
if (patchFiles.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const patches: InboxPatch[] = [];
|
||||
for (const patchFile of patchFiles) {
|
||||
const patchPath = path.join(skillsDir, patchFile);
|
||||
try {
|
||||
const content = await fs.readFile(patchPath, 'utf-8');
|
||||
const parsed = Diff.parsePatch(content);
|
||||
if (!hasParsedPatchHunks(parsed)) continue;
|
||||
|
||||
const patchEntries: InboxPatchEntry[] = parsed.map((p) => ({
|
||||
targetPath: p.newFileName ?? p.oldFileName ?? '',
|
||||
diffContent: formatParsedDiff(p),
|
||||
}));
|
||||
|
||||
patches.push({
|
||||
fileName: patchFile,
|
||||
name: patchFile.replace(/\.patch$/, ''),
|
||||
entries: patchEntries,
|
||||
extractedAt: await getPatchExtractedAt(patchPath),
|
||||
});
|
||||
} catch {
|
||||
// Skip unreadable patch files
|
||||
}
|
||||
}
|
||||
|
||||
return patches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a .patch file from the inbox by reading each target file,
|
||||
* applying the diff, and writing the result. Deletes the patch on success.
|
||||
*/
|
||||
export async function applyInboxPatch(
|
||||
config: Config,
|
||||
fileName: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
if (!isValidInboxPatchFileName(fileName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Invalid patch file name.',
|
||||
};
|
||||
}
|
||||
|
||||
const skillsDir = config.storage.getProjectSkillsMemoryDir();
|
||||
const patchPath = path.join(skillsDir, fileName);
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(patchPath, 'utf-8');
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch "${fileName}" not found in inbox.`,
|
||||
};
|
||||
}
|
||||
|
||||
let parsed: Diff.StructuredPatch[];
|
||||
try {
|
||||
parsed = Diff.parsePatch(content);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to parse patch "${fileName}": ${getErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
if (!hasParsedPatchHunks(parsed)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch "${fileName}" contains no valid hunks.`,
|
||||
};
|
||||
}
|
||||
|
||||
const validatedHeaders = validateParsedSkillPatchHeaders(parsed);
|
||||
if (!validatedHeaders.success) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
validatedHeaders.reason === 'missingTargetPath'
|
||||
? `Patch "${fileName}" is missing a target file path.`
|
||||
: `Patch "${fileName}" has invalid diff headers.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
!config.isTrustedFolder() &&
|
||||
(await patchTargetsProjectSkills(
|
||||
validatedHeaders.patches.map((patch) => patch.targetPath),
|
||||
config,
|
||||
))
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
'Project skill patches are unavailable until this workspace is trusted.',
|
||||
};
|
||||
}
|
||||
|
||||
// Dry-run first: verify all patches apply cleanly before writing anything.
|
||||
// Repeated file blocks are validated against the progressively patched content.
|
||||
const applied = await applyParsedSkillPatches(parsed, config);
|
||||
if (!applied.success) {
|
||||
switch (applied.reason) {
|
||||
case 'missingTargetPath':
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch "${fileName}" is missing a target file path.`,
|
||||
};
|
||||
case 'invalidPatchHeaders':
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch "${fileName}" has invalid diff headers.`,
|
||||
};
|
||||
case 'outsideAllowedRoots':
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch "${fileName}" targets a file outside the global/workspace skill directories: ${applied.targetPath}`,
|
||||
};
|
||||
case 'newFileAlreadyExists':
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch "${fileName}" declares a new file, but the target already exists: ${applied.targetPath}`,
|
||||
};
|
||||
case 'targetNotFound':
|
||||
return {
|
||||
success: false,
|
||||
message: `Target file not found: ${applied.targetPath}`,
|
||||
};
|
||||
case 'doesNotApply':
|
||||
return {
|
||||
success: false,
|
||||
message: applied.isNewFile
|
||||
? `Patch "${fileName}" failed to apply for new file ${applied.targetPath}.`
|
||||
: `Patch does not apply cleanly to ${applied.targetPath}.`,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch "${fileName}" could not be applied.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let stagedTargets: StagedInboxPatchTarget[];
|
||||
try {
|
||||
stagedTargets = await stageInboxPatchTargets(applied.results);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch "${fileName}" could not be staged: ${getErrorMessage(error)}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const committedTargets: StagedInboxPatchTarget[] = [];
|
||||
try {
|
||||
for (const stagedTarget of stagedTargets) {
|
||||
await fs.rename(stagedTarget.tempPath, stagedTarget.targetPath);
|
||||
committedTargets.push(stagedTarget);
|
||||
}
|
||||
} catch (error) {
|
||||
for (const committedTarget of committedTargets.reverse()) {
|
||||
try {
|
||||
await restoreCommittedInboxPatchTarget(committedTarget);
|
||||
} catch {
|
||||
// Best-effort rollback. We still report the commit failure below.
|
||||
}
|
||||
}
|
||||
await cleanupStagedInboxPatchTargets(
|
||||
stagedTargets.filter((target) => !committedTargets.includes(target)),
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch "${fileName}" could not be applied atomically: ${getErrorMessage(error)}.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Remove the patch file
|
||||
await fs.unlink(patchPath);
|
||||
|
||||
const fileCount = applied.results.length;
|
||||
return {
|
||||
success: true,
|
||||
message: `Applied patch to ${fileCount} file${fileCount !== 1 ? 's' : ''}.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a .patch file from the extraction inbox.
|
||||
*/
|
||||
export async function dismissInboxPatch(
|
||||
config: Config,
|
||||
fileName: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
if (!isValidInboxPatchFileName(fileName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Invalid patch file name.',
|
||||
};
|
||||
}
|
||||
|
||||
const skillsDir = config.storage.getProjectSkillsMemoryDir();
|
||||
const patchPath = path.join(skillsDir, fileName);
|
||||
|
||||
try {
|
||||
await fs.access(patchPath);
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch "${fileName}" not found in inbox.`,
|
||||
};
|
||||
}
|
||||
|
||||
await fs.unlink(patchPath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Dismissed "${fileName}" from inbox.`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||