Compare commits

...

13 Commits

Author SHA1 Message Date
jacob314 7df8c88d96 Checkpoint fixing tests. 2026-04-15 15:10:31 -07:00
gemini-cli-robot 166845d933 Changelog for v0.39.0-preview.0 (#25417)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-04-15 17:28:39 +00:00
Tommaso Sciortino 55620235c0 feat: bundle ripgrep binaries into SEA for offline support (#25342) 2026-04-15 06:28:06 +00:00
Gal Zahavi 366f9e4766 fix(core): prevent YOLO mode from being downgraded (#25341) 2026-04-15 06:27:36 +00:00
Rob Clevenger 06e7621b26 Fix(core): retry additional OpenSSL 3.x SSL errors during streaming (#16075) (#25187) 2026-04-15 02:50:22 +00:00
gemini-cli-robot 8d05bdbe49 chore(release): bump version to 0.40.0-nightly.20260414.g5b1f7375a (#25420) 2026-04-15 00:06:35 +00:00
gemini-cli-robot 5b1f7375a3 Changelog for v0.37.2 (#25336)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-04-14 21:44:49 +00:00
Tommaso Sciortino d613dd05db use macos-latest-large runner where applicable. (#25413) 2026-04-14 14:05:25 -07:00
Adam Weidman a6d43cba2d ci: add agent session drift check workflow (#25389) 2026-04-14 19:31:48 +00:00
Clay 161ba28966 fix(core): detect kmscon terminal as supporting true color (#25282)
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-14 19:08:51 +00:00
Adib234 05aa1465fe feat(cli): enable mouse clicking for cursor positioning in AskUser multi-line answers (#24630) 2026-04-14 19:07:00 +00:00
cynthialong0-0 8f6edc50c1 feat(test): add a performance test in asian language (#25392) 2026-04-14 18:43:36 +00:00
Emily Hedlund 88ddcab616 fix(core): use debug level for keychain fallback logging (#25398) 2026-04-14 18:33:01 +00:00
69 changed files with 2630 additions and 1893 deletions
@@ -0,0 +1,132 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: 'Agent Session Drift Check'
on:
pull_request:
branches:
- 'main'
- 'release/**'
paths:
- 'packages/cli/src/nonInteractiveCli.ts'
- 'packages/cli/src/nonInteractiveCliAgentSession.ts'
concurrency:
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
cancel-in-progress: true
jobs:
check-drift:
name: 'Check Agent Session Drift'
runs-on: 'ubuntu-latest'
if: "github.repository == 'google-gemini/gemini-cli'"
permissions:
contents: 'read'
pull-requests: 'write'
steps:
- name: 'Detect drift and comment'
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v8
with:
script: |-
// === Pair configuration — append here to cover more pairs ===
const PAIRS = [
{
legacy: 'packages/cli/src/nonInteractiveCli.ts',
session: 'packages/cli/src/nonInteractiveCliAgentSession.ts',
label: 'non-interactive CLI',
},
// Future pairs can be added here. Remember to also add both
// paths to the `paths:` filter at the top of this workflow.
// Example:
// {
// legacy: 'packages/core/src/agents/local-invocation.ts',
// session: 'packages/core/src/agents/local-session-invocation.ts',
// label: 'local subagent invocation',
// },
];
// ============================================================
const prNumber = context.payload.pull_request.number;
const { owner, repo } = context.repo;
// Use the API to list changed files — no checkout/git diff needed.
const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: prNumber,
per_page: 100,
});
const changed = new Set(files.map((f) => f.filename));
const warnings = [];
for (const { legacy, session, label } of PAIRS) {
const legacyChanged = changed.has(legacy);
const sessionChanged = changed.has(session);
if (legacyChanged && !sessionChanged) {
warnings.push(
`**${label}**: \`${legacy}\` was modified but \`${session}\` was not.`,
);
} else if (!legacyChanged && sessionChanged) {
warnings.push(
`**${label}**: \`${session}\` was modified but \`${legacy}\` was not.`,
);
}
}
const MARKER = '<!-- agent-session-drift-check -->';
// Look up our existing drift comment (for upsert/cleanup).
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: prNumber,
per_page: 100,
});
const existing = comments.find(
(c) => c.user?.type === 'Bot' && c.body?.includes(MARKER),
);
if (warnings.length === 0) {
core.info('No drift detected.');
// If drift was previously flagged and is now resolved, remove the comment.
if (existing) {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: existing.id,
});
core.info(`Deleted stale drift comment ${existing.id}.`);
}
return;
}
const body = [
MARKER,
'### ⚠️ Invocation Drift Warning',
'',
'The following file pairs should generally be kept in sync during the AgentSession migration:',
'',
...warnings.map((w) => `- ${w}`),
'',
'If this is intentional (e.g., a bug fix specific to one implementation), you can ignore this comment.',
'',
'_This check will be removed once the legacy implementations are deleted._',
].join('\n');
if (existing) {
core.info(`Updating existing drift comment ${existing.id}.`);
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
} else {
core.info('Creating new drift comment.');
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body,
});
}
+1 -1
View File
@@ -183,7 +183,7 @@ jobs:
needs:
- 'merge_queue_skipper'
- 'parse_run_context'
runs-on: 'macos-latest'
runs-on: 'macos-latest-large'
if: |
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
steps:
+1 -1
View File
@@ -224,7 +224,7 @@ jobs:
test_mac:
name: 'Test (Mac) - ${{ matrix.node-version }}, ${{ matrix.shard }}'
runs-on: 'macos-latest'
runs-on: 'macos-latest-large'
needs:
- 'merge_queue_skipper'
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
+1 -1
View File
@@ -77,7 +77,7 @@ jobs:
deflake_e2e_mac:
name: 'E2E Test (macOS)'
runs-on: 'macos-latest'
runs-on: 'macos-latest-large'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
+6 -3
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.37.1
# Latest stable release: v0.37.2
Released: April 09, 2026
Released: April 13, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -26,6 +26,9 @@ npm install -g @google/gemini-cli
## What's Changed
- fix(patch): cherry-pick 9d741ab to release/v0.37.1-pr-24565 to patch version
v0.37.1 and create version 0.37.2 by @gemini-cli-robot in
[#25322](https://github.com/google-gemini/gemini-cli/pull/25322)
- fix(acp): handle all InvalidStreamError types gracefully in prompt
[#24540](https://github.com/google-gemini/gemini-cli/pull/24540)
- feat(acp): add support for /about command
@@ -422,4 +425,4 @@ npm install -g @google/gemini-cli
[#24842](https://github.com/google-gemini/gemini-cli/pull/24842)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.36.0...v0.37.1
https://github.com/google-gemini/gemini-cli/compare/v0.36.0...v0.37.2
+236 -247
View File
@@ -1,6 +1,6 @@
# Preview release: v0.38.0-preview.0
# Preview release: v0.39.0-preview.0
Released: April 08, 2026
Released: April 14, 2026
Our preview release includes the latest, new, and experimental features. This
release may not be as stable as our [latest weekly release](latest.md).
@@ -13,256 +13,245 @@ npm install -g @google/gemini-cli@preview
## Highlights
- **Context Management:** Introduced a Context Compression Service to optimize
context window usage and landed a background memory service for skill
extraction.
- **Enhanced Security:** Implemented context-aware persistent policy approvals
for smarter tool permissions and enabled `web_fetch` in plan mode with user
confirmation.
- **Workflow Monitoring:** Added background process monitoring and inspection
tools for better visibility into long-running tasks.
- **UI/UX Refinements:** Enhanced the tool confirmation UI, selection layout,
and added support for selective topic expansion and click-to-expand.
- **Core Stability:** Improved sandbox reliability on Linux and Windows,
resolved shebang compatibility issues, and fixed various crashes in the CLI
and core services.
- **Refactored Subagents and Unified Tooling:** Consolidate subagent tools into
a single `invoke_subagent` tool, removed legacy wrapping tools, and improved
turn limits for codebase investigator.
- **Advanced Memory and Skill Management:** Introduced `/memory` inbox for
reviewing extracted skills and added skill patching support, enhancing agent
learning and persistence.
- **Expanded Test and Evaluation Infrastructure:** Added memory and CPU
performance integration test harnesses and generalized evaluation
infrastructure for better suite organization.
- **Sandbox and Security Hardening:** Centralized sandbox paths for Linux and
macOS, enforced read-only security for async git worktree resolution, and
optimized Windows sandbox initialization.
- **Enhanced CLI UX and UI Stability:** Improved scroll momentum, added a
`debugRainbow` setting, and resolved various memory leaks and PTY exhaustion
issues for a smoother terminal experience.
## What's Changed
- fix(cli): refresh slash command list after /skills reload by @NTaylorMullen in
[#24454](https://github.com/google-gemini/gemini-cli/pull/24454)
- Update README.md for links. by @g-samroberts in
[#22759](https://github.com/google-gemini/gemini-cli/pull/22759)
- fix(core): ensure complete_task tool calls are recorded in chat history by
@abhipatel12 in
[#24437](https://github.com/google-gemini/gemini-cli/pull/24437)
- feat(policy): explicitly allow web_fetch in plan mode with ask_user by
@Adib234 in [#24456](https://github.com/google-gemini/gemini-cli/pull/24456)
- fix(core): refactor linux sandbox to fix ARG_MAX crashes by @ehedlund in
[#24286](https://github.com/google-gemini/gemini-cli/pull/24286)
- feat(config): add experimental.adk.agentSessionNoninteractiveEnabled setting
by @adamfweidman in
[#24439](https://github.com/google-gemini/gemini-cli/pull/24439)
- Changelog for v0.36.0-preview.8 by @gemini-cli-robot in
[#24453](https://github.com/google-gemini/gemini-cli/pull/24453)
- feat(cli): change default loadingPhrases to 'off' to hide tips by @keithguerin
in [#24342](https://github.com/google-gemini/gemini-cli/pull/24342)
- fix(cli): ensure agent stops when all declinable tools are cancelled by
@NTaylorMullen in
[#24479](https://github.com/google-gemini/gemini-cli/pull/24479)
- fix(core): enhance sandbox usability and fix build error by @galz10 in
[#24460](https://github.com/google-gemini/gemini-cli/pull/24460)
- Terminal Serializer Optimization by @jacob314 in
[#24485](https://github.com/google-gemini/gemini-cli/pull/24485)
- Auto configure memory. by @jacob314 in
[#24474](https://github.com/google-gemini/gemini-cli/pull/24474)
- Unused error variables in catch block are not allowed by @alisa-alisa in
[#24487](https://github.com/google-gemini/gemini-cli/pull/24487)
- feat(core): add background memory service for skill extraction by @SandyTao520
in [#24274](https://github.com/google-gemini/gemini-cli/pull/24274)
- feat: implement high-signal PR regression check for evaluations by
@alisa-alisa in
[#23937](https://github.com/google-gemini/gemini-cli/pull/23937)
- Fix shell output display by @jacob314 in
[#24490](https://github.com/google-gemini/gemini-cli/pull/24490)
- fix(ui): resolve unwanted vertical spacing around various tool output
treatments by @jwhelangoog in
[#24449](https://github.com/google-gemini/gemini-cli/pull/24449)
- revert(cli): bring back input box and footer visibility in copy mode by
@sehoon38 in [#24504](https://github.com/google-gemini/gemini-cli/pull/24504)
- fix(cli): prevent crash in AnsiOutputText when handling non-array data by
@sehoon38 in [#24498](https://github.com/google-gemini/gemini-cli/pull/24498)
- feat(cli): support default values for environment variables by @ruomengz in
[#24469](https://github.com/google-gemini/gemini-cli/pull/24469)
- Implement background process monitoring and inspection tools by @cocosheng-g
in [#23799](https://github.com/google-gemini/gemini-cli/pull/23799)
- docs(browser-agent): update stale browser agent documentation by @gsquared94
in [#24463](https://github.com/google-gemini/gemini-cli/pull/24463)
- fix: enable browser_agent in integration tests and add localhost fixture tests
by @gsquared94 in
[#24523](https://github.com/google-gemini/gemini-cli/pull/24523)
- fix(browser): handle computer-use model detection for analyze_screenshot by
@gsquared94 in
[#24502](https://github.com/google-gemini/gemini-cli/pull/24502)
- feat(core): Land ContextCompressionService by @joshualitt in
[#24483](https://github.com/google-gemini/gemini-cli/pull/24483)
- feat(core): scope subagent workspace directories via AsyncLocalStorage by
- refactor(plan): simplify policy priorities and consolidate read-only rules by
@ruomengz in [#24849](https://github.com/google-gemini/gemini-cli/pull/24849)
- feat(test-utils): add memory usage integration test harness by @sripasg in
[#24876](https://github.com/google-gemini/gemini-cli/pull/24876)
- feat(memory): add /memory inbox command for reviewing extracted skills by
@SandyTao520 in
[#24445](https://github.com/google-gemini/gemini-cli/pull/24445)
- Update ink version to 6.6.7 by @jacob314 in
[#24514](https://github.com/google-gemini/gemini-cli/pull/24514)
- fix(acp): handle all InvalidStreamError types gracefully in prompt by @sripasg
in [#24540](https://github.com/google-gemini/gemini-cli/pull/24540)
- Fix crash when vim editor is not found in PATH on Windows by
@Nagajyothi-tammisetti in
[#22423](https://github.com/google-gemini/gemini-cli/pull/22423)
- fix(core): move project memory dir under tmp directory by @SandyTao520 in
[#24542](https://github.com/google-gemini/gemini-cli/pull/24542)
- Enable 'Other' option for yesno question type by @ruomengz in
[#24545](https://github.com/google-gemini/gemini-cli/pull/24545)
- fix(cli): clear stale retry/loading state after cancellation (#21096) by
@Aaxhirrr in [#21960](https://github.com/google-gemini/gemini-cli/pull/21960)
- Changelog for v0.37.0-preview.0 by @gemini-cli-robot in
[#24464](https://github.com/google-gemini/gemini-cli/pull/24464)
- feat(core): implement context-aware persistent policy approvals by @jerop in
[#23257](https://github.com/google-gemini/gemini-cli/pull/23257)
- docs: move agent disabling instructions and update remote agent status by
@jackwotherspoon in
[#24559](https://github.com/google-gemini/gemini-cli/pull/24559)
- feat(cli): migrate nonInteractiveCli to LegacyAgentSession by @adamfweidman in
[#22987](https://github.com/google-gemini/gemini-cli/pull/22987)
- fix(core): unsafe type assertions in Core File System #19712 by
@aniketsaurav18 in
[#19739](https://github.com/google-gemini/gemini-cli/pull/19739)
- fix(ui): hide model quota in /stats and refactor quota display by @danzaharia1
in [#24206](https://github.com/google-gemini/gemini-cli/pull/24206)
- Changelog for v0.36.0 by @gemini-cli-robot in
[#24558](https://github.com/google-gemini/gemini-cli/pull/24558)
- Changelog for v0.37.0-preview.1 by @gemini-cli-robot in
[#24568](https://github.com/google-gemini/gemini-cli/pull/24568)
- docs: add missing .md extensions to internal doc links by @ishaan-arora-1 in
[#24145](https://github.com/google-gemini/gemini-cli/pull/24145)
- fix(ui): fixed table styling by @devr0306 in
[#24565](https://github.com/google-gemini/gemini-cli/pull/24565)
- fix(core): pass includeDirectories to sandbox configuration by @galz10 in
[#24573](https://github.com/google-gemini/gemini-cli/pull/24573)
- feat(ui): enable "TerminalBuffer" mode to solve flicker by @jacob314 in
[#24512](https://github.com/google-gemini/gemini-cli/pull/24512)
- docs: clarify release coordination by @scidomino in
[#24575](https://github.com/google-gemini/gemini-cli/pull/24575)
- fix(core): remove broken PowerShell translation and fix native \_\_write in
Windows sandbox by @scidomino in
[#24571](https://github.com/google-gemini/gemini-cli/pull/24571)
- Add instructions for how to start react in prod and force react to prod mode
by @jacob314 in
[#24590](https://github.com/google-gemini/gemini-cli/pull/24590)
- feat(cli): minimalist sandbox status labels by @galz10 in
[#24582](https://github.com/google-gemini/gemini-cli/pull/24582)
- Feat/browser agent metrics by @kunal-10-cloud in
[#24210](https://github.com/google-gemini/gemini-cli/pull/24210)
- test: fix Windows CI execution and resolve exposed platform failures by
@ehedlund in [#24476](https://github.com/google-gemini/gemini-cli/pull/24476)
- feat(core,cli): prioritize summary for topics (#24608) by @Abhijit-2592 in
[#24609](https://github.com/google-gemini/gemini-cli/pull/24609)
- show color by @jacob314 in
[#24613](https://github.com/google-gemini/gemini-cli/pull/24613)
- feat(cli): enable compact tool output by default (#24509) by @jwhelangoog in
[#24510](https://github.com/google-gemini/gemini-cli/pull/24510)
- fix(core): inject skill system instructions into subagent prompts if activated
by @abhipatel12 in
[#24620](https://github.com/google-gemini/gemini-cli/pull/24620)
- fix(core): improve windows sandbox reliability and fix integration tests by
@ehedlund in [#24480](https://github.com/google-gemini/gemini-cli/pull/24480)
- fix(core): ensure sandbox approvals are correctly persisted and matched for
proactive expansions by @galz10 in
[#24577](https://github.com/google-gemini/gemini-cli/pull/24577)
- feat(cli) Scrollbar for input prompt by @jacob314 in
[#21992](https://github.com/google-gemini/gemini-cli/pull/21992)
- Do not run pr-eval workflow when no steering changes detected by @alisa-alisa
in [#24621](https://github.com/google-gemini/gemini-cli/pull/24621)
- Fix restoration of topic headers. by @gundermanc in
[#24650](https://github.com/google-gemini/gemini-cli/pull/24650)
- feat(core): discourage update topic tool for simple tasks by @Samee24 in
[#24640](https://github.com/google-gemini/gemini-cli/pull/24640)
- fix(core): ensure global temp directory is always in sandbox allowed paths by
@galz10 in [#24638](https://github.com/google-gemini/gemini-cli/pull/24638)
- fix(core): detect uninitialized lines by @jacob314 in
[#24646](https://github.com/google-gemini/gemini-cli/pull/24646)
- docs: update sandboxing documentation and toolSandboxing settings by @galz10
in [#24655](https://github.com/google-gemini/gemini-cli/pull/24655)
- feat(cli): enhance tool confirmation UI and selection layout by @galz10 in
[#24376](https://github.com/google-gemini/gemini-cli/pull/24376)
- feat(acp): add support for `/about` command by @sripasg in
[#24649](https://github.com/google-gemini/gemini-cli/pull/24649)
- feat(cli): add role specific metrics to /stats by @cynthialong0-0 in
[#24659](https://github.com/google-gemini/gemini-cli/pull/24659)
- split context by @jacob314 in
[#24623](https://github.com/google-gemini/gemini-cli/pull/24623)
- fix(cli): remove -S from shebang to fix Windows and BSD execution by
@scidomino in [#24756](https://github.com/google-gemini/gemini-cli/pull/24756)
- Fix issue where topic headers can be posted back to back by @gundermanc in
[#24759](https://github.com/google-gemini/gemini-cli/pull/24759)
- fix(core): handle partial llm_request in BeforeModel hook override by
@krishdef7 in [#22326](https://github.com/google-gemini/gemini-cli/pull/22326)
- fix(ui): improve narration suppression and reduce flicker by @gundermanc in
[#24635](https://github.com/google-gemini/gemini-cli/pull/24635)
- fix(ui): fixed auth race condition causing logo to flicker by @devr0306 in
[#24652](https://github.com/google-gemini/gemini-cli/pull/24652)
- fix(browser): remove premature browser cleanup after subagent invocation by
@gsquared94 in
[#24753](https://github.com/google-gemini/gemini-cli/pull/24753)
- Revert "feat(core,cli): prioritize summary for topics (#24608)" by
@Abhijit-2592 in
[#24777](https://github.com/google-gemini/gemini-cli/pull/24777)
- relax tool sandboxing overrides for plan mode to match defaults. by
@DavidAPierce in
[#24762](https://github.com/google-gemini/gemini-cli/pull/24762)
- fix(cli): respect global environment variable allowlist by @scidomino in
[#24767](https://github.com/google-gemini/gemini-cli/pull/24767)
- fix(cli): ensure skills list outputs to stdout in non-interactive environments
[#24544](https://github.com/google-gemini/gemini-cli/pull/24544)
- chore(release): bump version to 0.39.0-nightly.20260408.e77b22e63 by
@gemini-cli-robot in
[#24939](https://github.com/google-gemini/gemini-cli/pull/24939)
- fix(core): ensure robust sandbox cleanup in all process execution paths by
@ehedlund in [#24763](https://github.com/google-gemini/gemini-cli/pull/24763)
- chore: update ink version to 6.6.8 by @jacob314 in
[#24934](https://github.com/google-gemini/gemini-cli/pull/24934)
- Changelog for v0.38.0-preview.0 by @gemini-cli-robot in
[#24938](https://github.com/google-gemini/gemini-cli/pull/24938)
- chore: ignore conductor directory by @JayadityaGit in
[#22128](https://github.com/google-gemini/gemini-cli/pull/22128)
- Changelog for v0.37.0 by @gemini-cli-robot in
[#24940](https://github.com/google-gemini/gemini-cli/pull/24940)
- feat(plan): require user confirmation for activate_skill in Plan Mode by
@ruomengz in [#24946](https://github.com/google-gemini/gemini-cli/pull/24946)
- feat(test-utils): add CPU performance integration test harness by @sripasg in
[#24951](https://github.com/google-gemini/gemini-cli/pull/24951)
- fix(cli-ui): enable Ctrl+Backspace for word deletion in Windows Terminal by
@dogukanozen in
[#21447](https://github.com/google-gemini/gemini-cli/pull/21447)
- test(sdk): add unit tests for GeminiCliSession by @AdamyaSingh7 in
[#21897](https://github.com/google-gemini/gemini-cli/pull/21897)
- fix(core): resolve windows symlink bypass and stabilize sandbox integration
tests by @ehedlund in
[#24834](https://github.com/google-gemini/gemini-cli/pull/24834)
- fix(cli): restore file path display in edit and write tool confirmations by
@jwhelangoog in
[#24974](https://github.com/google-gemini/gemini-cli/pull/24974)
- feat(core): refine shell tool description display logic by @jwhelangoog in
[#24903](https://github.com/google-gemini/gemini-cli/pull/24903)
- fix(core): dynamic session ID injection to resolve resume bugs by @scidomino
in [#24972](https://github.com/google-gemini/gemini-cli/pull/24972)
- Update ink version to 6.6.9 by @jacob314 in
[#24980](https://github.com/google-gemini/gemini-cli/pull/24980)
- Generalize evals infra to support more types of evals, organization and
queuing of named suites by @gundermanc in
[#24941](https://github.com/google-gemini/gemini-cli/pull/24941)
- fix(cli): optimize startup with lightweight parent process by @sehoon38 in
[#24667](https://github.com/google-gemini/gemini-cli/pull/24667)
- refactor(sandbox): use centralized sandbox paths in macOS Seatbelt
implementation by @ehedlund in
[#24984](https://github.com/google-gemini/gemini-cli/pull/24984)
- feat(cli): refine tool output formatting for compact mode by @jwhelangoog in
[#24677](https://github.com/google-gemini/gemini-cli/pull/24677)
- fix(sdk): skip broken sendStream tests to unblock nightly by @SandyTao520 in
[#25000](https://github.com/google-gemini/gemini-cli/pull/25000)
- refactor(core): use centralized path resolution for Linux sandbox by @ehedlund
in [#24985](https://github.com/google-gemini/gemini-cli/pull/24985)
- Support ctrl+shift+g by @jacob314 in
[#25035](https://github.com/google-gemini/gemini-cli/pull/25035)
- feat(core): refactor subagent tool to unified invoke_subagent tool by
@abhipatel12 in
[#24489](https://github.com/google-gemini/gemini-cli/pull/24489)
- fix(core): add explicit git identity env vars to prevent sandbox checkpointing
error by @mrpmohiburrahman in
[#19775](https://github.com/google-gemini/gemini-cli/pull/19775)
- fix: respect hideContextPercentage when FooterConfigDialog is closed without
changes by @chernistry in
[#24773](https://github.com/google-gemini/gemini-cli/pull/24773)
- fix(cli): suppress unhandled AbortError logs during request cancellation by
@euxaristia in
[#22621](https://github.com/google-gemini/gemini-cli/pull/22621)
- Automated documentation audit by @g-samroberts in
[#24567](https://github.com/google-gemini/gemini-cli/pull/24567)
- feat(cli): implement useAgentStream hook by @mbleigh in
[#24292](https://github.com/google-gemini/gemini-cli/pull/24292)
- refactor(plan) Clean default plan toml by @ruomengz in
[#25037](https://github.com/google-gemini/gemini-cli/pull/25037)
- refactor(core): remove legacy subagent wrapping tools by @abhipatel12 in
[#25053](https://github.com/google-gemini/gemini-cli/pull/25053)
- fix(core): honor retryDelay in RetryInfo for 503 errors by @yunaseoul in
[#25057](https://github.com/google-gemini/gemini-cli/pull/25057)
- fix(core): remediate subagent memory leaks using AbortSignal in MessageBus by
@abhipatel12 in
[#25048](https://github.com/google-gemini/gemini-cli/pull/25048)
- feat(cli): wire up useAgentStream in AppContainer by @mbleigh in
[#24297](https://github.com/google-gemini/gemini-cli/pull/24297)
- feat(core): migrate chat recording to JSONL streaming by @spencer426 in
[#23749](https://github.com/google-gemini/gemini-cli/pull/23749)
- fix(core): clear 5-minute timeouts in oauth flow to prevent memory leaks by
@spencer426 in
[#24968](https://github.com/google-gemini/gemini-cli/pull/24968)
- fix(sandbox): centralize async git worktree resolution and enforce read-only
security by @ehedlund in
[#25040](https://github.com/google-gemini/gemini-cli/pull/25040)
- feat(test): add high-volume shell test and refine perf harness by @sripasg in
[#24983](https://github.com/google-gemini/gemini-cli/pull/24983)
- fix(core): silently handle EPERM when listing dir structure by @scidomino in
[#25066](https://github.com/google-gemini/gemini-cli/pull/25066)
- Changelog for v0.37.1 by @gemini-cli-robot in
[#25055](https://github.com/google-gemini/gemini-cli/pull/25055)
- fix: decode Uint8Array and multi-byte UTF-8 in API error messages by
@kimjune01 in [#23341](https://github.com/google-gemini/gemini-cli/pull/23341)
- Automated documentation audit results by @g-samroberts in
[#22755](https://github.com/google-gemini/gemini-cli/pull/22755)
- debugging(ui): add optional debugRainbow setting by @jacob314 in
[#25088](https://github.com/google-gemini/gemini-cli/pull/25088)
- fix: resolve lifecycle memory leaks by cleaning up listeners and root closures
by @spencer426 in
[#24566](https://github.com/google-gemini/gemini-cli/pull/24566)
- Add an eval for and fix unsafe cloning behavior. by @gundermanc in
[#24457](https://github.com/google-gemini/gemini-cli/pull/24457)
- fix(policy): allow complete_task in plan mode by @abhipatel12 in
[#24771](https://github.com/google-gemini/gemini-cli/pull/24771)
- feat(telemetry): add browser agent clearcut metrics by @gsquared94 in
[#24688](https://github.com/google-gemini/gemini-cli/pull/24688)
- feat(cli): support selective topic expansion and click-to-expand by
@Abhijit-2592 in
[#24793](https://github.com/google-gemini/gemini-cli/pull/24793)
- temporarily disable sandbox integration test on windows by @ehedlund in
[#24786](https://github.com/google-gemini/gemini-cli/pull/24786)
- Remove flakey test by @scidomino in
[#24837](https://github.com/google-gemini/gemini-cli/pull/24837)
- Alisa/approve button by @alisa-alisa in
[#24645](https://github.com/google-gemini/gemini-cli/pull/24645)
- feat(hooks): display hook system messages in UI by @mbleigh in
[#24616](https://github.com/google-gemini/gemini-cli/pull/24616)
- fix(core): propagate BeforeModel hook model override end-to-end by @krishdef7
in [#24784](https://github.com/google-gemini/gemini-cli/pull/24784)
- chore: fix formatting for behavioral eval skill reference file by @abhipatel12
in [#24846](https://github.com/google-gemini/gemini-cli/pull/24846)
- fix: use directory junctions on Windows for skill linking by @enjoykumawat in
[#24823](https://github.com/google-gemini/gemini-cli/pull/24823)
- fix(cli): prevent multiple banner increments on remount by @sehoon38 in
[#24843](https://github.com/google-gemini/gemini-cli/pull/24843)
- feat(acp): add /help command by @sripasg in
[#24839](https://github.com/google-gemini/gemini-cli/pull/24839)
- fix(core): remove tmux alternate buffer warning by @jackwotherspoon in
[#24852](https://github.com/google-gemini/gemini-cli/pull/24852)
- Improve sandbox error matching and caching by @DavidAPierce in
[#24550](https://github.com/google-gemini/gemini-cli/pull/24550)
- feat(core): add agent protocol UI types and experimental flag by @mbleigh in
[#24275](https://github.com/google-gemini/gemini-cli/pull/24275)
- feat(core): use experiment flags for default fetch timeouts by @yunaseoul in
[#24261](https://github.com/google-gemini/gemini-cli/pull/24261)
- Revert "fix(ui): improve narration suppression and reduce flicker (#2… by
[#25049](https://github.com/google-gemini/gemini-cli/pull/25049)
- docs(cli): updates f12 description to be more precise by @JayadityaGit in
[#15816](https://github.com/google-gemini/gemini-cli/pull/15816)
- fix(cli): mark /settings as unsafe to run concurrently by @jacob314 in
[#25061](https://github.com/google-gemini/gemini-cli/pull/25061)
- fix(core): remove buffer slice to prevent OOM on large output streams by
@spencer426 in
[#25094](https://github.com/google-gemini/gemini-cli/pull/25094)
- feat(core): persist subagent agentId in tool call records by @abhipatel12 in
[#25092](https://github.com/google-gemini/gemini-cli/pull/25092)
- chore(core): increase codebase investigator turn limits to 50 by @abhipatel12
in [#25125](https://github.com/google-gemini/gemini-cli/pull/25125)
- refactor(core): consolidate execute() arguments into ExecuteOptions by
@mbleigh in [#25101](https://github.com/google-gemini/gemini-cli/pull/25101)
- feat(core): add Strategic Re-evaluation guidance to system prompt by
@aishaneeshah in
[#25062](https://github.com/google-gemini/gemini-cli/pull/25062)
- fix(core): preserve shell execution config fields on update by
@jasonmatthewsuhari in
[#25113](https://github.com/google-gemini/gemini-cli/pull/25113)
- docs: add vi shortcuts and clarify MCP sandbox setup by @chrisjcthomas in
[#21679](https://github.com/google-gemini/gemini-cli/pull/21679)
- fix(cli): pass session id to interactive shell executions by
@jasonmatthewsuhari in
[#25114](https://github.com/google-gemini/gemini-cli/pull/25114)
- fix(cli): resolve text sanitization data loss due to C1 control characters by
@euxaristia in
[#22624](https://github.com/google-gemini/gemini-cli/pull/22624)
- feat(core): add large memory regression test by @cynthialong0-0 in
[#25059](https://github.com/google-gemini/gemini-cli/pull/25059)
- fix(core): resolve PTY exhaustion and orphan MCP subprocess leaks by
@spencer426 in
[#25079](https://github.com/google-gemini/gemini-cli/pull/25079)
- chore(deps): update vulnerable dependencies via npm audit fix by @scidomino in
[#25140](https://github.com/google-gemini/gemini-cli/pull/25140)
- perf(sandbox): optimize Windows sandbox initialization via native ACL
application by @ehedlund in
[#25077](https://github.com/google-gemini/gemini-cli/pull/25077)
- chore: switch from keytar to @github/keytar by @cocosheng-g in
[#25143](https://github.com/google-gemini/gemini-cli/pull/25143)
- fix: improve audio MIME normalization and validation in file reads by
@junaiddshaukat in
[#21636](https://github.com/google-gemini/gemini-cli/pull/21636)
- docs: Update docs-audit to include changes in PR body by @g-samroberts in
[#25153](https://github.com/google-gemini/gemini-cli/pull/25153)
- docs: correct documentation for enforced authentication type by @cocosheng-g
in [#25142](https://github.com/google-gemini/gemini-cli/pull/25142)
- fix(cli): exclude update_topic from confirmation queue count by @Abhijit-2592
in [#24945](https://github.com/google-gemini/gemini-cli/pull/24945)
- Memory fix for trace's streamWrapper. by @anthraxmilkshake in
[#25089](https://github.com/google-gemini/gemini-cli/pull/25089)
- fix(core): fix quota footer for non-auto models and improve display by
@jackwotherspoon in
[#25121](https://github.com/google-gemini/gemini-cli/pull/25121)
- docs(contributing): clarify self-assignment policy for issues by @jmr in
[#23087](https://github.com/google-gemini/gemini-cli/pull/23087)
- feat(core): add skill patching support with /memory inbox integration by
@SandyTao520 in
[#25148](https://github.com/google-gemini/gemini-cli/pull/25148)
- Stop suppressing thoughts and text in model response by @gundermanc in
[#25073](https://github.com/google-gemini/gemini-cli/pull/25073)
- fix(release): prefix git hash in nightly versions to prevent semver
normalization by @SandyTao520 in
[#25304](https://github.com/google-gemini/gemini-cli/pull/25304)
- feat(cli): extract QuotaContext and resolve infinite render loop by @Adib234
in [#24959](https://github.com/google-gemini/gemini-cli/pull/24959)
- refactor(core): extract and centralize sandbox path utilities by @ehedlund in
[#25305](https://github.com/google-gemini/gemini-cli/pull/25305)
- feat(ui): added enhancements to scroll momentum by @devr0306 in
[#24447](https://github.com/google-gemini/gemini-cli/pull/24447)
- fix(core): replace custom binary detection with isbinaryfile to correctly
handle UTF-8 (U+FFFD) by @Anjaligarhwal in
[#25297](https://github.com/google-gemini/gemini-cli/pull/25297)
- feat(agent): implement tool-controlled display protocol (Steps 2-3) by
@mbleigh in [#25134](https://github.com/google-gemini/gemini-cli/pull/25134)
- Stop showing scrollbar unless we are in terminalBuffer mode by @jacob314 in
[#25320](https://github.com/google-gemini/gemini-cli/pull/25320)
- feat: support auth block in MCP servers config in agents by @TanmayVartak in
[#24770](https://github.com/google-gemini/gemini-cli/pull/24770)
- fix(core): expose GEMINI_PLANS_DIR to hook environment by @Adib234 in
[#25296](https://github.com/google-gemini/gemini-cli/pull/25296)
- feat(core): implement silent fallback for Plan Mode model routing by @jerop in
[#25317](https://github.com/google-gemini/gemini-cli/pull/25317)
- fix: correct redirect count increment in fetchJson by @KevinZhao in
[#24896](https://github.com/google-gemini/gemini-cli/pull/24896)
- fix(core): prevent secondary crash in ModelRouterService finally block by
@gundermanc in
[#24857](https://github.com/google-gemini/gemini-cli/pull/24857)
- refactor(cli): remove duplication in interactive shell awaiting input hint by
@JayadityaGit in
[#24801](https://github.com/google-gemini/gemini-cli/pull/24801)
- refactor(core): make LegacyAgentSession dependencies optional by @mbleigh in
[#24287](https://github.com/google-gemini/gemini-cli/pull/24287)
- Changelog for v0.37.0-preview.2 by @gemini-cli-robot in
[#24848](https://github.com/google-gemini/gemini-cli/pull/24848)
- fix(cli): always show shell command description or actual command by @jacob314
in [#24774](https://github.com/google-gemini/gemini-cli/pull/24774)
- Added flag for ept size and increased default size by @devr0306 in
[#24859](https://github.com/google-gemini/gemini-cli/pull/24859)
- fix(core): dispose Scheduler to prevent McpProgress listener leak by
@Anjaligarhwal in
[#24870](https://github.com/google-gemini/gemini-cli/pull/24870)
- fix(cli): switch default back to terminalBuffer=false and fix regressions
introduced for that mode by @jacob314 in
[#24873](https://github.com/google-gemini/gemini-cli/pull/24873)
- feat(cli): switch to ctrl+g from ctrl-x by @jacob314 in
[#24861](https://github.com/google-gemini/gemini-cli/pull/24861)
- fix: isolate concurrent browser agent instances by @gsquared94 in
[#24794](https://github.com/google-gemini/gemini-cli/pull/24794)
- docs: update MCP server OAuth redirect port documentation by @adamfweidman in
[#24844](https://github.com/google-gemini/gemini-cli/pull/24844)
[#25333](https://github.com/google-gemini/gemini-cli/pull/25333)
- feat(core): introduce decoupled ContextManager and Sidecar architecture by
@joshualitt in
[#24752](https://github.com/google-gemini/gemini-cli/pull/24752)
- docs(core): update generalist agent documentation by @abhipatel12 in
[#25325](https://github.com/google-gemini/gemini-cli/pull/25325)
- chore(mcp): check MCP error code over brittle string match by @jackwotherspoon
in [#25381](https://github.com/google-gemini/gemini-cli/pull/25381)
- feat(plan): update plan mode prompt to allow showing plan content by @ruomengz
in [#25058](https://github.com/google-gemini/gemini-cli/pull/25058)
- test(core): improve sandbox integration test coverage and fix OS-specific
failures by @ehedlund in
[#25307](https://github.com/google-gemini/gemini-cli/pull/25307)
- fix(core): use debug level for keychain fallback logging by @ehedlund in
[#25398](https://github.com/google-gemini/gemini-cli/pull/25398)
- feat(test): add a performance test in asian language by @cynthialong0-0 in
[#25392](https://github.com/google-gemini/gemini-cli/pull/25392)
- feat(cli): enable mouse clicking for cursor positioning in AskUser multi-line
answers by @Adib234 in
[#24630](https://github.com/google-gemini/gemini-cli/pull/24630)
- fix(core): detect kmscon terminal as supporting true color by @claygeo in
[#25282](https://github.com/google-gemini/gemini-cli/pull/25282)
- ci: add agent session drift check workflow by @adamfweidman in
[#25389](https://github.com/google-gemini/gemini-cli/pull/25389)
- use macos-latest-large runner where applicable. by @scidomino in
[#25413](https://github.com/google-gemini/gemini-cli/pull/25413)
- Changelog for v0.37.2 by @gemini-cli-robot in
[#25336](https://github.com/google-gemini/gemini-cli/pull/25336)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.37.0-preview.2...v0.38.0-preview.0
https://github.com/google-gemini/gemini-cli/compare/v0.38.0-preview.0...v0.39.0-preview.0
+15 -328
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"workspaces": [
"packages/*"
],
@@ -1727,29 +1727,6 @@
"node": ">=8"
}
},
"node_modules/@joshua.litt/get-ripgrep": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/@joshua.litt/get-ripgrep/-/get-ripgrep-0.0.3.tgz",
"integrity": "sha512-rycdieAKKqXi2bsM7G2ayDiNk5CAX8ZOzsTQsirfOqUKPef04Xw40BWGGyimaOOuvPgLWYt3tPnLLG3TvPXi5Q==",
"license": "MIT",
"dependencies": {
"@lvce-editor/verror": "^1.6.0",
"execa": "^9.5.2",
"extract-zip": "^2.0.1",
"fs-extra": "^11.3.0",
"got": "^14.4.5",
"path-exists": "^5.0.0",
"xdg-basedir": "^5.1.0"
}
},
"node_modules/@joshua.litt/get-ripgrep/node_modules/path-exists": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
"integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1932,12 +1909,6 @@
"integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
"license": "MIT"
},
"node_modules/@lvce-editor/verror": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@lvce-editor/verror/-/verror-1.7.0.tgz",
"integrity": "sha512-+LGuAEIC2L7pbvkyAQVWM2Go0dAy+UWEui28g07zNtZsCBhm+gusBK8PNwLJLV5Jay+TyUYuwLIbJdjLLzqEBg==",
"license": "MIT"
},
"node_modules/@lydell/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz",
@@ -3590,18 +3561,6 @@
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/@sindresorhus/is": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.0.2.tgz",
"integrity": "sha512-d9xRovfKNz1SKieM0qJdO+PQonjnnIfSNWfHYnBSJ9hkjm0ZPw6HlxscDXYstp3z+7V2GOFHc+J0CYrYTjqCJw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
@@ -3614,18 +3573,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@szmarczak/http-timer": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz",
"integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==",
"license": "MIT",
"dependencies": {
"defer-to-connect": "^2.0.1"
},
"engines": {
"node": ">=14.16"
}
},
"node_modules/@textlint/ast-node-types": {
"version": "15.2.2",
"resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.2.2.tgz",
@@ -3931,12 +3878,6 @@
"integrity": "sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ==",
"license": "MIT"
},
"node_modules/@types/http-cache-semantics": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
"integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==",
"license": "MIT"
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
@@ -6008,33 +5949,6 @@
"node": ">=8"
}
},
"node_modules/cacheable-lookup": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
"integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==",
"license": "MIT",
"engines": {
"node": ">=14.16"
}
},
"node_modules/cacheable-request": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-12.0.1.tgz",
"integrity": "sha512-Yo9wGIQUaAfIbk+qY0X4cDQgCosecfBe3V9NSyeY4qPC2SAkbCS4Xj79VP8WOzitpJUZKc/wsRCYF5ariDIwkg==",
"license": "MIT",
"dependencies": {
"@types/http-cache-semantics": "^4.0.4",
"get-stream": "^9.0.1",
"http-cache-semantics": "^4.1.1",
"keyv": "^4.5.4",
"mimic-response": "^4.0.0",
"normalize-url": "^8.0.1",
"responselike": "^3.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -6968,33 +6882,6 @@
}
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/decompress-response/node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@@ -7057,15 +6944,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/defer-to-connect": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@@ -8428,9 +8306,9 @@
}
},
"node_modules/execa": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz",
"integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==",
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
"integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==",
"license": "MIT",
"dependencies": {
"@sindresorhus/merge-streams": "^4.0.0",
@@ -8950,15 +8828,6 @@
"node": ">= 6"
}
},
"node_modules/form-data-encoder": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.1.0.tgz",
"integrity": "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==",
"license": "MIT",
"engines": {
"node": ">= 18"
}
},
"node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
@@ -9586,43 +9455,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/got": {
"version": "14.4.8",
"resolved": "https://registry.npmjs.org/got/-/got-14.4.8.tgz",
"integrity": "sha512-vxwU4HuR0BIl+zcT1LYrgBjM+IJjNElOjCzs0aPgHorQyr/V6H6Y73Sn3r3FOlUffvWD+Q5jtRuGWaXkU8Jbhg==",
"license": "MIT",
"dependencies": {
"@sindresorhus/is": "^7.0.1",
"@szmarczak/http-timer": "^5.0.1",
"cacheable-lookup": "^7.0.0",
"cacheable-request": "^12.0.1",
"decompress-response": "^6.0.0",
"form-data-encoder": "^4.0.2",
"http2-wrapper": "^2.2.1",
"lowercase-keys": "^3.0.0",
"p-cancelable": "^4.0.1",
"responselike": "^3.0.0",
"type-fest": "^4.26.1"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sindresorhus/got?sponsor=1"
}
},
"node_modules/got/node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -9878,12 +9710,6 @@
"entities": "^4.4.0"
}
},
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
"license": "BSD-2-Clause"
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -9917,19 +9743,6 @@
"node": ">= 14"
}
},
"node_modules/http2-wrapper": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz",
"integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==",
"license": "MIT",
"dependencies": {
"quick-lru": "^5.1.1",
"resolve-alpn": "^1.2.0"
},
"engines": {
"node": ">=10.19.0"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@@ -11040,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": {
@@ -11235,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"
@@ -11699,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",
@@ -11981,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",
@@ -12371,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",
@@ -12895,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",
@@ -13743,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",
@@ -14195,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",
@@ -14235,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",
@@ -17644,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",
@@ -17864,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",
@@ -17979,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",
@@ -18079,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",
@@ -18151,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",
@@ -18162,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",
@@ -18190,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",
@@ -18418,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"
@@ -18433,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",
@@ -18450,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",
@@ -18468,7 +18155,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.39.0-nightly.20260408.e77b22e63"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.40.0-nightly.20260414.g5b1f7375a"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -27,7 +27,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.39.0-nightly.20260408.e77b22e63"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.40.0-nightly.20260414.g5b1f7375a"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
+16 -11
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { ApiAuthDialog } from './ApiAuthDialog.js';
@@ -40,11 +40,16 @@ vi.mock('../components/shared/text-buffer.js', async (importOriginal) => {
};
});
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: vi.fn(() => ({
terminalWidth: 80,
})),
}));
vi.mock('../contexts/UIStateContext.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../contexts/UIStateContext.js')>();
return {
...actual,
useUIState: vi.fn(() => ({
terminalWidth: 80,
})),
};
});
const mockedUseKeypress = useKeypress as Mock;
const mockedUseTextBuffer = useTextBuffer as Mock;
@@ -73,7 +78,7 @@ describe('ApiAuthDialog', () => {
});
it('renders correctly', async () => {
const { lastFrame, unmount } = await render(
const { lastFrame, unmount } = await renderWithProviders(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
expect(lastFrame()).toMatchSnapshot();
@@ -81,7 +86,7 @@ describe('ApiAuthDialog', () => {
});
it('renders with a defaultValue', async () => {
const { unmount } = await render(
const { unmount } = await renderWithProviders(
<ApiAuthDialog
onSubmit={onSubmit}
onCancel={onCancel}
@@ -111,7 +116,7 @@ describe('ApiAuthDialog', () => {
'calls $expectedCall.name when $keyName is pressed',
async ({ keyName, sequence, expectedCall, args }) => {
mockBuffer.text = 'submitted-key'; // Set for the onSubmit case
const { unmount } = await render(
const { unmount } = await renderWithProviders(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
// calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler)
@@ -133,7 +138,7 @@ describe('ApiAuthDialog', () => {
);
it('displays an error message', async () => {
const { lastFrame, unmount } = await render(
const { lastFrame, unmount } = await renderWithProviders(
<ApiAuthDialog
onSubmit={onSubmit}
onCancel={onCancel}
@@ -146,7 +151,7 @@ describe('ApiAuthDialog', () => {
});
it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => {
const { unmount } = await render(
const { unmount } = await renderWithProviders(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
// Call 0 is ApiAuthDialog (isActive: true)
@@ -13,7 +13,8 @@ import {
useReducer,
useContext,
} from 'react';
import { Box, Text } from 'ink';
import { Box, Text, type DOMElement } from 'ink';
import { useMouseClick } from '../hooks/useMouseClick.js';
import { theme } from '../semantic-colors.js';
import { checkExhaustive, type Question } from '@google/gemini-cli-core';
import { BaseSelectionList } from './shared/BaseSelectionList.js';
@@ -85,6 +86,24 @@ function autoBoldIfPlain(text: string): string {
return text;
}
const ClickableCheckbox: React.FC<{
isChecked: boolean;
onClick: () => void;
}> = ({ isChecked, onClick }) => {
const ref = useRef<DOMElement>(null);
useMouseClick(ref, () => {
onClick();
});
return (
<Box ref={ref}>
<Text color={isChecked ? theme.status.success : theme.text.secondary}>
[{isChecked ? 'x' : ' '}]
</Text>
</Box>
);
};
interface AskUserDialogState {
answers: { [key: string]: string };
isEditingCustomOption: boolean;
@@ -919,13 +938,14 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
return (
<Box flexDirection="row">
{showCheck && (
<Text
color={
isChecked ? theme.status.success : theme.text.secondary
}
>
[{isChecked ? 'x' : ' '}]
</Text>
<ClickableCheckbox
isChecked={isChecked}
onClick={() => {
if (!context.isSelected) {
handleSelect(optionItem);
}
}}
/>
)}
<Text color={theme.text.primary}> </Text>
<TextInput
@@ -966,13 +986,14 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
<Box flexDirection="column">
<Box flexDirection="row">
{showCheck && (
<Text
color={
isChecked ? theme.status.success : theme.text.secondary
}
>
[{isChecked ? 'x' : ' '}]
</Text>
<ClickableCheckbox
isChecked={isChecked}
onClick={() => {
if (!context.isSelected) {
handleSelect(optionItem);
}
}}
/>
)}
<Text color={labelColor} bold={optionItem.type === 'done'}>
{' '}
@@ -0,0 +1,146 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders, cleanup } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { type UIState } from '../contexts/UIStateContext.js';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import chalk from 'chalk';
import { isLowColorDepth } from '../utils/terminalUtils.js';
import {
setupInputPromptTest,
TestInputPrompt,
type TestInputPromptProps,
} from './InputPrompt.test.helpers.js';
import '../../test-utils/customMatchers.js';
vi.mock('../utils/terminalUtils.js', () => ({
isLowColorDepth: vi.fn(() => false),
}));
// Mock ink BEFORE importing components that use it to intercept terminalCursorPosition
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
Text: vi.fn(({ children, ...props }) => (
<actual.Text {...props}>{children}</actual.Text>
)),
};
});
describe('InputPrompt - Background Color Styles', () => {
let props: TestInputPromptProps;
beforeEach(() => {
const setup = setupInputPromptTest();
props = setup.props;
vi.mocked(isLowColorDepth).mockReturnValue(false);
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
cleanup();
});
it('should render with background color by default', async () => {
const { stdout, unmount } = await renderWithProviders(
<TestInputPrompt {...props} />,
);
await waitFor(() => {
const frame = stdout.lastFrameRaw();
expect(frame).toContain('▀');
expect(frame).toContain('▄');
});
unmount();
});
it.each([
{ color: 'black', name: 'black' },
{ color: '#000000', name: '#000000' },
{ color: '#000', name: '#000' },
{ color: 'white', name: 'white' },
{ color: '#ffffff', name: '#ffffff' },
{ color: '#fff', name: '#fff' },
])(
'should render with safe grey background but NO side borders in 8-bit mode when background is $name',
async ({ color }) => {
vi.mocked(isLowColorDepth).mockReturnValue(true);
const { stdout, unmount } = await renderWithProviders(
<TestInputPrompt {...props} />,
{
uiState: {
terminalBackgroundColor: color,
} as Partial<UIState>,
},
);
const isWhite =
color === 'white' || color === '#ffffff' || color === '#fff';
const expectedBgColor = isWhite ? '#eeeeee' : '#1c1c1c';
await waitFor(() => {
const frame = stdout.lastFrameRaw();
// Use chalk to get the expected background color escape sequence
const bgCheck = chalk.bgHex(expectedBgColor)(' ');
const bgCode = bgCheck.substring(0, bgCheck.indexOf(' '));
// Background color code should be present
expect(frame).toContain(bgCode);
// Background characters should be rendered
expect(frame).toContain('▀');
expect(frame).toContain('▄');
// Side borders should STILL be removed
expect(frame).not.toContain('│');
});
unmount();
},
);
it('should NOT render with background color but SHOULD render horizontal lines when color depth is < 24 and background is NOT black', async () => {
vi.mocked(isLowColorDepth).mockReturnValue(true);
const { stdout, unmount } = await renderWithProviders(
<TestInputPrompt {...props} />,
{
uiState: {
terminalBackgroundColor: '#333333',
} as Partial<UIState>,
},
);
await waitFor(() => {
const frame = stdout.lastFrameRaw();
expect(frame).not.toContain('▀');
expect(frame).not.toContain('▄');
// It SHOULD have horizontal fallback lines
expect(frame).toContain('─');
});
unmount();
});
it('should fallback to lines if getUseBackgroundColor returns false', async () => {
props.config.getUseBackgroundColor = () => false;
const { stdout, unmount } = await renderWithProviders(
<TestInputPrompt {...props} />,
);
await waitFor(() => {
const frame = stdout.lastFrameRaw();
expect(frame).not.toContain('▀');
expect(frame).not.toContain('▄');
// It SHOULD have horizontal fallback lines
expect(frame).toContain('─');
});
unmount();
});
});
@@ -0,0 +1,193 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders, cleanup } from '../../test-utils/render.js';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
setupInputPromptTest,
TestInputPrompt,
type TestInputPromptProps,
} from './InputPrompt.test.helpers.js';
import { type TextBuffer } from './shared/text-buffer.js';
import '../../test-utils/customMatchers.js';
vi.mock('../utils/terminalUtils.js', () => ({
isLowColorDepth: vi.fn(() => false),
}));
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
Text: vi.fn(({ children, ...props }) => (
<actual.Text {...props}>{children}</actual.Text>
)),
};
});
describe('InputPrompt - Highlighting and Cursor Display', () => {
let props: TestInputPromptProps;
let mockBuffer: TextBuffer;
beforeEach(() => {
const setup = setupInputPromptTest();
props = setup.props;
mockBuffer = setup.mockBuffer;
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
cleanup();
});
describe('single-line scenarios', () => {
it.each([
{
name: 'at end of text',
text: 'hello',
visualCursor: [0, 5],
},
{
name: 'at beginning of text',
text: 'hello',
visualCursor: [0, 0],
},
{
name: 'in middle of text',
text: 'hello',
visualCursor: [0, 2],
},
{
name: 'empty string',
text: '',
visualCursor: [0, 0],
},
])(
'should display cursor correctly $name',
async ({ text, visualCursor }) => {
mockBuffer.text = text;
mockBuffer.lines = [text];
mockBuffer.allVisualLines = [text];
mockBuffer.viewportVisualLines = [text];
mockBuffer.visualCursor = visualCursor as [number, number];
mockBuffer.visualToLogicalMap = [[0, 0]];
props.config.getUseBackgroundColor = () => true;
const renderResult = await renderWithProviders(
<TestInputPrompt {...props} />,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
},
);
it('should display cursor correctly over full-width character', async () => {
const text = 'こんにちは';
mockBuffer.text = text;
mockBuffer.lines = [text];
mockBuffer.allVisualLines = [text];
mockBuffer.viewportVisualLines = [text];
mockBuffer.visualCursor = [0, 1]; // On 'ん'
mockBuffer.visualToLogicalMap = [[0, 0]];
props.config.getUseBackgroundColor = () => true;
const renderResult = await renderWithProviders(
<TestInputPrompt {...props} />,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
it('should display cursor correctly over emoji', async () => {
const text = 'hello🚀world';
mockBuffer.text = text;
mockBuffer.lines = [text];
mockBuffer.allVisualLines = [text];
mockBuffer.viewportVisualLines = [text];
mockBuffer.visualCursor = [0, 5]; // On '🚀'
mockBuffer.visualToLogicalMap = [[0, 0]];
props.config.getUseBackgroundColor = () => true;
const renderResult = await renderWithProviders(
<TestInputPrompt {...props} />,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
});
describe('multi-line scenarios', () => {
it.each([
{
name: 'at end of first line',
text: 'line 1\nline 2',
visualCursor: [0, 6],
visualToLogicalMap: [
[0, 0],
[1, 0],
],
},
{
name: 'at beginning of second line',
text: 'line 1\nline 2',
visualCursor: [1, 0],
visualToLogicalMap: [
[0, 0],
[1, 0],
],
},
])(
'should display cursor correctly $name in a multiline block',
async ({ text, visualCursor, visualToLogicalMap }) => {
mockBuffer.text = text;
mockBuffer.lines = text.split('\n');
mockBuffer.allVisualLines = text.split('\n');
mockBuffer.viewportVisualLines = text.split('\n');
mockBuffer.visualCursor = visualCursor as [number, number];
mockBuffer.visualToLogicalMap = visualToLogicalMap as Array<
[number, number]
>;
props.config.getUseBackgroundColor = () => true;
const renderResult = await renderWithProviders(
<TestInputPrompt {...props} />,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
},
);
it('should display cursor on a blank line in a multiline block', async () => {
const text = 'first line\n\nthird line';
mockBuffer.text = text;
mockBuffer.lines = text.split('\n');
mockBuffer.allVisualLines = text.split('\n');
mockBuffer.viewportVisualLines = text.split('\n');
mockBuffer.visualCursor = [1, 0]; // cursor on the blank line
mockBuffer.visualToLogicalMap = [
[0, 0],
[1, 0],
[2, 0],
];
props.config.getUseBackgroundColor = () => true;
const renderResult = await renderWithProviders(
<TestInputPrompt {...props} />,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
});
});
@@ -0,0 +1,137 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders, cleanup } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
setupInputPromptTest,
TestInputPrompt,
type TestInputPromptProps,
} from './InputPrompt.test.helpers.js';
import { type TextBuffer } from './shared/text-buffer.js';
import '../../test-utils/customMatchers.js';
vi.mock('../utils/terminalUtils.js', () => ({
isLowColorDepth: vi.fn(() => false),
}));
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
Text: vi.fn(({ children, ...props }) => (
<actual.Text {...props}>{children}</actual.Text>
)),
};
});
describe('InputPrompt - IME Cursor Support', () => {
let props: TestInputPromptProps;
let mockBuffer: TextBuffer;
beforeEach(() => {
const setup = setupInputPromptTest();
props = setup.props;
mockBuffer = setup.mockBuffer;
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
cleanup();
});
describe('terminalCursorPosition', () => {
it.each([
{
name: 'empty buffer',
text: '',
visualCursor: [0, 0],
expectedCol: 0,
},
{
name: 'ASCII text at start',
text: 'hello',
visualCursor: [0, 0],
expectedCol: 0,
},
{
name: 'ASCII text at end',
text: 'hello',
visualCursor: [0, 5],
expectedCol: 5,
},
{
name: 'Japanese text with full-width characters (こんにちは)',
text: 'こんにちは',
visualCursor: [0, 5],
expectedCol: 5,
},
{
name: 'Mixed ASCII and CJK text (aこbんc)',
text: 'aこbんc',
visualCursor: [0, 5],
expectedCol: 5,
},
{
name: 'Emoji (👩‍💻)',
text: '👩‍💻',
visualCursor: [0, 5],
expectedCol: 5,
},
{
name: 'Korean text (テスト)',
text: 'テスト',
visualCursor: [0, 3],
expectedCol: 3,
},
])(
'should set terminalCursorPosition correctly for $name',
async ({ text, visualCursor, expectedCol }) => {
mockBuffer.text = text;
mockBuffer.lines = [text];
mockBuffer.allVisualLines = [text];
mockBuffer.viewportVisualLines = [text];
mockBuffer.visualCursor = visualCursor as [number, number];
mockBuffer.visualToLogicalMap = [[0, 0]];
// Avoid rendering backgrounds which might complicate the snapshot/component tree
props.config.getUseBackgroundColor = () => false;
const { stdout, unmount } = await renderWithProviders(
<TestInputPrompt {...props} />,
);
// We can't easily extract props from the rendered ink tree in tests without a custom test renderer.
// However, if the component passes terminalCursorPosition down, Ink will handle it.
// In our mock, we just render <Text>.
// To verify the prop was passed, we need to inspect the mocked Text component's calls.
// Since we mock 'ink' at the top level, we can check if Text was called with terminalCursorPosition.
await waitFor(() => {
// Ensure it rendered
expect(stdout.lastFrameRaw()).not.toBe('');
});
const ink = await import('ink');
const textMock = ink.Text as unknown as ReturnType<typeof vi.fn>;
// Find the call to Text that has terminalCursorFocus: true
const focusCall = textMock.mock.calls.find(
(call) => call[0].terminalCursorFocus === true,
);
expect(focusCall).toBeDefined();
if (focusCall) {
expect(focusCall[0].terminalCursorPosition).toBe(expectedCol);
}
unmount();
},
);
});
});
@@ -0,0 +1,122 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders, cleanup } from '../../test-utils/render.js';
import { type UIState } from '../contexts/UIStateContext.js';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
setupInputPromptTest,
TestInputPrompt,
type TestInputPromptProps,
} from './InputPrompt.test.helpers.js';
import '../../test-utils/customMatchers.js';
import { ApprovalMode } from '@google/gemini-cli-core';
vi.mock('../utils/terminalUtils.js', () => ({
isLowColorDepth: vi.fn(() => false),
}));
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
Text: vi.fn(({ children, ...props }) => (
<actual.Text {...props}>{children}</actual.Text>
)),
};
});
describe('InputPrompt - Snapshots', () => {
let props: TestInputPromptProps;
beforeEach(() => {
const setup = setupInputPromptTest();
props = setup.props;
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
cleanup();
});
describe('snapshots', () => {
it.each([
{ name: 'dark', color: '#123456', mode: ApprovalMode.DEFAULT },
{ name: 'light', color: '#fff', mode: ApprovalMode.DEFAULT },
{ name: 'plan', color: 'black', mode: ApprovalMode.PLAN },
{ name: 'yolo', color: 'black', mode: ApprovalMode.YOLO },
])('should render correctly for $name mode', async ({ color, mode }) => {
props.approvalMode = mode;
const renderResult = await renderWithProviders(
<TestInputPrompt {...props} />,
{
uiState: {
terminalBackgroundColor: color,
} as Partial<UIState>,
},
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
it('renders with multiline text', async () => {
props.buffer.text = 'line1\nline2';
props.buffer.lines = ['line1', 'line2'];
props.buffer.allVisualLines = ['line1', 'line2'];
props.buffer.viewportVisualLines = ['line1', 'line2'];
props.buffer.visualCursor = [1, 5];
props.buffer.visualToLogicalMap = [
[0, 0],
[1, 0],
];
const renderResult = await renderWithProviders(
<TestInputPrompt {...props} />,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
it('renders correctly with shell mode active', async () => {
props.shellModeActive = true;
props.buffer.text = 'npm run dev';
props.buffer.lines = ['npm run dev'];
props.buffer.allVisualLines = ['npm run dev'];
props.buffer.viewportVisualLines = ['npm run dev'];
props.buffer.visualCursor = [0, 11];
const renderResult = await renderWithProviders(
<TestInputPrompt {...props} />,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
it('renders correctly with copy mode enabled', async () => {
props.copyModeEnabled = true;
props.buffer.text = 'copy me';
props.buffer.lines = ['copy me'];
props.buffer.allVisualLines = ['copy me'];
props.buffer.viewportVisualLines = ['copy me'];
props.buffer.visualCursor = [0, 7];
const renderResult = await renderWithProviders(
<TestInputPrompt {...props} />,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
});
});
@@ -0,0 +1,266 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useMemo } from 'react';
import { vi } from 'vitest';
import { ApprovalMode, coreEvents, type Config } from '@google/gemini-cli-core';
import * as path from 'node:path';
import { CommandKind, type SlashCommand } from '../commands/types.js';
import { StreamingState } from '../types.js';
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
import { type TextBuffer } from './shared/text-buffer.js';
import { cpLen } from '../utils/textUtils.js';
import { InputContext } from '../contexts/InputContext.js';
import { InputPrompt, type InputPromptProps } from './InputPrompt.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
export type TestInputPromptProps = InputPromptProps & {
buffer: TextBuffer;
userMessages: string[];
shellModeActive: boolean;
copyModeEnabled?: boolean;
showEscapePrompt?: boolean;
inputWidth: number;
suggestionsWidth: number;
};
export const TestInputPrompt = (props: TestInputPromptProps) => {
const contextValue = useMemo(
() => ({
buffer: props.buffer,
userMessages: props.userMessages,
shellModeActive: props.shellModeActive,
copyModeEnabled: props.copyModeEnabled,
showEscapePrompt: props.showEscapePrompt || false,
inputWidth: props.inputWidth,
suggestionsWidth: props.suggestionsWidth,
}),
[
props.buffer,
props.userMessages,
props.shellModeActive,
props.copyModeEnabled,
props.showEscapePrompt,
props.inputWidth,
props.suggestionsWidth,
],
);
return (
<InputContext.Provider value={contextValue}>
<InputPrompt {...props} />
</InputContext.Provider>
);
};
export const mockSlashCommands: SlashCommand[] = [
{
name: 'stats',
description: 'Check stats',
kind: CommandKind.BUILT_IN,
isSafeConcurrent: true,
},
{
name: 'clear',
kind: CommandKind.BUILT_IN,
description: 'Clear screen',
action: vi.fn(),
},
{
name: 'memory',
kind: CommandKind.BUILT_IN,
description: 'Manage memory',
subCommands: [
{
name: 'show',
kind: CommandKind.BUILT_IN,
description: 'Show memory',
action: vi.fn(),
},
{
name: 'add',
kind: CommandKind.BUILT_IN,
description: 'Add to memory',
action: vi.fn(),
},
{
name: 'refresh',
kind: CommandKind.BUILT_IN,
description: 'Refresh memory',
action: vi.fn(),
},
],
},
{
name: 'chat',
description: 'Manage chats',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'resume',
description: 'Resume a chat',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
completion: async () => ['fix-foo', 'fix-bar'],
},
],
},
{
name: 'resume',
description: 'Browse and resume sessions',
kind: CommandKind.BUILT_IN,
action: vi.fn(),
},
];
export function setupInputPromptTest() {
vi.resetAllMocks();
coreEvents.removeAllListeners();
vi.spyOn(terminalCapabilityManager, 'isKittyProtocolEnabled').mockReturnValue(
true,
);
const mockCommandContext = createMockCommandContext();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const mockBuffer = {
text: '',
cursor: [0, 0],
lines: [''],
setText: vi.fn(
(newText: string, cursorPosition?: 'start' | 'end' | number) => {
mockBuffer.text = newText;
mockBuffer.lines = newText.split('\n');
let col = 0;
if (typeof cursorPosition === 'number') {
col = cursorPosition;
} else if (cursorPosition === 'start') {
col = 0;
} else {
col = newText.length;
}
mockBuffer.cursor = [0, col];
mockBuffer.allVisualLines = newText.split('\n');
mockBuffer.viewportVisualLines = newText.split('\n');
mockBuffer.visualToLogicalMap = newText
.split('\n')
.map((_, i) => [i, 0] as [number, number]);
mockBuffer.visualCursor = [0, col];
mockBuffer.visualScrollRow = 0;
mockBuffer.viewportHeight = 10;
mockBuffer.visualToTransformedMap = newText
.split('\n')
.map((_, i) => i);
mockBuffer.transformationsByLine = newText.split('\n').map(() => []);
},
),
replaceRangeByOffset: vi.fn(),
viewportVisualLines: [''],
allVisualLines: [''],
visualCursor: [0, 0],
visualScrollRow: 0,
viewportHeight: 10,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
handleInput: vi.fn((key: any) => {
if (key.name === 'c' && key.ctrl) {
if (mockBuffer.text.length > 0) {
mockBuffer.setText('');
return true;
}
return false;
}
return false;
}),
move: vi.fn((dir: string) => {
if (dir === 'home') {
mockBuffer.visualCursor = [mockBuffer.visualCursor[0], 0];
} else if (dir === 'end') {
const line =
mockBuffer.allVisualLines[mockBuffer.visualCursor[0]] || '';
mockBuffer.visualCursor = [mockBuffer.visualCursor[0], cpLen(line)];
}
}),
moveToOffset: vi.fn((offset: number) => {
mockBuffer.cursor = [0, offset];
}),
moveToVisualPosition: vi.fn(),
killLineRight: vi.fn(),
killLineLeft: vi.fn(),
openInExternalEditor: vi.fn(),
newline: vi.fn(),
undo: vi.fn(),
redo: vi.fn(),
backspace: vi.fn(),
preferredCol: null,
selectionAnchor: null,
insert: vi.fn(),
del: vi.fn(),
replaceRange: vi.fn(),
deleteWordLeft: vi.fn(),
deleteWordRight: vi.fn(),
visualToLogicalMap: [[0, 0]],
visualToTransformedMap: [0],
transformationsByLine: [],
getOffset: vi.fn().mockReturnValue(0),
pastedContent: {},
} as unknown as TextBuffer;
const props: TestInputPromptProps = {
onQueueMessage: vi.fn(),
buffer: mockBuffer,
onSubmit: vi.fn(),
userMessages: [],
onClearScreen: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
config: {
getProjectRoot: () => path.join('test', 'project'),
getTargetDir: () => path.join('test', 'project', 'src'),
getVimMode: () => false,
getUseBackgroundColor: () => true,
getUseTerminalBuffer: () => false,
getTerminalBackground: () => undefined,
getWorkspaceContext: () => ({
getDirectories: () => ['/test/project/src'],
onDirectoriesChanged: vi.fn(),
}),
} as unknown as Config,
slashCommands: mockSlashCommands,
commandContext: mockCommandContext,
shellModeActive: false,
setShellModeActive: vi.fn(),
approvalMode: ApprovalMode.DEFAULT,
inputWidth: 80,
suggestionsWidth: 80,
focus: true,
setQueueErrorMessage: vi.fn(),
streamingState: StreamingState.Idle,
setBannerVisible: vi.fn(),
};
const uiActions = {
setEmbeddedShellFocused: vi.fn(),
setCleanUiDetailsVisible: vi.fn(),
toggleCleanUiDetailsVisible: vi.fn(),
revealCleanUiDetailsTemporarily: vi.fn(),
addMessage: vi.fn(),
};
return {
mockBuffer,
props,
uiActions,
mockCommandContext,
};
}
export function createMockMocks() {
return {
mockSetEmbeddedShellFocused: vi.fn(),
mockSetCleanUiDetailsVisible: vi.fn(),
mockToggleCleanUiDetailsVisible: vi.fn(),
mockRevealCleanUiDetailsTemporarily: vi.fn(),
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="88" viewBox="0 0 920 88">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="88" fill="#000000" />
<g transform="translate(10, 10)">
<rect x="0" y="0" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="2" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="17" width="9" height="17" fill="#1f1f1f" />
<rect x="9" y="17" width="18" height="17" fill="#1f1f1f" />
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="17" width="54" height="17" fill="#1f1f1f" />
<text x="27" y="19" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line 1</text>
<rect x="81" y="17" width="819" height="17" fill="#1f1f1f" />
<rect x="0" y="34" width="27" height="17" fill="#1f1f1f" />
<rect x="27" y="34" width="9" height="17" fill="#ffffff" />
<text x="27" y="36" fill="#1f1f1f" textLength="9" lengthAdjust="spacingAndGlyphs">l</text>
<rect x="36" y="34" width="45" height="17" fill="#1f1f1f" />
<text x="36" y="36" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">ine 2</text>
<rect x="81" y="34" width="819" height="17" fill="#1f1f1f" />
<rect x="0" y="51" width="900" height="17" fill="#1f1f1f" />
<text x="0" y="53" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

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

After

Width:  |  Height:  |  Size: 2.0 KiB

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

After

Width:  |  Height:  |  Size: 2.2 KiB

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

After

Width:  |  Height:  |  Size: 1.8 KiB

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

After

Width:  |  Height:  |  Size: 1.7 KiB

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

After

Width:  |  Height:  |  Size: 1.8 KiB

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

After

Width:  |  Height:  |  Size: 2.0 KiB

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

After

Width:  |  Height:  |  Size: 2.0 KiB

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

After

Width:  |  Height:  |  Size: 2.0 KiB

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

After

Width:  |  Height:  |  Size: 1.7 KiB

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

After

Width:  |  Height:  |  Size: 1.7 KiB

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

After

Width:  |  Height:  |  Size: 2.0 KiB

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

After

Width:  |  Height:  |  Size: 1.8 KiB

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

After

Width:  |  Height:  |  Size: 1.8 KiB

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

After

Width:  |  Height:  |  Size: 1.8 KiB

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

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,44 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`InputPrompt - Snapshots > snapshots > renders correctly with copy mode enabled 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> copy me
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt - Snapshots > snapshots > renders correctly with shell mode active 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
! npm run dev
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt - Snapshots > snapshots > renders with multiline text 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> line1
line2
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt - Snapshots > snapshots > should render correctly for 'dark' mode 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt - Snapshots > snapshots > should render correctly for 'light' mode 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt - Snapshots > snapshots > should render correctly for 'plan' mode 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt - Snapshots > snapshots > should render correctly for 'yolo' mode 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
* Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
@@ -1,95 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'at the beginning of a line' in a multiline block 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > first line │
│ second line │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'at the end of a line' in a multiline block 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > first line │
│ second line │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'in the middle of a line' in a multiline block 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > first line │
│ second line │
│ third line │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor on a blank line in a multiline block 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > first line │
│ │
│ third line │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'after multi-byte unicode characters' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > 👍A │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the beginning of the line' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > hello │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of a line with unicode cha…' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > hello 👍 │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of a short line with unico…' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > 👍 │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of the line' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > hello │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'for multi-byte unicode characters' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > hello 👍 world │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'mid-word' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > hello world │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on a highlighted token' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > run @path/to/file │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on a space between words' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > hello world │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on an empty line' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > Type your message or @path/to/file │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > History Navigation and Completion Suppression > should not render suggestions during history navigation 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> second message
@@ -175,30 +85,3 @@ exports[`InputPrompt > multiline rendering > should correctly render multiline i
│ world │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
! Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
* Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
@@ -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 =
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -20,7 +20,8 @@
"typecheck": "tsc --noEmit"
},
"files": [
"dist"
"dist",
"vendor"
],
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -31,7 +32,6 @@
"@google/genai": "1.30.0",
"@grpc/grpc-js": "^1.14.3",
"@iarna/toml": "^2.2.5",
"@joshua.litt/get-ripgrep": "^0.0.3",
"@modelcontextprotocol/sdk": "^1.23.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.211.0",
@@ -59,6 +59,7 @@
"diff": "^8.0.3",
"dotenv": "^17.2.4",
"dotenv-expand": "^12.0.3",
"execa": "^9.6.1",
"fast-levenshtein": "^2.0.6",
"fdir": "^6.4.6",
"fzf": "^0.5.2",
+1
View File
@@ -3552,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)),
@@ -519,4 +519,70 @@ describe('GeminiChat Network Retries', () => {
}),
);
});
it('should retry on OpenSSL 3.x SSL error during stream iteration (ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC)', async () => {
// OpenSSL 3.x produces a different error code format than OpenSSL 1.x
const sslError = new Error(
'request to https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent failed',
) as NodeJS.ErrnoException & { type?: string };
sslError.type = 'system';
sslError.errno =
'ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC' as unknown as number;
sslError.code = 'ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC';
vi.mocked(mockContentGenerator.generateContentStream)
.mockImplementationOnce(async () =>
(async function* () {
yield {
candidates: [
{ content: { parts: [{ text: 'Partial response...' }] } },
],
} as unknown as GenerateContentResponse;
throw sslError;
})(),
)
.mockImplementationOnce(async () =>
(async function* () {
yield {
candidates: [
{
content: { parts: [{ text: 'Complete response after retry' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})(),
);
const stream = await chat.sendMessageStream(
{ model: 'test-model' },
'test message',
'prompt-id-ssl3-mid-stream',
new AbortController().signal,
LlmRole.MAIN,
);
const events: StreamEvent[] = [];
for await (const event of stream) {
events.push(event);
}
const retryEvent = events.find((e) => e.type === StreamEventType.RETRY);
expect(retryEvent).toBeDefined();
const successChunk = events.find(
(e) =>
e.type === StreamEventType.CHUNK &&
e.value.candidates?.[0]?.content?.parts?.[0]?.text ===
'Complete response after retry',
);
expect(successChunk).toBeDefined();
expect(mockLogNetworkRetryAttempt).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
error_type: 'ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC',
}),
);
});
});
@@ -1762,6 +1762,39 @@ describe('PolicyEngine', () => {
});
describe('shell command parsing failure', () => {
it('should return ALLOW in YOLO mode for dangerous commands due to heuristics override', async () => {
// Create an engine with YOLO mode and a sandbox manager that flags a command as dangerous
const rules: PolicyRule[] = [
{
toolName: '*',
decision: PolicyDecision.ALLOW,
priority: 999,
modes: [ApprovalMode.YOLO],
},
];
const mockSandboxManager = new NoopSandboxManager();
mockSandboxManager.isDangerousCommand = vi.fn().mockReturnValue(true);
mockSandboxManager.isKnownSafeCommand = vi.fn().mockReturnValue(false);
engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.YOLO,
sandboxManager: mockSandboxManager,
});
const result = await engine.check(
{
name: 'run_shell_command',
args: { command: 'powershell echo "dangerous"' },
},
undefined,
);
// Even though the command is flagged as dangerous, YOLO mode should preserve the ALLOW decision
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should return ALLOW in YOLO mode even if shell command parsing fails', async () => {
const { splitCommands } = await import('../utils/shell-utils.js');
const rules: PolicyRule[] = [
@@ -312,6 +312,13 @@ export class PolicyEngine {
const parsedArgs = parsedObjArgs.map(extractStringFromParseEntry);
if (this.sandboxManager.isDangerousCommand(parsedArgs)) {
if (this.approvalMode === ApprovalMode.YOLO) {
debugLogger.debug(
`[PolicyEngine.check] Command evaluated as dangerous, but YOLO mode is active. Preserving decision: ${command}`,
);
return decision;
}
debugLogger.debug(
`[PolicyEngine.check] Command evaluated as dangerous, forcing ASK_USER: ${command}`,
);
@@ -53,7 +53,7 @@ vi.mock('../utils/events.js', () => ({
}));
vi.mock('../utils/debugLogger.js', () => ({
debugLogger: { log: vi.fn() },
debugLogger: { debug: vi.fn() },
}));
vi.mock('node:os', async (importOriginal) => {
@@ -153,14 +153,14 @@ describe('KeychainService', () => {
// Because it falls back to FileKeychain, it is always available.
expect(available).toBe(true);
expect(debugLogger.log).toHaveBeenCalledWith(
expect(debugLogger.debug).toHaveBeenCalledWith(
expect.stringContaining('encountered an error'),
'locked',
);
expect(coreEvents.emitTelemetryKeychainAvailability).toHaveBeenCalledWith(
expect.objectContaining({ available: false }),
);
expect(debugLogger.log).toHaveBeenCalledWith(
expect(debugLogger.debug).toHaveBeenCalledWith(
expect.stringContaining('Using FileKeychain fallback'),
);
expect(FileKeychain).toHaveBeenCalled();
@@ -173,7 +173,7 @@ describe('KeychainService', () => {
const available = await service.isAvailable();
expect(available).toBe(true);
expect(debugLogger.log).toHaveBeenCalledWith(
expect(debugLogger.debug).toHaveBeenCalledWith(
expect.stringContaining('failed structural validation'),
expect.objectContaining({ getPassword: expect.any(Array) }),
);
@@ -191,7 +191,7 @@ describe('KeychainService', () => {
const available = await service.isAvailable();
expect(available).toBe(true);
expect(debugLogger.log).toHaveBeenCalledWith(
expect(debugLogger.debug).toHaveBeenCalledWith(
expect.stringContaining('functional verification failed'),
);
expect(FileKeychain).toHaveBeenCalled();
@@ -243,7 +243,7 @@ describe('KeychainService', () => {
);
expect(mockKeytar.setPassword).not.toHaveBeenCalled();
expect(FileKeychain).toHaveBeenCalled();
expect(debugLogger.log).toHaveBeenCalledWith(
expect(debugLogger.debug).toHaveBeenCalledWith(
expect.stringContaining('MacOS default keychain not found'),
);
});
@@ -114,7 +114,7 @@ export class KeychainService {
}
// If native failed or was skipped, return the secure file fallback.
debugLogger.log('Using FileKeychain fallback for secure storage.');
debugLogger.debug('Using FileKeychain fallback for secure storage.');
return new FileKeychain();
}
@@ -130,7 +130,7 @@ export class KeychainService {
// Probing macOS prevents process-blocking popups when no keychain exists.
if (os.platform() === 'darwin' && !this.isMacOSKeychainAvailable()) {
debugLogger.log(
debugLogger.debug(
'MacOS default keychain not found; skipping functional verification.',
);
return null;
@@ -140,12 +140,15 @@ export class KeychainService {
return keychainModule;
}
debugLogger.log('Keychain functional verification failed');
debugLogger.debug('Keychain functional verification failed');
return null;
} catch (error) {
// Avoid logging full error objects to prevent PII exposure.
const message = error instanceof Error ? error.message : String(error);
debugLogger.log('Keychain initialization encountered an error:', message);
debugLogger.debug(
'Keychain initialization encountered an error:',
message,
);
return null;
}
}
@@ -162,7 +165,7 @@ export class KeychainService {
return potential as Keychain;
}
debugLogger.log(
debugLogger.debug(
'Keychain module failed structural validation:',
result.error.flatten().fieldErrors,
);
+97 -170
View File
@@ -4,20 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
beforeEach,
afterEach,
afterAll,
vi,
} from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
canUseRipgrep,
RipGrepTool,
ensureRgPath,
type RipGrepToolParams,
getRipgrepPath,
} from './ripGrep.js';
import type { GrepResult } from './tools.js';
import path from 'node:path';
@@ -25,18 +18,21 @@ import { isSubpath } from '../utils/paths.js';
import fs from 'node:fs/promises';
import os from 'node:os';
import type { Config } from '../config/config.js';
import { Storage } from '../config/storage.js';
import { GEMINI_IGNORE_FILE_NAME } from '../config/constants.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import { spawn, type ChildProcess } from 'node:child_process';
import { PassThrough, Readable } from 'node:stream';
import EventEmitter from 'node:events';
import { downloadRipGrep } from '@joshua.litt/get-ripgrep';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
// Mock dependencies for canUseRipgrep
vi.mock('@joshua.litt/get-ripgrep', () => ({
downloadRipGrep: vi.fn(),
}));
import { fileExists } from '../utils/fileUtils.js';
vi.mock('../utils/fileUtils.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/fileUtils.js')>();
return {
...actual,
fileExists: vi.fn(),
};
});
// Mock child_process for ripgrep calls
vi.mock('child_process', () => ({
@@ -44,161 +40,42 @@ vi.mock('child_process', () => ({
}));
const mockSpawn = vi.mocked(spawn);
const downloadRipGrepMock = vi.mocked(downloadRipGrep);
const originalGetGlobalBinDir = Storage.getGlobalBinDir.bind(Storage);
const storageSpy = vi.spyOn(Storage, 'getGlobalBinDir');
function getRipgrepBinaryName() {
return process.platform === 'win32' ? 'rg.exe' : 'rg';
}
describe('canUseRipgrep', () => {
let tempRootDir: string;
let binDir: string;
beforeEach(async () => {
downloadRipGrepMock.mockReset();
downloadRipGrepMock.mockResolvedValue(undefined);
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ripgrep-bin-'));
binDir = path.join(tempRootDir, 'bin');
await fs.mkdir(binDir, { recursive: true });
storageSpy.mockImplementation(() => binDir);
});
afterEach(async () => {
storageSpy.mockImplementation(() => originalGetGlobalBinDir());
await fs.rm(tempRootDir, { recursive: true, force: true });
beforeEach(() => {
vi.mocked(fileExists).mockReset();
});
it('should return true if ripgrep already exists', async () => {
const existingPath = path.join(binDir, getRipgrepBinaryName());
await fs.writeFile(existingPath, '');
vi.mocked(fileExists).mockResolvedValue(true);
const result = await canUseRipgrep();
expect(result).toBe(true);
expect(downloadRipGrepMock).not.toHaveBeenCalled();
});
it('should download ripgrep and return true if it does not exist initially', async () => {
const expectedPath = path.join(binDir, getRipgrepBinaryName());
downloadRipGrepMock.mockImplementation(async () => {
await fs.writeFile(expectedPath, '');
});
it('should return false if file does not exist', async () => {
vi.mocked(fileExists).mockResolvedValue(false);
const result = await canUseRipgrep();
expect(result).toBe(true);
expect(downloadRipGrep).toHaveBeenCalledWith(binDir);
await expect(fs.access(expectedPath)).resolves.toBeUndefined();
});
it('should return false if download fails and file does not exist', async () => {
const result = await canUseRipgrep();
expect(result).toBe(false);
expect(downloadRipGrep).toHaveBeenCalledWith(binDir);
});
it('should propagate errors from downloadRipGrep', async () => {
const error = new Error('Download failed');
downloadRipGrepMock.mockRejectedValue(error);
await expect(canUseRipgrep()).rejects.toThrow(error);
expect(downloadRipGrep).toHaveBeenCalledWith(binDir);
});
it('should only download once when called concurrently', async () => {
const expectedPath = path.join(binDir, getRipgrepBinaryName());
downloadRipGrepMock.mockImplementation(
() =>
new Promise<void>((resolve, reject) => {
setTimeout(() => {
fs.writeFile(expectedPath, '')
.then(() => resolve())
.catch(reject);
}, 0);
}),
);
const firstCall = ensureRgPath();
const secondCall = ensureRgPath();
const [pathOne, pathTwo] = await Promise.all([firstCall, secondCall]);
expect(pathOne).toBe(expectedPath);
expect(pathTwo).toBe(expectedPath);
expect(downloadRipGrepMock).toHaveBeenCalledTimes(1);
await expect(fs.access(expectedPath)).resolves.toBeUndefined();
});
});
describe('ensureRgPath', () => {
let tempRootDir: string;
let binDir: string;
beforeEach(async () => {
downloadRipGrepMock.mockReset();
downloadRipGrepMock.mockResolvedValue(undefined);
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ripgrep-bin-'));
binDir = path.join(tempRootDir, 'bin');
await fs.mkdir(binDir, { recursive: true });
storageSpy.mockImplementation(() => binDir);
});
afterEach(async () => {
storageSpy.mockImplementation(() => originalGetGlobalBinDir());
await fs.rm(tempRootDir, { recursive: true, force: true });
beforeEach(() => {
vi.mocked(fileExists).mockReset();
});
it('should return rg path if ripgrep already exists', async () => {
const existingPath = path.join(binDir, getRipgrepBinaryName());
await fs.writeFile(existingPath, '');
vi.mocked(fileExists).mockResolvedValue(true);
const rgPath = await ensureRgPath();
expect(rgPath).toBe(existingPath);
expect(downloadRipGrep).not.toHaveBeenCalled();
expect(rgPath).toBe(await getRipgrepPath());
});
it('should return rg path if ripgrep is downloaded successfully', async () => {
const expectedPath = path.join(binDir, getRipgrepBinaryName());
downloadRipGrepMock.mockImplementation(async () => {
await fs.writeFile(expectedPath, '');
});
const rgPath = await ensureRgPath();
expect(rgPath).toBe(expectedPath);
expect(downloadRipGrep).toHaveBeenCalledTimes(1);
await expect(fs.access(expectedPath)).resolves.toBeUndefined();
it('should throw an error if ripgrep cannot be used', async () => {
vi.mocked(fileExists).mockResolvedValue(false);
await expect(ensureRgPath()).rejects.toThrow(
/Cannot find bundled ripgrep binary/,
);
});
it('should throw an error if ripgrep cannot be used after download attempt', async () => {
await expect(ensureRgPath()).rejects.toThrow('Cannot use ripgrep.');
expect(downloadRipGrep).toHaveBeenCalledTimes(1);
});
it('should propagate errors from downloadRipGrep', async () => {
const error = new Error('Download failed');
downloadRipGrepMock.mockRejectedValue(error);
await expect(ensureRgPath()).rejects.toThrow(error);
expect(downloadRipGrep).toHaveBeenCalledWith(binDir);
});
it.runIf(process.platform === 'win32')(
'should detect ripgrep when only rg.exe exists on Windows',
async () => {
const expectedRgExePath = path.join(binDir, 'rg.exe');
await fs.writeFile(expectedRgExePath, '');
const rgPath = await ensureRgPath();
expect(rgPath).toBe(expectedRgExePath);
expect(downloadRipGrep).not.toHaveBeenCalled();
await expect(fs.access(expectedRgExePath)).resolves.toBeUndefined();
},
);
});
// Helper function to create mock spawn implementations
@@ -247,9 +124,6 @@ function createMockSpawn(
describe('RipGrepTool', () => {
let tempRootDir: string;
let tempBinRoot: string;
let binDir: string;
let ripgrepBinaryPath: string;
let grepTool: RipGrepTool;
const abortSignal = new AbortController().signal;
@@ -266,19 +140,12 @@ describe('RipGrepTool', () => {
} as unknown as Config;
beforeEach(async () => {
downloadRipGrepMock.mockReset();
downloadRipGrepMock.mockResolvedValue(undefined);
mockSpawn.mockReset();
mockSpawn.mockImplementation(createMockSpawn());
tempBinRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ripgrep-bin-'));
binDir = path.join(tempBinRoot, 'bin');
await fs.mkdir(binDir, { recursive: true });
const binaryName = process.platform === 'win32' ? 'rg.exe' : 'rg';
ripgrepBinaryPath = path.join(binDir, binaryName);
await fs.writeFile(ripgrepBinaryPath, '');
storageSpy.mockImplementation(() => binDir);
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-'));
vi.mocked(fileExists).mockResolvedValue(true);
mockConfig = {
getTargetDir: () => tempRootDir,
getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir),
@@ -335,9 +202,7 @@ describe('RipGrepTool', () => {
});
afterEach(async () => {
storageSpy.mockImplementation(() => originalGetGlobalBinDir());
await fs.rm(tempRootDir, { recursive: true, force: true });
await fs.rm(tempBinRoot, { recursive: true, force: true });
});
describe('validateToolParams', () => {
@@ -834,16 +699,16 @@ describe('RipGrepTool', () => {
});
it('should throw an error if ripgrep is not available', async () => {
await fs.rm(ripgrepBinaryPath, { force: true });
downloadRipGrepMock.mockResolvedValue(undefined);
vi.mocked(fileExists).mockResolvedValue(false);
const params: RipGrepToolParams = { pattern: 'world' };
const invocation = grepTool.build(params);
expect(await invocation.execute({ abortSignal })).toStrictEqual({
llmContent: 'Error during grep search operation: Cannot use ripgrep.',
returnDisplay: 'Error: Cannot use ripgrep.',
});
const result = await invocation.execute({ abortSignal });
expect(result.llmContent).toContain('Cannot find bundled ripgrep binary');
// restore the mock for subsequent tests
vi.mocked(fileExists).mockResolvedValue(true);
});
});
@@ -2080,6 +1945,68 @@ describe('RipGrepTool', () => {
});
});
afterAll(() => {
storageSpy.mockRestore();
describe('getRipgrepPath', () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe('OS/Architecture Resolution', () => {
it.each([
{ platform: 'darwin', arch: 'arm64', expectedBin: 'rg-darwin-arm64' },
{ platform: 'darwin', arch: 'x64', expectedBin: 'rg-darwin-x64' },
{ platform: 'linux', arch: 'arm64', expectedBin: 'rg-linux-arm64' },
{ platform: 'linux', arch: 'x64', expectedBin: 'rg-linux-x64' },
{ platform: 'win32', arch: 'x64', expectedBin: 'rg-win32-x64.exe' },
])(
'should map $platform $arch to $expectedBin',
async ({ platform, arch, expectedBin }) => {
vi.spyOn(os, 'platform').mockReturnValue(platform as NodeJS.Platform);
vi.spyOn(os, 'arch').mockReturnValue(arch);
vi.mocked(fileExists).mockImplementation(async (checkPath) =>
checkPath.endsWith(expectedBin),
);
const resolvedPath = await getRipgrepPath();
expect(resolvedPath).not.toBeNull();
expect(resolvedPath?.endsWith(expectedBin)).toBe(true);
},
);
});
describe('Path Fallback Logic', () => {
beforeEach(() => {
vi.spyOn(os, 'platform').mockReturnValue('linux');
vi.spyOn(os, 'arch').mockReturnValue('x64');
});
it('should resolve the SEA (flattened) path first', async () => {
vi.mocked(fileExists).mockImplementation(async (checkPath) =>
checkPath.includes(path.normalize('tools/vendor/ripgrep')),
);
const resolvedPath = await getRipgrepPath();
expect(resolvedPath).not.toBeNull();
expect(resolvedPath).toContain(path.normalize('tools/vendor/ripgrep'));
});
it('should fall back to the Dev path if SEA path is missing', async () => {
vi.mocked(fileExists).mockImplementation(
async (checkPath) =>
checkPath.includes(path.normalize('core/vendor/ripgrep')) &&
!checkPath.includes('tools'),
);
const resolvedPath = await getRipgrepPath();
expect(resolvedPath).not.toBeNull();
expect(resolvedPath).toContain(path.normalize('core/vendor/ripgrep'));
expect(resolvedPath).not.toContain('tools');
});
it('should return null if binary is missing from both paths', async () => {
vi.mocked(fileExists).mockResolvedValue(false);
const resolvedPath = await getRipgrepPath();
expect(resolvedPath).toBeNull();
});
});
});
+29 -54
View File
@@ -8,7 +8,8 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import { downloadRipGrep } from '@joshua.litt/get-ripgrep';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import {
BaseDeclarativeTool,
BaseToolInvocation,
@@ -22,7 +23,6 @@ import { makeRelative, shortenPath } from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js';
import type { Config } from '../config/config.js';
import { fileExists } from '../utils/fileUtils.js';
import { Storage } from '../config/storage.js';
import { GREP_TOOL_NAME } from './tool-names.js';
import { debugLogger } from '../utils/debugLogger.js';
import {
@@ -39,73 +39,48 @@ import { RIP_GREP_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { type GrepMatch, formatGrepResults } from './grep-utils.js';
function getRgCandidateFilenames(): readonly string[] {
return process.platform === 'win32' ? ['rg.exe', 'rg'] : ['rg'];
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function resolveExistingRgPath(): Promise<string | null> {
const binDir = Storage.getGlobalBinDir();
for (const fileName of getRgCandidateFilenames()) {
const candidatePath = path.join(binDir, fileName);
if (await fileExists(candidatePath)) {
return candidatePath;
export async function getRipgrepPath(): Promise<string | null> {
const platform = os.platform();
const arch = os.arch();
// Map to the correct bundled binary
const binName = `rg-${platform}-${arch}${platform === 'win32' ? '.exe' : ''}`;
const candidatePaths = [
// 1. SEA runtime layout: everything is flattened into the root dir
path.resolve(__dirname, 'vendor/ripgrep', binName),
// 2. Dev/Dist layout: packages/core/dist/tools/ripGrep.js -> packages/core/vendor/ripgrep
path.resolve(__dirname, '../../vendor/ripgrep', binName),
];
for (const candidate of candidatePaths) {
if (await fileExists(candidate)) {
return candidate;
}
}
return null;
}
let ripgrepAcquisitionPromise: Promise<string | null> | null = null;
/**
* Ensures a ripgrep binary is available.
*
* NOTE:
* - The Gemini CLI currently prefers a managed ripgrep binary downloaded
* into its global bin directory.
* - Even if ripgrep is available on the system PATH, it is intentionally
* not used at this time.
*
* Preference for system-installed ripgrep is blocked on:
* - checksum verification of external binaries
* - internalization of the get-ripgrep dependency
*
* See:
* - feat(core): Prefer rg in system path (#11847)
* - Move get-ripgrep to third_party (#12099)
*/
async function ensureRipgrepAvailable(): Promise<string | null> {
const existingPath = await resolveExistingRgPath();
if (existingPath) {
return existingPath;
}
if (!ripgrepAcquisitionPromise) {
ripgrepAcquisitionPromise = (async () => {
try {
await downloadRipGrep(Storage.getGlobalBinDir());
return await resolveExistingRgPath();
} finally {
ripgrepAcquisitionPromise = null;
}
})();
}
return ripgrepAcquisitionPromise;
}
/**
* Checks if `rg` exists, if not then attempt to download it.
* Checks if `rg` exists in the bundled vendor directory.
*/
export async function canUseRipgrep(): Promise<boolean> {
return (await ensureRipgrepAvailable()) !== null;
const binPath = await getRipgrepPath();
return binPath !== null;
}
/**
* Ensures `rg` is downloaded, or throws.
* Ensures `rg` is available, or throws.
*/
export async function ensureRgPath(): Promise<string> {
const downloadedPath = await ensureRipgrepAvailable();
if (downloadedPath) {
return downloadedPath;
const binPath = await getRipgrepPath();
if (binPath !== null) {
return binPath;
}
throw new Error('Cannot use ripgrep.');
throw new Error(`Cannot find bundled ripgrep binary.`);
}
/**
+5
View File
@@ -45,6 +45,7 @@ import {
} from '../utils/shell-utils.js';
import { SHELL_TOOL_NAME } from './tool-names.js';
import { PARAM_ADDITIONAL_PERMISSIONS } from './definitions/base-declarations.js';
import { ApprovalMode } from '../policy/types.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { getShellDefinition } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
@@ -249,6 +250,10 @@ export class ShellToolInvocation extends BaseToolInvocation<
abortSignal: AbortSignal,
forcedDecision?: ForcedToolDecision,
): Promise<ToolCallConfirmationDetails | false> {
if (this.context.config.getApprovalMode() === ApprovalMode.YOLO) {
return super.shouldConfirmExecute(abortSignal, forcedDecision);
}
if (this.params[PARAM_ADDITIONAL_PERMISSIONS]) {
return this.getConfirmationDetails(abortSignal);
}
@@ -195,6 +195,7 @@ describe('compatibility', () => {
desc: '256 colors are not supported',
},
])('should return $expected when $desc', ({ depth, term, expected }) => {
vi.stubEnv('COLORTERM', '');
process.stdout.getColorDepth = vi.fn().mockReturnValue(depth);
if (term !== undefined) {
vi.stubEnv('TERM', term);
@@ -203,6 +204,13 @@ describe('compatibility', () => {
}
expect(supports256Colors()).toBe(expected);
});
it('should return true when COLORTERM is kmscon', () => {
process.stdout.getColorDepth = vi.fn().mockReturnValue(4);
vi.stubEnv('TERM', 'linux');
vi.stubEnv('COLORTERM', 'kmscon');
expect(supports256Colors()).toBe(true);
});
});
describe('supportsTrueColor', () => {
@@ -230,6 +238,12 @@ describe('compatibility', () => {
expected: true,
desc: 'getColorDepth returns >= 24',
},
{
colorterm: 'kmscon',
depth: 4,
expected: true,
desc: 'COLORTERM is kmscon',
},
{
colorterm: '',
depth: 8,
@@ -409,6 +423,18 @@ describe('compatibility', () => {
);
});
it('should return no color warnings for kmscon terminal', () => {
vi.mocked(os.platform).mockReturnValue('linux');
vi.stubEnv('TERMINAL_EMULATOR', '');
vi.stubEnv('TERM', 'linux');
vi.stubEnv('COLORTERM', 'kmscon');
process.stdout.getColorDepth = vi.fn().mockReturnValue(4);
const warnings = getCompatibilityWarnings();
expect(warnings.find((w) => w.id === '256-color')).toBeUndefined();
expect(warnings.find((w) => w.id === 'true-color')).toBeUndefined();
});
it('should return no warnings in a standard environment with true color', () => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.stubEnv('TERMINAL_EMULATOR', '');
+7 -1
View File
@@ -85,6 +85,11 @@ export function supports256Colors(): boolean {
return true;
}
// Terminals supporting true color (like kmscon) also support 256 colors
if (supportsTrueColor()) {
return true;
}
return false;
}
@@ -95,7 +100,8 @@ export function supportsTrueColor(): boolean {
// Check COLORTERM environment variable
if (
process.env['COLORTERM'] === 'truecolor' ||
process.env['COLORTERM'] === '24bit'
process.env['COLORTERM'] === '24bit' ||
process.env['COLORTERM'] === 'kmscon'
) {
return true;
}
+34
View File
@@ -511,6 +511,40 @@ describe('retryWithBackoff', () => {
expect(mockFn).toHaveBeenCalledTimes(2);
});
it('should retry on OpenSSL 3.x SSL error code (ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC)', async () => {
const error = new Error('SSL error');
(error as any).code = 'ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC';
const mockFn = vi
.fn()
.mockRejectedValueOnce(error)
.mockResolvedValue('success');
const promise = retryWithBackoff(mockFn, {
initialDelayMs: 1,
maxDelayMs: 1,
});
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe('success');
expect(mockFn).toHaveBeenCalledTimes(2);
});
it('should retry on unknown SSL BAD_RECORD_MAC variant via substring fallback', async () => {
const error = new Error('SSL error');
(error as any).code = 'ERR_SSL_SOME_FUTURE_BAD_RECORD_MAC';
const mockFn = vi
.fn()
.mockRejectedValueOnce(error)
.mockResolvedValue('success');
const promise = retryWithBackoff(mockFn, {
initialDelayMs: 1,
maxDelayMs: 1,
});
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe('success');
expect(mockFn).toHaveBeenCalledTimes(2);
});
it('should retry on gaxios-style SSL error with code property', async () => {
// This matches the exact structure from issue #17318
const error = new Error(
+22 -6
View File
@@ -53,14 +53,30 @@ const RETRYABLE_NETWORK_CODES = [
'ENOTFOUND',
'EAI_AGAIN',
'ECONNREFUSED',
// SSL/TLS transient errors
'ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC',
'ERR_SSL_WRONG_VERSION_NUMBER',
'ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC',
'ERR_SSL_BAD_RECORD_MAC',
'EPROTO', // Generic protocol error (often SSL-related)
];
// Node.js builds SSL error codes by prepending ERR_SSL_ to the uppercased
// OpenSSL reason string with spaces replaced by underscores (see
// TLSWrap::ClearOut in node/src/crypto/crypto_tls.cc). The reason string
// format varies by OpenSSL version (e.g. ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC
// on OpenSSL 1.x, ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC on OpenSSL 3.x), so
// match the stable suffix instead of enumerating every variant.
const RETRYABLE_SSL_ERROR_PATTERN = /^ERR_SSL_.*BAD_RECORD_MAC/i;
/**
* Returns true if the error code should be retried: either an exact match
* against RETRYABLE_NETWORK_CODES, or an SSL BAD_RECORD_MAC variant (the
* OpenSSL reason-string portion of the code varies across OpenSSL versions).
*/
function isRetryableSslErrorCode(code: string): boolean {
return (
RETRYABLE_NETWORK_CODES.includes(code) ||
RETRYABLE_SSL_ERROR_PATTERN.test(code)
);
}
function getNetworkErrorCode(error: unknown): string | undefined {
const getCode = (obj: unknown): string | undefined => {
if (typeof obj !== 'object' || obj === null) {
@@ -112,7 +128,7 @@ export function getRetryErrorType(error: unknown): string {
}
const errorCode = getNetworkErrorCode(error);
if (errorCode && RETRYABLE_NETWORK_CODES.includes(errorCode)) {
if (errorCode && isRetryableSslErrorCode(errorCode)) {
return errorCode;
}
@@ -153,7 +169,7 @@ export function isRetryableError(
): boolean {
// Check for common network error codes
const errorCode = getNetworkErrorCode(error);
if (errorCode && RETRYABLE_NETWORK_CODES.includes(errorCode)) {
if (errorCode && isRetryableSslErrorCode(errorCode)) {
return true;
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-devtools",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"license": "Apache-2.0",
"type": "module",
"main": "dist/src/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-sdk",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"description": "Gemini CLI SDK",
"license": "Apache-2.0",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.39.0-nightly.20260408.e77b22e63",
"version": "0.40.0-nightly.20260414.g5b1f7375a",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
+5
View File
@@ -12,6 +12,11 @@
"cpuTotalUs": 12157,
"timestamp": "2026-04-08T22:28:19.098Z"
},
"asian-language-conv": {
"wallClockMs": 2315.1,
"cpuTotalUs": 6283,
"timestamp": "2026-04-14T15:22:56.133Z"
},
"skill-loading-time": {
"wallClockMs": 930.0920409999962,
"cpuTotalUs": 1323,
+30
View File
@@ -98,6 +98,36 @@ describe('CPU Performance Tests', () => {
}
});
it('asian-language-conv: verify perf is acceptable ', async () => {
const result = await harness.runScenario(
'asian-language-conv',
async () => {
const rig = new TestRig();
try {
rig.setup('perf-asian-language', {
fakeResponsesPath: join(__dirname, 'perf.asian-language.responses'),
});
return await harness.measure('asian-language', async () => {
await rig.run({
args: ['嗨'],
timeout: 120000,
env: { GEMINI_API_KEY: 'fake-perf-test-key' },
});
});
} finally {
await rig.cleanup();
}
},
);
if (UPDATE_BASELINES) {
harness.updateScenarioBaseline(result);
} else {
harness.assertWithinBaseline(result);
}
});
it('skill-loading-time: startup with many skills within baseline', async () => {
const SKILL_COUNT = 20;
+2
View File
@@ -0,0 +1,2 @@
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"你好!我是 Gemini CLI,你的 AI 编程助手"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":20648,"candidatesTokenCount":12,"totalTokenCount":20769,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]}
+12
View File
@@ -108,4 +108,16 @@ if (!existsSync(bundleMcpSrc)) {
cpSync(bundleMcpSrc, bundleMcpDest, { recursive: true, dereference: true });
console.log('Copied bundled chrome-devtools-mcp to bundle/bundled/');
// 7. Copy pre-built ripgrep vendor binaries
const ripgrepVendorSrc = join(root, 'packages/core/vendor/ripgrep');
const ripgrepVendorDest = join(bundleDir, 'vendor', 'ripgrep');
if (existsSync(ripgrepVendorSrc)) {
mkdirSync(ripgrepVendorDest, { recursive: true });
cpSync(ripgrepVendorSrc, ripgrepVendorDest, {
recursive: true,
dereference: true,
});
console.log('Copied ripgrep vendor binaries to bundle/vendor/ripgrep/');
}
console.log('Assets copied to bundle/');
+146
View File
@@ -0,0 +1,146 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview This script downloads pre-built ripgrep binaries for all supported
* architectures and platforms. These binaries are checked into the repository
* under packages/core/vendor/ripgrep.
*
* Maintainers should periodically run this script to upgrade the version
* of ripgrep being distributed.
*
* Usage: npx tsx scripts/download-ripgrep-binaries.ts
*/
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
import { createWriteStream } from 'node:fs';
import { Readable } from 'node:stream';
import type { ReadableStream } from 'node:stream/web';
import { execFileSync } from 'node:child_process';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CORE_VENDOR_DIR = path.join(__dirname, '../packages/core/vendor/ripgrep');
const VERSION = 'v13.0.0-10';
interface Target {
platform: string;
arch: string;
file: string;
}
const targets: Target[] = [
{ platform: 'darwin', arch: 'arm64', file: 'aarch64-apple-darwin.tar.gz' },
{ platform: 'darwin', arch: 'x64', file: 'x86_64-apple-darwin.tar.gz' },
{
platform: 'linux',
arch: 'arm64',
file: 'aarch64-unknown-linux-gnu.tar.gz',
},
{ platform: 'linux', arch: 'x64', file: 'x86_64-unknown-linux-musl.tar.gz' },
{ platform: 'win32', arch: 'x64', file: 'x86_64-pc-windows-msvc.zip' },
];
async function downloadBinary() {
await fsPromises.mkdir(CORE_VENDOR_DIR, { recursive: true });
for (const target of targets) {
const url = `https://github.com/microsoft/ripgrep-prebuilt/releases/download/${VERSION}/ripgrep-${VERSION}-${target.file}`;
const archivePath = path.join(CORE_VENDOR_DIR, target.file);
const binName = `rg-${target.platform}-${target.arch}${target.platform === 'win32' ? '.exe' : ''}`;
const finalBinPath = path.join(CORE_VENDOR_DIR, binName);
if (fs.existsSync(finalBinPath)) {
console.log(`[Cache] ${binName} already exists.`);
continue;
}
console.log(`[Download] ${url} -> ${archivePath}`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
}
if (!response.body) {
throw new Error(`Response body is null for ${url}`);
}
const fileStream = createWriteStream(archivePath);
// Node 18+ global fetch response.body is a ReadableStream (web stream)
// pipeline(Readable.fromWeb(response.body), fileStream) works in Node 18+
await pipeline(
Readable.fromWeb(response.body as ReadableStream),
fileStream,
);
console.log(`[Extract] Extracting ${archivePath}...`);
// Extract using shell commands for simplicity
if (target.file.endsWith('.tar.gz')) {
execFileSync('tar', ['-xzf', archivePath, '-C', CORE_VENDOR_DIR]);
// Microsoft's ripgrep release extracts directly to `rg` inside the current directory sometimes
const sourceBin = path.join(CORE_VENDOR_DIR, 'rg');
if (fs.existsSync(sourceBin)) {
await fsPromises.rename(sourceBin, finalBinPath);
} else {
// Fallback for sub-directory if it happens
const extractedDirName = `ripgrep-${VERSION}-${target.file.replace('.tar.gz', '')}`;
const fallbackSourceBin = path.join(
CORE_VENDOR_DIR,
extractedDirName,
'rg',
);
if (fs.existsSync(fallbackSourceBin)) {
await fsPromises.rename(fallbackSourceBin, finalBinPath);
await fsPromises.rm(path.join(CORE_VENDOR_DIR, extractedDirName), {
recursive: true,
force: true,
});
} else {
throw new Error(
`Could not find extracted 'rg' binary for ${target.platform} ${target.arch}`,
);
}
}
} else if (target.file.endsWith('.zip')) {
execFileSync('unzip', ['-o', '-q', archivePath, '-d', CORE_VENDOR_DIR]);
const sourceBin = path.join(CORE_VENDOR_DIR, 'rg.exe');
if (fs.existsSync(sourceBin)) {
await fsPromises.rename(sourceBin, finalBinPath);
} else {
const extractedDirName = `ripgrep-${VERSION}-${target.file.replace('.zip', '')}`;
const fallbackSourceBin = path.join(
CORE_VENDOR_DIR,
extractedDirName,
'rg.exe',
);
if (fs.existsSync(fallbackSourceBin)) {
await fsPromises.rename(fallbackSourceBin, finalBinPath);
await fsPromises.rm(path.join(CORE_VENDOR_DIR, extractedDirName), {
recursive: true,
force: true,
});
} else {
throw new Error(
`Could not find extracted 'rg.exe' binary for ${target.platform} ${target.arch}`,
);
}
}
}
// Clean up archive
await fsPromises.unlink(archivePath);
console.log(`[Success] Saved to ${finalBinPath}`);
}
}
downloadBinary().catch((err) => {
console.error(err);
process.exit(1);
});