Compare commits

...

168 Commits

Author SHA1 Message Date
github-actions[bot]
22d6bd49a3 chore(release): v0.0.7 2025-08-15 10:35:53 +00:00
Mingholy
14e6d3c01e Update qwen-code-pr-review.yml
Trigger Qwen PR Review when a PR opens.
Fix the auto-skip issue.
2025-08-15 18:24:43 +08:00
pomelo
da0b8b5534 Merge pull request #340 from QwenLM/feat/web_fetch_tool
feat: refactor web-fetch tool to remove google genai dependency
2025-08-15 18:10:32 +08:00
tanzhenxin
e1d502991d chore: remove https restricton 2025-08-15 17:58:05 +08:00
tanzhenxin
7e01554b9c chore: fix test case failure 2025-08-15 17:27:09 +08:00
tanzhenxin
36c65658ff chore: npm run lint 2025-08-15 17:16:05 +08:00
tanzhenxin
a925ac56fa Potential fix for code scanning alert no. 24: Incomplete URL substring sanitization
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-08-15 17:10:20 +08:00
tanzhenxin
5d4a9452d8 feat: refactor web-fetch tool to remove google genai dependency 2025-08-15 17:06:00 +08:00
tanzhenxin
3e082ae89a feat: replace google web search with tavily web search (#329) 2025-08-14 21:20:23 +08:00
Fan
51207043d0 fix: custom API's trailing space and empty tool id issues (#326)
* fix: generate random tool call id when serving API does not have one

* tmp
2025-08-14 21:18:52 +08:00
Mingholy
2403061bab fix: OpenAI tools (#328)
- MCP tool params schema lost causing all MCP not working well
- Compatible with occasional llm return tool call parameters that are invalid json
2025-08-14 21:18:26 +08:00
Mingholy
1ffcb51052 fix: seperate static QR code and dynamic spin components (#327)
* fix: seperate static QR code and dynamic spin components

* fix: format issues
2025-08-14 21:17:56 +08:00
tanzhenxin
c33d162ff2 Merge pull request #325 from QwenLM/fix/max_listeners_warning
fix: qwen logger exit handler setup
2025-08-14 20:06:14 +08:00
tanzhenxin
bbfe94cfe2 chore: npm run format 2025-08-14 18:54:24 +08:00
pomelo
03c7b1836f Merge pull request #323 from QwenLM/release/0.0.6
chore: bump version to 0.0.6
2025-08-14 18:54:04 +08:00
pomelo
f2ba6dbb8a Merge pull request #322 from QwenLM/fix/concurrent_requests
feat: prevent concurrent query submissions in useGeminiStream hook
2025-08-14 18:53:19 +08:00
tanzhenxin
2d0884b04d fix: qwen logger exit handler setup 2025-08-14 18:08:14 +08:00
tanzhenxin
fc70439355 chore: bump version to 0.0.6 2025-08-14 16:52:39 +08:00
tanzhenxin
0265b67b90 chore: npm run format & lint 2025-08-14 16:48:51 +08:00
tanzhenxin
1f91b9ece1 Merge pull request #309 from QwenLM/chore/sync-gemini-cli-v0.1.18
Sync with upstream gemini-cli v0.1.18
2025-08-14 16:47:31 +08:00
tanzhenxin
c58106079e feat: prevent concurrent query submissions in useGeminiStream hook 2025-08-14 16:39:26 +08:00
pomelo
6516d0d136 Merge pull request #313 from QwenLM/chore/log_api_request
chore: add api request logger
2025-08-13 18:54:02 +08:00
tanzhenxin
5369af61d2 chore: add api request logger 2025-08-13 18:51:40 +08:00
pomelo
6a4005cace Merge pull request #262 from nguu0123/main
feat(sandbox): add GHA to build sandbox image
2025-08-13 18:37:39 +08:00
tanzhenxin
290ccdbe21 chore: stick openai sdk version to 5.11.0 2025-08-13 16:10:04 +08:00
tanzhenxin
b5514fd052 chore: fix invalid package deps 2025-08-13 16:00:26 +08:00
tanzhenxin
bc92da04e9 Merge tag 'v0.1.18' of https://github.com/google-gemini/gemini-cli into chore/sync-gemini-cli-v0.1.18 2025-08-13 15:11:10 +08:00
nguu0123
a37423bf7f Update name of the workflow 2025-08-10 13:40:24 +03:00
nguu0123
bfcb3e7f1d Remove redundant Node.js setup and build steps from Docker workflow 2025-08-10 13:23:49 +03:00
nguu0123
1a581ed191 Limit docker image workflow to tags and manual triggers 2025-08-10 13:17:34 +03:00
nguu0123
5c94913643 Refactor Dockerfile with multi-stage build for smaller image size 2025-08-10 13:00:46 +03:00
nguu0123
e221b077e5 Fix gha version 2025-08-10 12:26:51 +03:00
nguu0123
0f58b3fd32 fix qemu gha version 2025-08-10 12:24:18 +03:00
nguu0123
32d06b2fc1 Add publish image gha 2025-08-10 12:20:22 +03:00
github-actions[bot]
ea96293e16 chore(release): v0.1.18 2025-08-06 00:58:42 +00:00
Jerop Kipruto
cd7e60e008 switch from heads to tags in url path (#5638) 2025-08-05 17:47:28 -07:00
Sandy Tao
59bde4a612 fix(core) Fix not resetting when after first get out of completion suggestions (#5635)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-06 00:37:44 +00:00
Bryan Morgan
02f7e48c51 Removed GitHub Actions experiment files (#5627) 2025-08-06 00:01:18 +00:00
christine betts
aeb6602266 Remove a few witty loading phrases (#5631)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2025-08-05 23:59:14 +00:00
David Rees
805114aef8 fix(docs): Fix code block delimiters in commands.md (#5521)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 23:30:57 +00:00
Justin Mahood
91035ad7b0 Fix(vim): Fix shell mode in Vim mode (#5567)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-05 23:29:37 +00:00
Bryant Chandler
12a9bc3ed9 feat(core, cli): Introduce high-performance FileSearch engine (#5136)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-05 23:18:03 +00:00
Allen Hutchison
2141b39c3d feat(cli): route non-interactive output to stderr (#5624) 2025-08-05 23:11:21 +00:00
Shreya Keshive
268627469b Refactor IDE client state management, improve user-facing error messages, and add logging of connection events (#5591)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2025-08-05 22:52:58 +00:00
Jacob MacDonald
6a72cd064b check for the prompt capability before listing prompts from MCP servers (#5616)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 22:50:30 +00:00
sangwook
aebe3ace3c perf(core): implement parallel file processing for 74% performance improvement (#4763)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 22:47:18 +00:00
8bitmp3
c402784d97 Fix and improve Gemini CLI troubleshooting.md doc (#2734)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 22:43:41 +00:00
William Thurston
bed6ab1cce fix(start): use absolute path to resolve CLI package (#3196)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 22:43:15 +00:00
xyizko
1b08a6c063 fix(minor): Grammar and Typos (#5053)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-05 22:41:27 +00:00
Sandy Tao
82fa7a0660 fix(format) Fix format for .github/workflows/weekly-velocity-report.yml (#5622) 2025-08-05 22:32:06 +00:00
Bryan Morgan
2e9236fab4 Update weekly-velocity-report.yml 2025-08-05 18:11:06 -04:00
Mikhail Aksenov
dadf05809c feat: mcp - support audiences for OAuth2 (#5265) 2025-08-05 22:02:16 +00:00
Ramón Medrano Llamas
29c3825604 fix(mcp): clear prompt registry on refresh to prevent duplicates (#5385)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 21:59:31 +00:00
Hiroaki Mitsuyoshi
faf6a5497a feat(docs): Add /chat delete command in commands.md (#5408)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 21:58:09 +00:00
Jacob Richman
dd85aaa951 bug(core): Fix flaky test by using waitFor. (#5540)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 21:56:38 +00:00
Gal Zahavi
aacae1de43 fix(core): prevent UI shift after vim edit (#5315) 2025-08-05 21:55:54 +00:00
Sandy Tao
8d993156e7 Fix format (#5617) 2025-08-05 21:38:43 +00:00
Bryan Morgan
57003ca68c Update weekly-velocity-report.yml 2025-08-05 17:18:19 -04:00
Bryan Morgan
47de37eb0a Update weekly-velocity-report.yml 2025-08-05 17:10:37 -04:00
Bryan Morgan
dc7b4fda64 Update weekly-velocity-report.yml 2025-08-05 17:08:22 -04:00
Bryan Morgan
3dcca31796 Update weekly-velocity-report.yml 2025-08-05 17:00:44 -04:00
Bryan Morgan
c194a6ac3b GitHub Action for velocity reporting purposes (#5607) 2025-08-05 20:33:59 +00:00
Bryan Morgan
d421fa9e64 Testing basic velocity report action 2025-08-05 15:55:50 -04:00
Luccas Paroni
2778c7d851 feat(core): Parse Multimodal MCP Tool responses (#5529)
Co-authored-by: Luccas Paroni <luccasparoni@google.com>
2025-08-05 19:19:47 +00:00
Oleksandr Gotgelf
b465145229 chore(settings): clean up comments in settings.ts (#5576) 2025-08-05 19:10:16 +00:00
Alexander J
f2d6748432 fix: small typo in ROADMAP.md (#5593)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-05 19:04:10 +00:00
joshualitt
08f1431946 bug(core): fix contentRangeTruncated calculation. (#5329)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-05 18:52:39 +00:00
David East
43d5aaa798 fix(mcp): ensure authorization url is valid when containing query params (#5545)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-05 18:44:30 +00:00
Yuki Okita
5c8268b6f4 feat: Multi-Directory Workspace Support (part 3: configuration in settings.json) (#5354)
Co-authored-by: Allen Hutchison <adh@google.com>
2025-08-05 17:01:01 +00:00
Jack Wotherspoon
d0cda58f1f docs: update typo in commands.md (#5584) 2025-08-05 14:03:58 +00:00
N. Taylor Mullen
c7a1de4983 chore(release): v0.1.17 (#5561) 2025-08-04 21:37:32 -07:00
DeWitt Clinton
49001a0f83 Remove the "local modifications" string from bug and about reports. (#5552) 2025-08-05 04:01:19 +00:00
Olcan
11ecf6fc86 fix self-reference in build script (#5548) 2025-08-05 01:12:21 +00:00
Harold Mciver
99ba2f6424 Update MCP client to connect to servers with only prompts (#5290) 2025-08-04 21:38:23 +00:00
christine betts
93f8fe3671 [ide-mode] Add openDiff tool to IDE MCP server (#4519)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-04 21:36:23 +00:00
Mo Moadeli
e7b468e122 feat(cli): Prevent redundant opening of browser tabs when zero MCP servers are configured (#5367)
Co-authored-by: Allen Hutchison <adh@google.com>
2025-08-04 21:20:49 +00:00
Shreya Keshive
dca040908a ide-mode flag cleanup (#5531) 2025-08-04 21:06:50 +00:00
Shreya Keshive
2180dd13dc Improve user-facing error messages for IDE mode (#5522) 2025-08-04 21:06:17 +00:00
Richie Foreman
11808ef7ed fix(core): Allow model to be set from settings.json (#5527) 2025-08-04 20:41:58 +00:00
Sandy Tao
8da6d23688 refactor(core): Rename useSlashCompletion to useCommandCompletion (#5532) 2025-08-04 20:35:26 +00:00
Seth Vargo
37b83e05a7 Use new URLs for downloading workflows (#5524) 2025-08-04 20:10:36 +00:00
Jacob MacDonald
5caf23d627 remove unnecessary checks in WriteFileChecks.getDescription (#5526) 2025-08-04 19:12:33 +00:00
Sandy Tao
d1bfba1abb feat(core): Add trailing space when completing an at completion suggestion (#5475)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-04 18:30:59 +00:00
Sandy Tao
b9fe4fc263 feat(cli): Handle Punctuation in @ Command Parsing (#5482) 2025-08-04 17:49:15 +00:00
Pyush Sinha
e506b40c27 fix: /help remove flickering and respect clear shortcut (ctr+l) (#3611)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: Allen Hutchison <adh@google.com>
2025-08-04 16:53:50 +00:00
owenofbrien
83a04c4755 Cloud Shell surface logging fix (#5364) 2025-08-04 16:48:46 +00:00
matt korwel
94b7b402c5 feat(docs): create new documentation for automation and triage (#5363) 2025-08-04 08:49:14 -07:00
Kumbham Ajay Goud
a8984a9b30 Fix: Preserve conversation history when changing auth methods via /auth (#5216)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-03 22:03:01 +00:00
Ali Al Jufairi
acd48a1259 docs(fix): Update themes documentation to include new color keys for… (#5467) 2025-08-03 21:56:27 +00:00
N. Taylor Mullen
70478b92a9 chore(release): v0.1.16 (#5478) 2025-08-03 13:38:03 -07:00
Shreya Keshive
2cdaf912ba Generate NOTICES.TXT and surface via command (#5310) 2025-08-03 20:19:34 +00:00
Ayesha Shafique
072d8ba289 feat: Add reverse search capability for shell commands (#4793) 2025-08-03 19:53:24 +00:00
Oleksandr Gotgelf
03ed37d0dc fix: exclude DEBUG and DEBUG_MODE from project .env files by default (#5289)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-03 18:44:15 +00:00
Billy Biggs
bedcbb9feb Add a setting to disable the version update nag message (#5449) 2025-08-03 18:20:55 +00:00
Gal Zahavi
820169ba2e feat(autoupdate): Improve update check and refactor for testability (#5389) 2025-08-02 03:17:32 +00:00
TIRUMALASETTI PRANITH
15a1f1af9d fix(config): Resolve duplicate config loading from home directory (#5090)
Co-authored-by: Allen Hutchison <adh@google.com>
Co-authored-by: Allen Hutchison <allen@hutchison.org>
2025-08-01 22:22:17 +00:00
Allen Hutchison
387706607d fix(tests): refactor integration tests to be less flaky (#4890)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2025-08-01 21:33:33 +00:00
mrcabbage972
dccca91fc9 Switch utility calls to use the gemini-2.5-flash-lite model (#5193)
Co-authored-by: Anjali Sridhar <anjsridhar@gmail.com>
2025-08-01 21:11:51 +00:00
owenofbrien
a6a386f72a Propagate prompt (#5033) 2025-08-01 19:37:56 +00:00
joshualitt
67d16992cf bug(cli): Prefer IPv4 dns resolution by default. (#5338) 2025-08-01 19:30:39 +00:00
Santhosh Kumar
9382334a5e feat(github): add workflow to manage stale issues and PRs (#4871)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-01 19:26:03 +00:00
Sandy Tao
c795168e9c feat(core): Use completionStart/End for slash command auto-completion (#5374) 2025-08-01 18:51:38 +00:00
Billy Biggs
24c5a15d7a Add a setting to disable auth mode validation on startup (#5358) 2025-08-01 18:49:03 +00:00
andrea-berling
c725e258c6 feat(sandbox): Add SANDBOX_FLAGS for custom container options (#2036)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2025-08-01 16:32:44 +00:00
Brian de Alwis
d42e3f1e7f doc: use standard Google security policy for GitHub projects (#5062) 2025-08-01 16:12:32 +00:00
Silvio Junior
7748e56153 [Fix Telemetry for tool calls, PR 1/n] Propagate tool reported errors via ToolCallResponseInfo and ToolResult (#5222) 2025-08-01 15:20:08 +00:00
cornmander
e126d2fcd9 Add missing emacs entry in UI. (#5351) 2025-08-01 14:40:05 +00:00
Brian Ray
dc9f17bb4a New browser launcher for MCP OAuth. (#5261) 2025-08-01 05:47:22 +00:00
Sandy Tao
f21ff09389 fix(core): Remove json output schema form the next speaker check prompt (#5325)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-01 01:17:52 +00:00
Raushan Raj
6c3fb18ef6 Update gemini-automated-issue-triage.yml (#5312)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-01 01:14:26 +00:00
Tommaso Sciortino
a3a432e3cf Fix bug executing commands in windows whose flags contain spaces (#5317) 2025-08-01 00:27:07 +00:00
Miguel Solorio
6f7beb414c Highlight slash commands in history (#5323) 2025-07-31 23:24:23 +00:00
Jacob Richman
61e382444a fix(ux) bug in replaceRange dealing with newLines that was breaking vim support (#5320) 2025-07-31 23:16:29 +00:00
Sandy Tao
32809a7be7 feat(cli): Improve @ autocompletion for mid-sentence edits (#5321) 2025-07-31 23:07:12 +00:00
Paige Bailey
37a3f1e6b6 Add emacs support, as per user requests. :) (#1633)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: matt korwel <matt.korwel@gmail.com>
Co-authored-by: matt korwel <mattkorwel@google.com>
2025-07-31 22:46:04 +00:00
JeromeJu
574015edd9 feat: Implement /setup-github command (#5069) 2025-07-31 22:14:22 +00:00
Yuki Okita
f9a05401c1 feat: Multi-Directory Workspace Support (part2: add "directory" command) (#5241) 2025-07-31 19:02:08 +00:00
Niladri Das
9a6422f331 fix: CLAUDE.md compatibility for GEMINI.md '@' file import behavior (#2978)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Allen Hutchison <adh@google.com>
2025-07-31 16:36:50 +00:00
joshualitt
ae86c7ba05 bug(core): UI reporting for truncated read_file. (#5155)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-31 16:31:14 +00:00
anj-s
65be9cab47 Fix: Ensure that non interactive mode and interactive mode are calling the same entry points (#5137) 2025-07-31 12:36:12 +00:00
Sandy Tao
23c014e29c Replace FlashDecidedToContinueEvent with NextSpeakerCheckEvent (#5257) 2025-07-31 04:47:04 +00:00
Kazunari001
3ef2c6d198 feat(docs): Add /init command in commands.md (#5187)
Co-authored-by: saucykazugmail <saucydog0922@gmail.com>
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-31 01:52:40 +00:00
Seth Troisi
c77a22d4c6 Add render counter in debug mode (#5242)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-31 00:43:11 +00:00
Gal Zahavi
d06e17fbd9 Improve error message for discoverTools function (#4157) 2025-07-31 00:16:21 +00:00
Shreya Keshive
0c6f788406 Exclude companion extension from release versioning (#5226)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-30 22:49:26 +00:00
christine betts
325bb89137 Add toggleable IDE mode setting (#5146) 2025-07-30 22:36:24 +00:00
Olcan
ac1bb5ee42 confirm save_memory tool, with ability to see diff and edit manually for advanced changes that may override past memories (#5237) 2025-07-30 22:21:31 +00:00
Allen Hutchison
498edb57ab fix(testing): make ModelStatsDisplay snapshot test deterministic (#5236)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-30 22:09:32 +00:00
christine betts
7bc8766542 Introduce IDE mode installer (#4877) 2025-07-30 21:26:31 +00:00
Yuki Okita
c1fe688956 feat: Multi-Directory Workspace Support (part1: add --include-directories option) (#4605)
Co-authored-by: Allen Hutchison <adh@google.com>
2025-07-30 20:38:20 +00:00
Srinath Padmanabhan
21965f986c Srithreepo Fixes for Scheduled triage (#5158) 2025-07-30 20:38:02 +00:00
shamso-goog
32b1ef3779 feat(ui): Update tool confirmation cancel button text (#4820)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-30 20:37:51 +00:00
Hyunsu Shin
bcce1e7b84 perf(core): parallelize bfsFileSearch for 40% faster CLI startup (#5185) 2025-07-30 17:32:03 +00:00
Olcan
bc23009f61 do not mention GEMINI.md in system prompt as it is not fixed and can confuse model as it is not mentioned by memory tool and memory file paths are generally not exposed to model (yet) (#5202) 2025-07-30 17:21:15 +00:00
yaksh gandhi
b447c329db docs: Update chat command documentation with checkpoint locations (#5027)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
Co-authored-by: F. Hinkelmann <franziska.hinkelmann@gmail.com>
2025-07-30 10:01:08 +00:00
N. Taylor Mullen
fd434626c5 chore(release): v0.1.15 (#5163) 2025-07-29 22:03:54 -07:00
Sandy Tao
8985e489a5 Skip and reset loop checking around code blocks (#5144) 2025-07-30 04:05:03 +00:00
Jenna Inouye
0ce89392b8 Docs: add documentation for .geminiignore (#5123)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-07-30 03:36:26 +00:00
Sambhav Khanna
d5a1b717c2 fix(update): correctly report new updates (#4821)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-30 00:11:15 +00:00
Allen Hutchison
091804c750 feat(docs): document GEMINI.md import syntax (#5145) 2025-07-29 23:41:31 +00:00
Ava
d64c3d6af8 Add Starcraft ref to witty loading phrases (#5120)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-29 23:22:13 +00:00
Tommaso Sciortino
327f915610 Fix typo in RFC 9728 impl (#5126) 2025-07-29 23:03:39 +00:00
Srinath Padmanabhan
008051e42d Update Triage Logic to improve issue categorization. (#5110) 2025-07-29 21:44:48 +00:00
Shreya Keshive
293bb82019 Adds centralized support to log slash commands + sub commands (#5128) 2025-07-29 20:20:37 +00:00
shamso-goog
80079cd2a5 feat(cli): introduce /init command for GEMINI.md creation (#4852)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2025-07-29 16:49:01 +00:00
Daniel Lee
7356764a48 feat(commands): add custom commands support for extensions (#4703) 2025-07-29 01:40:47 +00:00
Gal Zahavi
871e0dfab8 feat: Add auto update functionality (#4686) 2025-07-29 00:56:52 +00:00
Shreya Keshive
83c4dddb7e Only enable IDE integration if gemini-cli is running in the same path as open workspace (#5068) 2025-07-28 20:55:00 +00:00
Seth Troisi
1c1aa047ff feat: Add tests for checkpoint tag sanitization (#4882) 2025-07-28 20:43:39 +00:00
Abhi
b08679c906 Add new fallback state as prefactor for routing (#5065) 2025-07-28 19:55:50 +00:00
Danny
b6c2c64f9b Adds docs outlining keyboard shortcuts for gemini-cli (#4727)
Co-authored-by: dannyzen <dannyrosen@google.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-28 19:35:06 +00:00
Shreya Keshive
cfe3753d4c Refactors companion VS Code extension to import & use notification schema defined in gemini-cli (#5059) 2025-07-28 18:20:56 +00:00
N. Taylor Mullen
9aef0a8e6c Revert "feat: Add /config refresh command" (#5060) 2025-07-28 18:13:46 +00:00
Neha Prasad
a5ea113a8e fix: Clear previous thoughts when starting new prompts (#4966) 2025-07-28 17:57:33 +00:00
christine betts
379765da23 Add documentation for MCP prompts (#4897)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2025-07-28 16:01:15 +00:00
Alexander Parshakov
f7e559223d docs: Add more examples to Popular tasks (#4979)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2025-07-28 15:54:09 +00:00
Ramón Medrano Llamas
0170791800 feat: Add /config refresh command (#4993)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2025-07-28 15:46:43 +00:00
Shreya Keshive
e275441651 Updates schema, UX and prompt for IDE context (#5046) 2025-07-28 15:03:22 +00:00
James Woo
f2e006179d Fix author attribution (#5042) 2025-07-28 14:45:23 +00:00
N. Taylor Mullen
bd85070411 Revert "Propagate user_prompt_id to GenerateConentRequest for logging" (#5007) 2025-07-27 19:28:20 -07:00
Jenna Inouye
9ed351260c Update documentation for read_many_files. (#4874)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-07-27 22:25:04 +00:00
Jenna Inouye
ab0d9df658 Clarify ToS and privacy documentation FAQs. (#4899) 2025-07-27 22:24:53 +00:00
Hiroaki Mitsuyoshi
bce6eb5014 feat(chat): Implement /chat delete command (#2401) 2025-07-27 22:18:12 +00:00
Leeroy Ding
9ca48c00a6 fix: yolo mode not respected (#4972) 2025-07-27 21:42:26 +00:00
Abhi
0b5cc96362 (model) - Use Flash Lite For Next Speaker Checks (#4991) 2025-07-27 21:40:55 +00:00
owenofbrien
b497791c59 Propagate user_prompt_id to GenerateConentRequest for logging (#4741) 2025-07-27 21:34:39 +00:00
Abhi
36e1e57252 (docs) - Fix small markdown mistake for custom commands docs (#4983) 2025-07-27 21:33:58 +00:00
Hyeladi Bassi
a9f04eba2c refactor(telemetry): enhance flushToClearcut method with retry logic and early return for empty events (#1601)
Co-authored-by: Scott Densmore <scottdensmore@mac.com>
2025-07-27 18:18:27 +00:00
116 changed files with 7736 additions and 4162 deletions

View File

@@ -0,0 +1,65 @@
name: Build and Publish Docker Image
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
publish:
description: 'Publish to GHCR (only works on main branch)'
type: boolean
default: false
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-to-ghcr:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=sha-,format=short
- name: Log in to the Container registry
if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event.inputs.publish == 'true') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
CLI_VERSION_ARG=${{ github.sha }}

View File

@@ -24,7 +24,7 @@ jobs:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
with:
version: 0.0.5
version: 0.0.6
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
settings_json: |
{

View File

@@ -42,7 +42,7 @@ jobs:
ISSUES_TO_TRIAGE: ${{ steps.find_issues.outputs.issues_to_triage }}
REPOSITORY: ${{ github.repository }}
with:
version: 0.0.5
version: 0.0.6
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}

View File

@@ -18,7 +18,11 @@ jobs:
review-pr:
if: >
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && github.event.action == 'opened') ||
(github.event_name == 'pull_request_target' &&
github.event.action == 'opened' &&
(github.event.pull_request.author_association == 'OWNER' ||
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'COLLABORATOR')) ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '@qwen /review') &&
@@ -49,9 +53,9 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Get PR details (pull_request & workflow_dispatch)
- name: Get PR details (pull_request_target & workflow_dispatch)
id: get_pr
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
if: github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |

View File

@@ -1,3 +1,31 @@
# Build stage
FROM docker.io/library/node:20-slim AS builder
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
make \
g++ \
git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Set up npm global package folder
RUN mkdir -p /usr/local/share/npm-global
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
ENV PATH=$PATH:/usr/local/share/npm-global/bin
# Copy source code
COPY . /home/node/app
WORKDIR /home/node/app
# Install dependencies and build packages
RUN npm ci \
&& npm run build --workspaces \
&& npm pack -w @qwen-code/qwen-code --pack-destination ./packages/cli/dist \
&& npm pack -w @qwen-code/qwen-code-core --pack-destination ./packages/core/dist
# Runtime stage
FROM docker.io/library/node:20-slim
ARG SANDBOX_NAME="qwen-code-sandbox"
@@ -5,11 +33,9 @@ ARG CLI_VERSION_ARG
ENV SANDBOX="$SANDBOX_NAME"
ENV CLI_VERSION=$CLI_VERSION_ARG
# install minimal set of packages, then clean up
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
make \
g++ \
man-db \
curl \
dnsutils \
@@ -29,22 +55,19 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# set up npm global package folder under /usr/local/share
# give it to non-root user node, already set up in base image
RUN mkdir -p /usr/local/share/npm-global \
&& chown -R node:node /usr/local/share/npm-global
# Set up npm global package folder
RUN mkdir -p /usr/local/share/npm-global
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
ENV PATH=$PATH:/usr/local/share/npm-global/bin
# switch to non-root user node
USER node
# Copy built packages from builder stage
COPY --from=builder /home/node/app/packages/cli/dist/*.tgz /tmp/
COPY --from=builder /home/node/app/packages/core/dist/*.tgz /tmp/
# install qwen-code and clean up
COPY packages/cli/dist/qwen-code-*.tgz /usr/local/share/npm-global/qwen-code.tgz
COPY packages/core/dist/qwen-code-qwen-code-core-*.tgz /usr/local/share/npm-global/qwen-code-core.tgz
RUN npm install -g /usr/local/share/npm-global/qwen-code.tgz /usr/local/share/npm-global/qwen-code-core.tgz \
# Install built packages globally
RUN npm install -g /tmp/*.tgz \
&& npm cache clean --force \
&& rm -f /usr/local/share/npm-global/qwen-{code,code-core}.tgz
&& rm -rf /tmp/*.tgz
# default entrypoint when none specified
CMD ["qwen"]
# Default entrypoint when none specified
CMD ["qwen"]

View File

@@ -56,7 +56,7 @@ find initiatives that interest you.
Gemini CLI is an open-source project, and we welcome contributions from the community! Whether you're a developer, a designer, or just an enthusiastic user you can find our [Community Guidelines here](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md) to learn how to get started. There are many ways to get involved:
- **Roadmap:** Please review and find areas in our [roadmap](https://github.com/google-gemini/gemini-cli/issues/4191) that you would like to contribute to. Contributions based on this will be easiest to integrate with.
- **Report Bugs:** If you find an issue, please create a bug(https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml) with as much detail as possible. If you believe it is a critical breaking issue preventing direct CLI usage, please tag it as `priorty/p0`.
- **Report Bugs:** If you find an issue, please create a [bug](https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml) with as much detail as possible. If you believe it is a critical breaking issue preventing direct CLI usage, please tag it as `priority/p0`.
- **Suggest Features:** Have a great idea? We'd love to hear it! Open a [feature request](https://github.com/google-gemini/gemini-cli/issues/new?template=feature_request.yml).
- **Contribute Code:** Check out our [CONTRIBUTING.md](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md) file for guidelines on how to submit pull requests. We have a list of "good first issues" for new contributors.
- **Write Documentation:** Help us improve our documentation, tutorials, and examples.

View File

@@ -27,6 +27,9 @@ Slash commands provide meta-level control over the CLI itself.
- **Usage:** `/chat resume <tag>`
- **`list`**
- **Description:** Lists available tags for chat state resumption.
- **`delete`**
- **Description:** Deletes a saved conversation checkpoint.
- **Usage:** `/chat delete <tag>`
- **`/clear`**
- **Description:** Clear the terminal screen, including the visible session history and scrollback within the CLI. The underlying session data (for history recall) might be preserved depending on the exact implementation, but the visual display is cleared.
@@ -49,6 +52,17 @@ Slash commands provide meta-level control over the CLI itself.
- **Description:** Display all directories added by `/directory add` and `--include-directories`.
- **Usage:** `/directory show`
- **`/directory`** (or **`/dir`**)
- **Description:** Manage workspace directories for multi-directory support.
- **Sub-commands:**
- **`add`**:
- **Description:** Add a directory to the workspace. The path can be absolute or relative to the current working directory. Moreover, the reference from home directory is supported as well.
- **Usage:** `/directory add <path1>,<path2>`
- **Note:** Disabled in restrictive sandbox profiles. If you're using that, use `--include-directories` when starting the session instead.
- **`show`**:
- **Description:** Display all directories added by `/directory add` and `--include-directories`.
- **Usage:** `/directory show`
- **`/editor`**
- **Description:** Open a dialog for selecting supported editors.
@@ -253,7 +267,7 @@ Please generate a Conventional Commit message based on the following git diff:
```diff
!{git diff --staged}
````
```
"""
@@ -274,7 +288,7 @@ First, ensure the user commands directory exists, then create a `refactor` subdi
```bash
mkdir -p ~/.gemini/commands/refactor
touch ~/.gemini/commands/refactor/pure.toml
````
```
**2. Add the content to the file:**

View File

@@ -248,6 +248,31 @@ In addition to a project settings file, a project's `.gemini` directory can cont
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"]
```
- **`includeDirectories`** (array of strings):
- **Description:** Specifies an array of additional absolute or relative paths to include in the workspace context. This allows you to work with files across multiple directories as if they were one. Paths can use `~` to refer to the user's home directory. This setting can be combined with the `--include-directories` command-line flag.
- **Default:** `[]`
- **Example:**
```json
"includeDirectories": [
"/path/to/another/project",
"../shared-library",
"~/common-utils"
]
```
- **`loadMemoryFromIncludeDirectories`** (boolean):
- **Description:** Controls the behavior of the `/memory refresh` command. If set to `true`, `QWEN.md` files should be loaded from all directories that are added. If set to `false`, `QWEN.md` should only be loaded from the current directory.
- **Default:** `false`
- **Example:**
```json
"loadMemoryFromIncludeDirectories": true
```
- **`tavilyApiKey`** (string):
- **Description:** API key for Tavily web search service. Required to enable the `web_search` tool functionality. If not configured, the web search tool will be disabled and skipped.
- **Default:** `undefined` (web search disabled)
- **Example:** `"tavilyApiKey": "tvly-your-api-key-here"`
### Example `settings.json`:
```json
@@ -256,6 +281,7 @@ In addition to a project settings file, a project's `.gemini` directory can cont
"sandbox": "docker",
"toolDiscoveryCommand": "bin/get_tools",
"toolCallCommand": "bin/call_tool",
"tavilyApiKey": "$TAVILY_API_KEY",
"mcpServers": {
"mainServer": {
"command": "bin/mcp_server.py"
@@ -280,7 +306,9 @@ In addition to a project settings file, a project's `.gemini` directory can cont
"tokenBudget": 100
}
},
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"]
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"],
"includeDirectories": ["path/to/dir1", "~/path/to/dir2", "../path/to/dir3"],
"loadMemoryFromIncludeDirectories": true
}
```
@@ -351,6 +379,11 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
- **`CODE_ASSIST_ENDPOINT`**:
- Specifies the endpoint for the code assist server.
- This is useful for development and testing.
- **`TAVILY_API_KEY`**:
- Your API key for the Tavily web search service.
- Required to enable the `web_search` tool functionality.
- If not configured, the web search tool will be disabled and skipped.
- Example: `export TAVILY_API_KEY="tvly-your-api-key-here"`
## Command-Line Arguments
@@ -408,6 +441,9 @@ Arguments passed directly when running the CLI can override other configurations
- Displays the version of the CLI.
- **`--openai-logging`**:
- Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`.
- **`--tavily-api-key <api_key>`**:
- Sets the Tavily API key for web search functionality for this session.
- Example: `gemini --tavily-api-key tvly-your-api-key-here`
## Context Files (Hierarchical Instructional Context)

View File

@@ -15,9 +15,11 @@ The Gemini CLI core (`packages/core`) features a robust system for defining, reg
- `execute()`: The core method that performs the tool's action and returns a `ToolResult`.
- **`ToolResult` (`tools.ts`):** An interface defining the structure of a tool's execution outcome:
- `llmContent`: The factual string content to be included in the history sent back to the LLM for context.
- `llmContent`: The factual content to be included in the history sent back to the LLM for context. This can be a simple string or a `PartListUnion` (an array of `Part` objects and strings) for rich content.
- `returnDisplay`: A user-friendly string (often Markdown) or a special object (like `FileDiff`) for display in the CLI.
- **Returning Rich Content:** Tools are not limited to returning simple text. The `llmContent` can be a `PartListUnion`, which is an array that can contain a mix of `Part` objects (for images, audio, etc.) and `string`s. This allows a single tool execution to return multiple pieces of rich content.
- **Tool Registry (`tool-registry.ts`):** A class (`ToolRegistry`) responsible for:
- **Registering Tools:** Holding a collection of all available built-in tools (e.g., `ReadFileTool`, `ShellTool`).
- **Discovering Tools:** It can also discover tools dynamically:

View File

@@ -28,7 +28,7 @@ This documentation is organized into the following sections:
- **[Multi-File Read Tool](./tools/multi-file.md):** Documentation for the `read_many_files` tool.
- **[Shell Tool](./tools/shell.md):** Documentation for the `run_shell_command` tool.
- **[Web Fetch Tool](./tools/web-fetch.md):** Documentation for the `web_fetch` tool.
- **[Web Search Tool](./tools/web-search.md):** Documentation for the `google_web_search` tool.
- **[Web Search Tool](./tools/web-search.md):** Documentation for the `web_search` tool.
- **[Memory Tool](./tools/memory.md):** Documentation for the `save_memory` tool.
- **[Contributing & Development Guide](../CONTRIBUTING.md):** Information for contributors and developers, including setup, building, testing, and coding conventions.
- **[NPM Workspaces and Publishing](./npm.md):** Details on how the project's packages are managed and published.

View File

@@ -169,6 +169,7 @@ Use the `/mcp auth` command to manage OAuth authentication:
- **`scopes`** (string[]): Required OAuth scopes
- **`redirectUri`** (string): Custom redirect URI (defaults to `http://localhost:7777/oauth/callback`)
- **`tokenParamName`** (string): Query parameter name for tokens in SSE URLs
- **`audiences`** (string[]): Audiences the token is valid for
#### Token Management
@@ -571,6 +572,56 @@ The MCP integration tracks several states:
This comprehensive integration makes MCP servers a powerful way to extend the Gemini CLI's capabilities while maintaining security, reliability, and ease of use.
## Returning Rich Content from Tools
MCP tools are not limited to returning simple text. You can return rich, multi-part content, including text, images, audio, and other binary data in a single tool response. This allows you to build powerful tools that can provide diverse information to the model in a single turn.
All data returned from the tool is processed and sent to the model as context for its next generation, enabling it to reason about or summarize the provided information.
### How It Works
To return rich content, your tool's response must adhere to the MCP specification for a [`CallToolResult`](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#tool-result). The `content` field of the result should be an array of `ContentBlock` objects. The Gemini CLI will correctly process this array, separating text from binary data and packaging it for the model.
You can mix and match different content block types in the `content` array. The supported block types include:
- `text`
- `image`
- `audio`
- `resource` (embedded content)
- `resource_link`
### Example: Returning Text and an Image
Here is an example of a valid JSON response from an MCP tool that returns both a text description and an image:
```json
{
"content": [
{
"type": "text",
"text": "Here is the logo you requested."
},
{
"type": "image",
"data": "BASE64_ENCODED_IMAGE_DATA_HERE",
"mimeType": "image/png"
},
{
"type": "text",
"text": "The logo was created in 2025."
}
]
}
```
When the Gemini CLI receives this response, it will:
1. Extract all the text and combine it into a single `functionResponse` part for the model.
2. Present the image data as a separate `inlineData` part.
3. Provide a clean, user-friendly summary in the CLI, indicating that both text and an image were received.
This enables you to build sophisticated tools that can provide rich, multi-modal context to the Gemini model.
## MCP Prompts as Slash Commands
In addition to tools, MCP servers can expose predefined prompts that can be executed as slash commands within the Gemini CLI. This allows you to create shortcuts for common or complex queries that can be easily invoked by name.

View File

@@ -52,7 +52,7 @@ Read the main README, all Markdown files in the `docs` directory, and a specific
read_many_files(paths=["README.md", "docs/**/*.md", "assets/logo.png"], exclude=["docs/OLD_README.md"])
```
Read all JavaScript files but explicitly including test files and all JPEGs in an `images` folder:
Read all JavaScript files but explicitly include test files and all JPEGs in an `images` folder:
```
read_many_files(paths=["**/*.js"], include=["**/*.test.js", "images/**/*.jpg"], useDefaultExcludes=False)

View File

@@ -137,6 +137,5 @@ To block all shell commands, add the `run_shell_command` wildcard to `excludeToo
## Security Note for `excludeTools`
Command-specific restrictions in
`excludeTools` for `run_shell_command` are based on simple string matching and can be easily bypassed. This feature is **not a security mechanism** and should not be relied upon to safely execute untrusted code. It is recommended to use `coreTools` to explicitly select commands
Command-specific restrictions in `excludeTools` for `run_shell_command` are based on simple string matching and can be easily bypassed. This feature is **not a security mechanism** and should not be relied upon to safely execute untrusted code. It is recommended to use `coreTools` to explicitly select commands
that can be executed.

View File

@@ -4,24 +4,25 @@ This document describes the `web_fetch` tool for the Gemini CLI.
## Description
Use `web_fetch` to summarize, compare, or extract information from web pages. The `web_fetch` tool processes content from one or more URLs (up to 20) embedded in a prompt. `web_fetch` takes a natural language prompt and returns a generated response.
Use `web_fetch` to fetch content from a specified URL and process it using an AI model. The tool takes a URL and a prompt as input, fetches the URL content, converts HTML to markdown, and processes the content with the prompt using a small, fast model.
### Arguments
`web_fetch` takes one argument:
`web_fetch` takes two arguments:
- `prompt` (string, required): A comprehensive prompt that includes the URL(s) (up to 20) to fetch and specific instructions on how to process their content. For example: `"Summarize https://example.com/article and extract key points from https://another.com/data"`. The prompt must contain at least one URL starting with `http://` or `https://`.
- `url` (string, required): The URL to fetch content from. Must be a fully-formed valid URL starting with `http://` or `https://`.
- `prompt` (string, required): The prompt describing what information you want to extract from the page content.
## How to use `web_fetch` with the Gemini CLI
To use `web_fetch` with the Gemini CLI, provide a natural language prompt that contains URLs. The tool will ask for confirmation before fetching any URLs. Once confirmed, the tool will process URLs through Gemini API's `urlContext`.
To use `web_fetch` with the Gemini CLI, provide a URL and a prompt describing what you want to extract from that URL. The tool will ask for confirmation before fetching the URL. Once confirmed, the tool will fetch the content directly and process it using an AI model.
If the Gemini API cannot access the URL, the tool will fall back to fetching content directly from the local machine. The tool will format the response, including source attribution and citations where possible. The tool will then provide the response to the user.
The tool automatically converts HTML to text, handles GitHub blob URLs (converting them to raw URLs), and upgrades HTTP URLs to HTTPS for security.
Usage:
```
web_fetch(prompt="Your prompt, including a URL such as https://google.com.")
web_fetch(url="https://example.com", prompt="Summarize the main points of this article")
```
## `web_fetch` examples
@@ -29,16 +30,25 @@ web_fetch(prompt="Your prompt, including a URL such as https://google.com.")
Summarize a single article:
```
web_fetch(prompt="Can you summarize the main points of https://example.com/news/latest")
web_fetch(url="https://example.com/news/latest", prompt="Can you summarize the main points of this article?")
```
Compare two articles:
Extract specific information:
```
web_fetch(prompt="What are the differences in the conclusions of these two papers: https://arxiv.org/abs/2401.0001 and https://arxiv.org/abs/2401.0002?")
web_fetch(url="https://arxiv.org/abs/2401.0001", prompt="What are the key findings and methodology described in this paper?")
```
Analyze GitHub documentation:
```
web_fetch(url="https://github.com/google/gemini-react/blob/main/README.md", prompt="What are the installation steps and main features?")
```
## Important notes
- **URL processing:** `web_fetch` relies on the Gemini API's ability to access and process the given URLs.
- **Single URL processing:** `web_fetch` processes one URL at a time. To analyze multiple URLs, make separate calls to the tool.
- **URL format:** The tool automatically upgrades HTTP URLs to HTTPS and converts GitHub blob URLs to raw format for better content access.
- **Content processing:** The tool fetches content directly and processes it using an AI model, converting HTML to readable text format.
- **Output quality:** The quality of the output will depend on the clarity of the instructions in the prompt.
- **MCP tools:** If an MCP-provided web fetch tool is available (starting with "mcp\_\_"), prefer using that tool as it may have fewer restrictions.

View File

@@ -1,36 +1,43 @@
# Web Search Tool (`google_web_search`)
# Web Search Tool (`web_search`)
This document describes the `google_web_search` tool.
This document describes the `web_search` tool.
## Description
Use `google_web_search` to perform a web search using Google Search via the Gemini API. The `google_web_search` tool returns a summary of web results with sources.
Use `web_search` to perform a web search using the Tavily API. The tool returns a concise answer with sources when possible.
### Arguments
`google_web_search` takes one argument:
`web_search` takes one argument:
- `query` (string, required): The search query.
## How to use `google_web_search` with the Gemini CLI
## How to use `web_search`
The `google_web_search` tool sends a query to the Gemini API, which then performs a web search. `google_web_search` will return a generated response based on the search results, including citations and sources.
`web_search` calls the Tavily API directly. You must configure the `TAVILY_API_KEY` through one of the following methods:
1. **Settings file**: Add `"tavilyApiKey": "your-key-here"` to your `settings.json`
2. **Environment variable**: Set `TAVILY_API_KEY` in your environment or `.env` file
3. **Command line**: Use `--tavily-api-key your-key-here` when running the CLI
If the key is not configured, the tool will be disabled and skipped.
Usage:
```
google_web_search(query="Your query goes here.")
web_search(query="Your query goes here.")
```
## `google_web_search` examples
## `web_search` examples
Get information on a topic:
```
google_web_search(query="latest advancements in AI-powered code generation")
web_search(query="latest advancements in AI-powered code generation")
```
## Important notes
- **Response returned:** The `google_web_search` tool returns a processed summary, not a raw list of search results.
- **Citations:** The response includes citations to the sources used to generate the summary.
- **Response returned:** The `web_search` tool returns a concise answer when available, with a list of source links.
- **Citations:** Source links are appended as a numbered list.
- **API key:** Configure `TAVILY_API_KEY` via settings.json, environment variables, .env files, or command line arguments. If not configured, the tool is not registered.

View File

@@ -1,28 +1,38 @@
# Troubleshooting Guide
# Troubleshooting guide
This guide provides solutions to common issues and debugging tips.
This guide provides solutions to common issues and debugging tips, including topics on:
## Authentication
- Authentication or login errors
- Frequently asked questions (FAQs)
- Debugging tips
- Existing GitHub Issues similar to yours or creating new Issues
## Authentication or login errors
- **Error: `Failed to login. Message: Request contains an invalid argument`**
- Users with Google Workspace accounts, or users with Google Cloud accounts
- Users with Google Workspace accounts or Google Cloud accounts
associated with their Gmail accounts may not be able to activate the free
tier of the Google Code Assist plan.
- For Google Cloud accounts, you can work around this by setting
`GOOGLE_CLOUD_PROJECT` to your project ID.
- You can also grab an API key from [AI Studio](https://aistudio.google.com/app/apikey), which also includes a
- Alternatively, you can obtain the Gemini API key from
[Google AI Studio](http://aistudio.google.com/app/apikey), which also includes a
separate free tier.
## Frequently asked questions (FAQs)
- **Q: How do I update Gemini CLI to the latest version?**
- A: If installed globally via npm, update Gemini CLI using the command `npm install -g @google/gemini-cli@latest`. If run from source, pull the latest changes from the repository and rebuild using `npm run build`.
- A: If you installed it globally via `npm`, update it using the command `npm install -g @google/gemini-cli@latest`. If you compiled it from source, pull the latest changes from the repository, and then rebuild using the command `npm run build`.
- **Q: Where are Gemini CLI configuration files stored?**
- A: The CLI configuration is stored within two `settings.json` files: one in your home directory and one in your project's root directory. In both locations, `settings.json` is found in the `.gemini/` folder. Refer to [CLI Configuration](./cli/configuration.md) for more details.
- **Q: Where are the Gemini CLI configuration or settings files stored?**
- A: The Gemini CLI configuration is stored in two `settings.json` files:
1. In your home directory: `~/.gemini/settings.json`.
2. In your project's root directory: `./.gemini/settings.json`.
Refer to [Gemini CLI Configuration](./cli/configuration.md) for more details.
- **Q: Why don't I see cached token counts in my stats output?**
- A: Cached token information is only displayed when cached tokens are being used. This feature is available for API key users (Gemini API key or Vertex AI) but not for OAuth users (Google Personal/Enterprise accounts) at this time, as the Code Assist API does not support cached content creation. You can still view your total token usage with the `/stats` command.
- A: Cached token information is only displayed when cached tokens are being used. This feature is available for API key users (Gemini API key or Google Cloud Vertex AI) but not for OAuth users (such as Google Personal/Enterprise accounts like Google Gmail or Google Workspace, respectively). This is because the Gemini Code Assist API does not support cached content creation. You can still view your total token usage using the `/stats` command in Gemini CLI.
## Common error messages and solutions
@@ -31,26 +41,27 @@ This guide provides solutions to common issues and debugging tips.
- **Solution:**
Either stop the other process that is using the port or configure the MCP server to use a different port.
- **Error: Command not found (when attempting to run Gemini CLI).**
- **Cause:** Gemini CLI is not correctly installed or not in your system's PATH.
- **Error: Command not found (when attempting to run Gemini CLI with `gemini`).**
- **Cause:** Gemini CLI is not correctly installed or it is not in your system's `PATH`.
- **Solution:**
1. Ensure Gemini CLI installation was successful.
2. If installed globally, check that your npm global binary directory is in your PATH.
3. If running from source, ensure you are using the correct command to invoke it (e.g., `node packages/cli/dist/index.js ...`).
The update depends on how you installed Gemini CLI:
- If you installed `gemini` globally, check that your `npm` global binary directory is in your `PATH`. You can update Gemini CLI using the command `npm install -g @google/gemini-cli@latest`.
- If you are running `gemini` from source, ensure you are using the correct command to invoke it (e.g., `node packages/cli/dist/index.js ...`). To update Gemini CLI, pull the latest changes from the repository, and then rebuild using the command `npm run build`.
- **Error: `MODULE_NOT_FOUND` or import errors.**
- **Cause:** Dependencies are not installed correctly, or the project hasn't been built.
- **Solution:**
1. Run `npm install` to ensure all dependencies are present.
2. Run `npm run build` to compile the project.
3. Verify that the build completed successfully with `npm run start`.
- **Error: "Operation not permitted", "Permission denied", or similar.**
- **Cause:** If sandboxing is enabled, then the application is likely attempting an operation restricted by your sandbox, such as writing outside the project directory or system temp directory.
- **Solution:** See [Sandboxing](./cli/configuration.md#sandboxing) for more information, including how to customize your sandbox configuration.
- **Cause:** When sandboxing is enabled, Gemini CLI may attempt operations that are restricted by your sandbox configuration, such as writing outside the project directory or system temp directory.
- **Solution:** Refer to the [Configuration: Sandboxing](./cli/configuration.md#sandboxing) documentation for more information, including how to customize your sandbox configuration.
- **CLI is not interactive in "CI" environments**
- **Issue:** The CLI does not enter interactive mode (no prompt appears) if an environment variable starting with `CI_` (e.g., `CI_TOKEN`) is set. This is because the `is-in-ci` package, used by the underlying UI framework, detects these variables and assumes a non-interactive CI environment.
- **Cause:** The `is-in-ci` package checks for the presence of `CI`, `CONTINUOUS_INTEGRATION`, or any environment variable with a `CI_` prefix. When any of these are found, it signals that the environment is non-interactive, which prevents the CLI from starting in its interactive mode.
- **Gemini CLI is not running in interactive mode in "CI" environments**
- **Issue:** The Gemini CLI does not enter interactive mode (no prompt appears) if an environment variable starting with `CI_` (e.g., `CI_TOKEN`) is set. This is because the `is-in-ci` package, used by the underlying UI framework, detects these variables and assumes a non-interactive CI environment.
- **Cause:** The `is-in-ci` package checks for the presence of `CI`, `CONTINUOUS_INTEGRATION`, or any environment variable with a `CI_` prefix. When any of these are found, it signals that the environment is non-interactive, which prevents the Gemini CLI from starting in its interactive mode.
- **Solution:** If the `CI_` prefixed variable is not needed for the CLI to function, you can temporarily unset it for the command. e.g., `env -u CI_TOKEN gemini`
- **DEBUG mode not working from project .env file**
@@ -72,9 +83,11 @@ This guide provides solutions to common issues and debugging tips.
- **Tool issues:**
- If a specific tool is failing, try to isolate the issue by running the simplest possible version of the command or operation the tool performs.
- For `run_shell_command`, check that the command works directly in your shell first.
- For file system tools, double-check paths and permissions.
- For _file system tools_, verify that paths are correct and check the permissions.
- **Pre-flight checks:**
- Always run `npm run preflight` before committing code. This can catch many common issues related to formatting, linting, and type errors.
If you encounter an issue not covered here, consider searching the project's issue tracker on GitHub or reporting a new issue with detailed information.
## Existing GitHub Issues similar to yours or creating new Issues
If you encounter an issue that was not covered here in this _Troubleshooting guide_, consider searching the Gemini CLI [Issue tracker on GitHub](https://github.com/google-gemini/gemini-cli/issues). If you can't find an issue similar to yours, consider creating a new GitHub Issue with a detailed description. Pull requests are also welcome!

View File

@@ -9,6 +9,11 @@ import { strict as assert } from 'assert';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
test('should be able to search the web', async () => {
// Skip if Tavily key is not configured
if (!process.env.TAVILY_API_KEY) {
console.warn('Skipping web search test: TAVILY_API_KEY not set');
return;
}
const rig = new TestRig();
await rig.setup('should be able to search the web');
@@ -27,7 +32,7 @@ test('should be able to search the web', async () => {
throw error; // Re-throw if not a network error
}
const foundToolCall = await rig.waitForToolCall('google_web_search');
const foundToolCall = await rig.waitForToolCall('web_search');
// Add debugging information
if (!foundToolCall) {
@@ -35,12 +40,11 @@ test('should be able to search the web', async () => {
// Check if the tool call failed due to network issues
const failedSearchCalls = allTools.filter(
(t) =>
t.toolRequest.name === 'google_web_search' && !t.toolRequest.success,
(t) => t.toolRequest.name === 'web_search' && !t.toolRequest.success,
);
if (failedSearchCalls.length > 0) {
console.warn(
'google_web_search tool was called but failed, possibly due to network issues',
'web_search tool was called but failed, possibly due to network issues',
);
console.warn(
'Failed calls:',
@@ -50,20 +54,20 @@ test('should be able to search the web', async () => {
}
}
assert.ok(foundToolCall, 'Expected to find a call to google_web_search');
assert.ok(foundToolCall, 'Expected to find a call to web_search');
// Validate model output - will throw if no output, warn if missing expected content
const hasExpectedContent = validateModelOutput(
result,
['weather', 'london'],
'Google web search test',
'Web search test',
);
// If content was missing, log the search queries used
if (!hasExpectedContent) {
const searchCalls = rig
.readToolLogs()
.filter((t) => t.toolRequest.name === 'google_web_search');
.filter((t) => t.toolRequest.name === 'web_search');
if (searchCalls.length > 0) {
console.warn(
'Search queries used:',

2692
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.5",
"version": "0.0.7",
"engines": {
"node": ">=20.0.0"
},
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.5"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.7"
},
"scripts": {
"start": "node scripts/start.js",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.5",
"version": "0.0.7",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.5"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.7"
},
"dependencies": {
"@google/genai": "1.9.0",
@@ -75,7 +75,8 @@
"pretty-format": "^30.0.2",
"react-dom": "^19.1.0",
"typescript": "^5.3.3",
"vitest": "^3.1.1"
"vitest": "^3.1.1",
"@qwen-code/qwen-code-test-utils": "file:../test-utils"
},
"engines": {
"node": ">=20"

View File

@@ -6,7 +6,9 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as os from 'os';
import { loadCliConfig, parseArguments, CliArgs } from './config.js';
import * as fs from 'fs';
import * as path from 'path';
import { loadCliConfig, parseArguments } from './config.js';
import { Settings } from './settings.js';
import { Extension } from './extension.js';
import * as ServerConfig from '@qwen-code/qwen-code-core';
@@ -44,7 +46,7 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
},
loadEnvironment: vi.fn(),
loadServerHierarchicalMemory: vi.fn(
(cwd, debug, fileService, extensionPaths, _maxDirs) =>
(cwd, dirs, debug, fileService, extensionPaths, _maxDirs) =>
Promise.resolve({
memoryContent: extensionPaths?.join(',') || '',
fileCount: extensionPaths?.length || 0,
@@ -499,6 +501,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
await loadCliConfig(settings, extensions, 'session-id', argv);
expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith(
expect.any(String),
[],
false,
expect.any(Object),
[
@@ -1078,14 +1081,86 @@ describe('loadCliConfig ideModeFeature', () => {
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeModeFeature()).toBe(false);
});
});
it('should be false when settings.ideModeFeature is true, but SANDBOX is set', async () => {
process.argv = ['node', 'script.js'];
vi.mock('fs', async () => {
const actualFs = await vi.importActual<typeof fs>('fs');
const MOCK_CWD1 = process.cwd();
const MOCK_CWD2 = path.resolve(path.sep, 'home', 'user', 'project');
const mockPaths = new Set([
MOCK_CWD1,
MOCK_CWD2,
path.resolve(path.sep, 'cli', 'path1'),
path.resolve(path.sep, 'settings', 'path1'),
path.join(os.homedir(), 'settings', 'path2'),
path.join(MOCK_CWD2, 'cli', 'path2'),
path.join(MOCK_CWD2, 'settings', 'path3'),
]);
return {
...actualFs,
existsSync: vi.fn((p) => mockPaths.has(p.toString())),
statSync: vi.fn((p) => {
if (mockPaths.has(p.toString())) {
return { isDirectory: () => true };
}
// Fallback for other paths if needed, though the test should be specific.
return actualFs.statSync(p);
}),
realpathSync: vi.fn((p) => p),
};
});
describe('loadCliConfig with includeDirectories', () => {
const originalArgv = process.argv;
const originalEnv = { ...process.env };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
process.env.GEMINI_API_KEY = 'test-api-key';
vi.spyOn(process, 'cwd').mockReturnValue(
path.resolve(path.sep, 'home', 'user', 'project'),
);
});
afterEach(() => {
process.argv = originalArgv;
process.env = originalEnv;
vi.restoreAllMocks();
});
it('should combine and resolve paths from settings and CLI arguments', async () => {
const mockCwd = path.resolve(path.sep, 'home', 'user', 'project');
process.argv = [
'node',
'script.js',
'--include-directories',
`${path.resolve(path.sep, 'cli', 'path1')},${path.join(mockCwd, 'cli', 'path2')}`,
];
const argv = await parseArguments();
process.env.TERM_PROGRAM = 'vscode';
process.env.SANDBOX = 'true';
const settings: Settings = { ideModeFeature: true };
const settings: Settings = {
includeDirectories: [
path.resolve(path.sep, 'settings', 'path1'),
path.join(os.homedir(), 'settings', 'path2'),
path.join(mockCwd, 'settings', 'path3'),
],
};
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeModeFeature()).toBe(false);
const expected = [
mockCwd,
path.resolve(path.sep, 'cli', 'path1'),
path.join(mockCwd, 'cli', 'path2'),
path.resolve(path.sep, 'settings', 'path1'),
path.join(os.homedir(), 'settings', 'path2'),
path.join(mockCwd, 'settings', 'path3'),
];
expect(config.getWorkspaceContext().getDirectories()).toEqual(
expect.arrayContaining(expected),
);
expect(config.getWorkspaceContext().getDirectories()).toHaveLength(
expected.length,
);
});
});

View File

@@ -22,13 +22,13 @@ import {
FileDiscoveryService,
TelemetryTarget,
FileFilteringOptions,
IdeClient,
} from '@qwen-code/qwen-code-core';
import { Settings } from './settings.js';
import { Extension, annotateActiveExtensions } from './extension.js';
import { getCliVersion } from '../utils/version.js';
import { loadSandboxConfig } from './sandboxConfig.js';
import { resolvePath } from '../utils/resolvePath.js';
// Simple console logger for now - replace with actual logger if available
const logger = {
@@ -68,6 +68,8 @@ export interface CliArgs {
openaiBaseUrl: string | undefined;
proxy: string | undefined;
includeDirectories: string[] | undefined;
loadMemoryFromIncludeDirectories: boolean | undefined;
tavilyApiKey: string | undefined;
}
export async function parseArguments(): Promise<CliArgs> {
@@ -214,6 +216,10 @@ export async function parseArguments(): Promise<CliArgs> {
type: 'string',
description: 'OpenAI base URL (for custom endpoints)',
})
.option('tavily-api-key', {
type: 'string',
description: 'Tavily API key for web search functionality',
})
.option('proxy', {
type: 'string',
description:
@@ -228,6 +234,12 @@ export async function parseArguments(): Promise<CliArgs> {
// Handle comma-separated values
dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())),
})
.option('load-memory-from-include-directories', {
type: 'boolean',
description:
'If true, when refreshing memory, QWEN.md files should be loaded from all directories that are added. If false, QWEN.md files should only be loaded from the primary working directory.',
default: false,
})
.version(await getCliVersion()) // This will enable the --version flag based on package.json
.alias('v', 'version')
.help()
@@ -255,6 +267,7 @@ export async function parseArguments(): Promise<CliArgs> {
// TODO: Consider if App.tsx should get memory via a server call or if Config should refresh itself.
export async function loadHierarchicalGeminiMemory(
currentWorkingDirectory: string,
includeDirectoriesToReadGemini: readonly string[] = [],
debugMode: boolean,
fileService: FileDiscoveryService,
settings: Settings,
@@ -280,6 +293,7 @@ export async function loadHierarchicalGeminiMemory(
// Directly call the server function with the corrected path.
return loadServerHierarchicalMemory(
effectiveCwd,
includeDirectoriesToReadGemini,
debugMode,
fileService,
extensionContextFilePaths,
@@ -302,13 +316,10 @@ export async function loadCliConfig(
) ||
false;
const memoryImportFormat = settings.memoryImportFormat || 'tree';
const ideMode = settings.ideMode ?? false;
const ideModeFeature =
(argv.ideModeFeature ?? settings.ideModeFeature ?? false) &&
!process.env.SANDBOX;
const ideClient = IdeClient.getInstance(ideMode && ideModeFeature);
argv.ideModeFeature ?? settings.ideModeFeature ?? false;
const allExtensions = annotateActiveExtensions(
extensions,
@@ -328,6 +339,11 @@ export async function loadCliConfig(
process.env.OPENAI_BASE_URL = argv.openaiBaseUrl;
}
// Handle Tavily API key from command line
if (argv.tavilyApiKey) {
process.env.TAVILY_API_KEY = argv.tavilyApiKey;
}
// Set the context filename in the server's memoryTool module BEFORE loading memory
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
// directly to the Config constructor in core, and have core handle setGeminiMdFilename.
@@ -350,9 +366,14 @@ export async function loadCliConfig(
...settings.fileFiltering,
};
const includeDirectories = (settings.includeDirectories || [])
.map(resolvePath)
.concat((argv.includeDirectories || []).map(resolvePath));
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
process.cwd(),
settings.loadMemoryFromIncludeDirectories ? includeDirectories : [],
debugMode,
fileService,
settings,
@@ -419,7 +440,11 @@ export async function loadCliConfig(
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: sandboxConfig,
targetDir: process.cwd(),
includeDirectories: argv.includeDirectories,
includeDirectories,
loadMemoryFromIncludeDirectories:
argv.loadMemoryFromIncludeDirectories ||
settings.loadMemoryFromIncludeDirectories ||
false,
debugMode,
question: argv.promptInteractive || argv.prompt || '',
fullContext: argv.allFiles || argv.all_files || false,
@@ -480,7 +505,6 @@ export async function loadCliConfig(
summarizeToolOutput: settings.summarizeToolOutput,
ideMode,
ideModeFeature,
ideClient,
enableOpenAILogging:
(typeof argv.openaiLogging === 'undefined'
? settings.enableOpenAILogging
@@ -499,6 +523,8 @@ export async function loadCliConfig(
],
contentGenerator: settings.contentGenerator,
cliVersion,
tavilyApiKey:
argv.tavilyApiKey || settings.tavilyApiKey || process.env.TAVILY_API_KEY,
});
}

View File

@@ -112,6 +112,7 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged).toEqual({
customThemes: {},
mcpServers: {},
includeDirectories: [],
});
expect(settings.errors.length).toBe(0);
});
@@ -145,6 +146,7 @@ describe('Settings Loading and Merging', () => {
...systemSettingsContent,
customThemes: {},
mcpServers: {},
includeDirectories: [],
});
});
@@ -178,6 +180,7 @@ describe('Settings Loading and Merging', () => {
...userSettingsContent,
customThemes: {},
mcpServers: {},
includeDirectories: [],
});
});
@@ -209,6 +212,7 @@ describe('Settings Loading and Merging', () => {
...workspaceSettingsContent,
customThemes: {},
mcpServers: {},
includeDirectories: [],
});
});
@@ -246,6 +250,7 @@ describe('Settings Loading and Merging', () => {
contextFileName: 'WORKSPACE_CONTEXT.md',
customThemes: {},
mcpServers: {},
includeDirectories: [],
});
});
@@ -295,6 +300,7 @@ describe('Settings Loading and Merging', () => {
allowMCPServers: ['server1', 'server2'],
customThemes: {},
mcpServers: {},
includeDirectories: [],
});
});
@@ -616,6 +622,40 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged.mcpServers).toEqual({});
});
it('should merge includeDirectories from all scopes', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const systemSettingsContent = {
includeDirectories: ['/system/dir'],
};
const userSettingsContent = {
includeDirectories: ['/user/dir1', '/user/dir2'],
};
const workspaceSettingsContent = {
includeDirectories: ['/workspace/dir'],
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath())
return JSON.stringify(systemSettingsContent);
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.includeDirectories).toEqual([
'/system/dir',
'/user/dir1',
'/user/dir2',
'/workspace/dir',
]);
});
it('should handle JSON parsing errors gracefully', () => {
(mockFsExistsSync as Mock).mockReturnValue(true); // Both files "exist"
const invalidJsonContent = 'invalid json';
@@ -654,6 +694,7 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged).toEqual({
customThemes: {},
mcpServers: {},
includeDirectories: [],
});
// Check that error objects are populated in settings.errors
@@ -1090,6 +1131,7 @@ describe('Settings Loading and Merging', () => {
...systemSettingsContent,
customThemes: {},
mcpServers: {},
includeDirectories: [],
});
});
});

View File

@@ -132,6 +132,7 @@ export interface Settings {
// Environment variables to exclude from project .env files
excludedProjectEnvVars?: string[];
dnsResolutionOrder?: DnsResolutionOrder;
sampling_params?: Record<string, unknown>;
systemPromptMappings?: Array<{
baseUrls: string[];
@@ -142,6 +143,13 @@ export interface Settings {
timeout?: number;
maxRetries?: number;
};
includeDirectories?: string[];
loadMemoryFromIncludeDirectories?: boolean;
// Web search API keys
tavilyApiKey?: string;
}
export interface SettingsError {
@@ -197,6 +205,11 @@ export class LoadedSettings {
...(workspace.mcpServers || {}),
...(system.mcpServers || {}),
},
includeDirectories: [
...(system.includeDirectories || []),
...(user.includeDirectories || []),
...(workspace.includeDirectories || []),
],
};
}
@@ -387,7 +400,7 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
const settingsErrors: SettingsError[] = [];
const systemSettingsPath = getSystemSettingsPath();
// FIX: Resolve paths to their canonical representation to handle symlinks
// Resolve paths to their canonical representation to handle symlinks
const resolvedWorkspaceDir = path.resolve(workspaceDir);
const resolvedHomeDir = path.resolve(homedir());
@@ -442,7 +455,6 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
});
}
// This comparison is now much more reliable.
if (realWorkspaceDir !== realHomeDir) {
// Load workspace settings
try {

View File

@@ -70,6 +70,7 @@ describe('runNonInteractive', () => {
getIdeMode: vi.fn().mockReturnValue(false),
getFullContext: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getDebugMode: vi.fn().mockReturnValue(false),
} as unknown as Config;
});

View File

@@ -17,28 +17,37 @@ import {
import { Content, Part, FunctionCall } from '@google/genai';
import { parseAndFormatApiError } from './ui/utils/errorParsing.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
export async function runNonInteractive(
config: Config,
input: string,
prompt_id: string,
): Promise<void> {
await config.initialize();
// Handle EPIPE errors when the output is piped to a command that closes early.
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EPIPE') {
// Exit gracefully if the pipe is closed.
process.exit(0);
}
const consolePatcher = new ConsolePatcher({
stderr: true,
debugMode: config.getDebugMode(),
});
const geminiClient = config.getGeminiClient();
const toolRegistry: ToolRegistry = await config.getToolRegistry();
const abortController = new AbortController();
let currentMessages: Content[] = [{ role: 'user', parts: [{ text: input }] }];
let turnCount = 0;
try {
await config.initialize();
consolePatcher.patch();
// Handle EPIPE errors when the output is piped to a command that closes early.
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EPIPE') {
// Exit gracefully if the pipe is closed.
process.exit(0);
}
});
const geminiClient = config.getGeminiClient();
const toolRegistry: ToolRegistry = await config.getToolRegistry();
const abortController = new AbortController();
let currentMessages: Content[] = [
{ role: 'user', parts: [{ text: input }] },
];
let turnCount = 0;
while (true) {
turnCount++;
if (
@@ -133,6 +142,7 @@ export async function runNonInteractive(
);
process.exit(1);
} finally {
consolePatcher.cleanup();
if (isTelemetrySdkInitialized()) {
await shutdownTelemetry();
}

View File

@@ -308,6 +308,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
try {
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
process.cwd(),
settings.merged.loadMemoryFromIncludeDirectories
? config.getWorkspaceContext().getDirectories()
: [],
config.getDebugMode(),
config.getFileService(),
settings.merged,
@@ -512,6 +515,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
openPrivacyNotice,
toggleVimEnabled,
setIsProcessing,
setGeminiMdFileCount,
);
const {
@@ -533,6 +537,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
performMemoryRefresh,
modelSwitchedFromQuotaError,
setModelSwitchedFromQuotaError,
refreshStatic,
);
// Input handling
@@ -631,7 +636,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
if (config) {
setGeminiMdFileCount(config.getGeminiMdFileCount());
}
}, [config]);
}, [config, config.getGeminiMdFileCount]);
const logger = useLogger();
const [userMessages, setUserMessages] = useState<string[]>([]);

View File

@@ -40,11 +40,24 @@ describe('directoryCommand', () => {
getGeminiClient: vi.fn().mockReturnValue({
addDirectoryContext: vi.fn(),
}),
getWorkingDir: () => '/test/dir',
shouldLoadMemoryFromIncludeDirectories: () => false,
getDebugMode: () => false,
getFileService: () => ({}),
getExtensionContextFilePaths: () => [],
getFileFilteringOptions: () => ({ ignore: [], include: [] }),
setUserMemory: vi.fn(),
setGeminiMdFileCount: vi.fn(),
} as unknown as Config;
mockContext = {
services: {
config: mockConfig,
settings: {
merged: {
memoryDiscoveryMaxDirs: 1000,
},
},
},
ui: {
addItem: vi.fn(),

View File

@@ -8,6 +8,7 @@ import { SlashCommand, CommandContext, CommandKind } from './types.js';
import { MessageType } from '../types.js';
import * as os from 'os';
import * as path from 'path';
import { loadServerHierarchicalMemory } from '@qwen-code/qwen-code-core';
export function expandHomeDir(p: string): string {
if (!p) {
@@ -16,7 +17,7 @@ export function expandHomeDir(p: string): string {
let expandedPath = p;
if (p.toLowerCase().startsWith('%userprofile%')) {
expandedPath = os.homedir() + p.substring('%userprofile%'.length);
} else if (p.startsWith('~')) {
} else if (p === '~' || p.startsWith('~/')) {
expandedPath = os.homedir() + p.substring(1);
}
return path.normalize(expandedPath);
@@ -90,6 +91,37 @@ export const directoryCommand: SlashCommand = {
}
}
try {
if (config.shouldLoadMemoryFromIncludeDirectories()) {
const { memoryContent, fileCount } =
await loadServerHierarchicalMemory(
config.getWorkingDir(),
[
...config.getWorkspaceContext().getDirectories(),
...pathsToAdd,
],
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),
context.services.settings.merged.memoryImportFormat || 'tree', // Use setting or default to 'tree'
config.getFileFilteringOptions(),
context.services.settings.merged.memoryDiscoveryMaxDirs,
);
config.setUserMemory(memoryContent);
config.setGeminiMdFileCount(fileCount);
context.ui.setGeminiMdFileCount(fileCount);
}
addItem(
{
type: MessageType.INFO,
text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`,
},
Date.now(),
);
} catch (error) {
errors.push(`Error refreshing memory: ${(error as Error).message}`);
}
if (added.length > 0) {
const gemini = config.getGeminiClient();
if (gemini) {

View File

@@ -42,9 +42,15 @@ describe('ideCommand', () => {
mockConfig = {
getIdeModeFeature: vi.fn(),
getIdeMode: vi.fn(),
getIdeClient: vi.fn(),
getIdeClient: vi.fn(() => ({
reconnect: vi.fn(),
disconnect: vi.fn(),
getCurrentIde: vi.fn(),
getDetectedIdeDisplayName: vi.fn(),
getConnectionStatus: vi.fn(),
})),
setIdeModeAndSyncConnection: vi.fn(),
setIdeMode: vi.fn(),
setIdeClientDisconnected: vi.fn(),
} as unknown as Config;
platformSpy = vi.spyOn(process, 'platform', 'get');

View File

@@ -8,6 +8,7 @@ import {
Config,
DetectedIde,
IDEConnectionStatus,
IdeClient,
getIdeDisplayName,
getIdeInstaller,
} from '@qwen-code/qwen-code-core';
@@ -19,6 +20,35 @@ import {
} from './types.js';
import { SettingScope } from '../../config/settings.js';
function getIdeStatusMessage(ideClient: IdeClient): {
messageType: 'info' | 'error';
content: string;
} {
const connection = ideClient.getConnectionStatus();
switch (connection.status) {
case IDEConnectionStatus.Connected:
return {
messageType: 'info',
content: `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`,
};
case IDEConnectionStatus.Connecting:
return {
messageType: 'info',
content: `🟡 Connecting...`,
};
default: {
let content = `🔴 Disconnected`;
if (connection?.details) {
content += `: ${connection.details}`;
}
return {
messageType: 'error',
content,
};
}
}
}
export const ideCommand = (config: Config | null): SlashCommand | null => {
if (!config || !config.getIdeModeFeature()) {
return null;
@@ -54,33 +84,13 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
name: 'status',
description: 'check status of IDE integration',
kind: CommandKind.BUILT_IN,
action: (_context: CommandContext): SlashCommandActionReturn => {
const connection = ideClient.getConnectionStatus();
switch (connection.status) {
case IDEConnectionStatus.Connected:
return {
type: 'message',
messageType: 'info',
content: `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`,
} as const;
case IDEConnectionStatus.Connecting:
return {
type: 'message',
messageType: 'info',
content: `🟡 Connecting...`,
} as const;
default: {
let content = `🔴 Disconnected`;
if (connection?.details) {
content += `: ${connection.details}`;
}
return {
type: 'message',
messageType: 'error',
content,
} as const;
}
}
action: (): SlashCommandActionReturn => {
const { messageType, content } = getIdeStatusMessage(ideClient);
return {
type: 'message',
messageType,
content,
} as const;
},
};
@@ -110,6 +120,10 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
);
const result = await installer.install();
if (result.success) {
config.setIdeMode(true);
context.services.settings.setValue(SettingScope.User, 'ideMode', true);
}
context.ui.addItem(
{
type: result.success ? 'info' : 'error',
@@ -126,8 +140,15 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
context.services.settings.setValue(SettingScope.User, 'ideMode', true);
config.setIdeMode(true);
config.setIdeClientConnected();
await config.setIdeModeAndSyncConnection(true);
const { messageType, content } = getIdeStatusMessage(ideClient);
context.ui.addItem(
{
type: messageType,
text: content,
},
Date.now(),
);
},
};
@@ -137,8 +158,15 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
context.services.settings.setValue(SettingScope.User, 'ideMode', false);
config.setIdeMode(false);
config.setIdeClientDisconnected();
await config.setIdeModeAndSyncConnection(false);
const { messageType, content } = getIdeStatusMessage(ideClient);
context.ui.addItem(
{
type: messageType,
text: content,
},
Date.now(),
);
},
};

View File

@@ -161,6 +161,10 @@ describe('memoryCommand', () => {
getDebugMode: () => false,
getFileService: () => ({}) as FileDiscoveryService,
getExtensionContextFilePaths: () => [],
shouldLoadMemoryFromIncludeDirectories: () => false,
getWorkspaceContext: () => ({
getDirectories: () => [],
}),
getFileFilteringOptions: () => ({
ignore: [],
include: [],

View File

@@ -89,6 +89,9 @@ export const memoryCommand: SlashCommand = {
const { memoryContent, fileCount } =
await loadServerHierarchicalMemory(
config.getWorkingDir(),
config.shouldLoadMemoryFromIncludeDirectories()
? config.getWorkspaceContext().getDirectories()
: [],
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),

View File

@@ -49,7 +49,7 @@ describe('setupGithubCommand', () => {
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-issue-automated-triage.yml"`,
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-issue-scheduled-triage.yml"`,
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-pr-review.yml"`,
'https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/heads/v0/examples/workflows/',
'https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/v0/examples/workflows/',
];
for (const substring of expectedSubstrings) {

View File

@@ -28,7 +28,7 @@ export const setupGithubCommand: SlashCommand = {
}
const version = 'v0';
const workflowBaseUrl = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/heads/${version}/examples/workflows/`;
const workflowBaseUrl = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${version}/examples/workflows/`;
const workflows = [
'gemini-cli/gemini-cli.yml',

View File

@@ -59,6 +59,7 @@ export interface CommandContext {
/** Toggles a special display mode. */
toggleCorgiMode: () => void;
toggleVimEnabled: () => Promise<boolean>;
setGeminiMdFileCount: (count: number) => void;
};
// Session-specific data
session: {

View File

@@ -5,6 +5,7 @@
*/
import { render } from 'ink-testing-library';
import { waitFor } from '@testing-library/react';
import { InputPrompt, InputPromptProps } from './InputPrompt.js';
import type { TextBuffer } from './shared/text-buffer.js';
import { Config } from '@qwen-code/qwen-code-core';
@@ -1226,11 +1227,12 @@ describe('InputPrompt', () => {
stdin.write('\x12');
await wait();
stdin.write('\x1B');
await wait();
const frame = stdout.lastFrame();
expect(frame).not.toContain('(r:)');
expect(frame).not.toContain('echo hello');
await waitFor(() => {
expect(stdout.lastFrame()).not.toContain('(r:)');
});
expect(stdout.lastFrame()).not.toContain('echo hello');
unmount();
});
@@ -1240,9 +1242,11 @@ describe('InputPrompt', () => {
stdin.write('\x12');
await wait();
stdin.write('\t');
await wait();
expect(stdout.lastFrame()).not.toContain('(r:)');
await waitFor(() => {
expect(stdout.lastFrame()).not.toContain('(r:)');
});
expect(props.buffer.setText).toHaveBeenCalledWith('echo hello');
unmount();
});
@@ -1253,9 +1257,11 @@ describe('InputPrompt', () => {
await wait();
expect(stdout.lastFrame()).toContain('(r:)');
stdin.write('\r');
await wait();
expect(stdout.lastFrame()).not.toContain('(r:)');
await waitFor(() => {
expect(stdout.lastFrame()).not.toContain('(r:)');
});
expect(props.onSubmit).toHaveBeenCalledWith('echo hello');
unmount();
});
@@ -1268,9 +1274,10 @@ describe('InputPrompt', () => {
await wait();
expect(stdout.lastFrame()).toContain('(r:)');
stdin.write('\x1B');
await wait();
expect(stdout.lastFrame()).not.toContain('(r:)');
await waitFor(() => {
expect(stdout.lastFrame()).not.toContain('(r:)');
});
expect(props.buffer.text).toBe('initial text');
expect(props.buffer.cursor).toEqual([0, 3]);

View File

@@ -4,8 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect } from 'react';
import { Box, Text, useInput, Static } from 'ink';
import React, { useState, useEffect, useMemo } from 'react';
import { Box, Text, useInput } from 'ink';
import Spinner from 'ink-spinner';
import Link from 'ink-link';
import qrcode from 'qrcode-terminal';
@@ -26,17 +26,93 @@ interface QwenOAuthProgressProps {
authMessage?: string | null;
}
interface StaticItem {
key: string;
type:
| 'title'
| 'instructions'
| 'url'
| 'qr-instructions'
| 'qr-code'
| 'auth-content';
url?: string;
qrCode?: string;
/**
* Static QR Code Display Component
* Renders the QR code and URL once and doesn't re-render unless the URL changes
*/
function QrCodeDisplay({
verificationUrl,
qrCodeData,
}: {
verificationUrl: string;
qrCodeData: string | null;
}): React.JSX.Element | null {
if (!qrCodeData) {
return null;
}
return (
<Box
borderStyle="round"
borderColor={Colors.AccentBlue}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold color={Colors.AccentBlue}>
Qwen OAuth Authentication
</Text>
<Box marginTop={1}>
<Text>Please visit this URL to authorize:</Text>
</Box>
<Link url={verificationUrl} fallback={false}>
<Text color={Colors.AccentGreen} bold>
{verificationUrl}
</Text>
</Link>
<Box marginTop={1}>
<Text>Or scan the QR code below:</Text>
</Box>
<Box marginTop={1}>
<Text>{qrCodeData}</Text>
</Box>
</Box>
);
}
/**
* Dynamic Status Display Component
* Shows the loading spinner, timer, and status messages
*/
function StatusDisplay({
timeRemaining,
dots,
}: {
timeRemaining: number;
dots: string;
}): React.JSX.Element {
const formatTime = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
};
return (
<Box
borderStyle="round"
borderColor={Colors.AccentBlue}
flexDirection="column"
padding={1}
width="100%"
>
<Box marginTop={1}>
<Text>
<Spinner type="dots" /> Waiting for authorization{dots}
</Text>
</Box>
<Box marginTop={1} justifyContent="space-between">
<Text color={Colors.Gray}>
Time remaining: {formatTime(timeRemaining)}
</Text>
<Text color={Colors.AccentPurple}>(Press ESC to cancel)</Text>
</Box>
</Box>
);
}
export function QwenOAuthProgress({
@@ -60,33 +136,29 @@ export function QwenOAuthProgress({
}
});
// Generate QR code when device auth is available
// Generate QR code once when device auth is available
useEffect(() => {
if (!deviceAuth) {
setQrCodeData(null);
if (!deviceAuth?.verification_uri_complete) {
return;
}
// Only generate QR code if we don't have one yet for this URL
if (qrCodeData === null) {
const generateQR = () => {
try {
qrcode.generate(
deviceAuth.verification_uri_complete,
{ small: true },
(qrcode: string) => {
setQrCodeData(qrcode);
},
);
} catch (error) {
console.error('Failed to generate QR code:', error);
setQrCodeData(null);
}
};
const generateQR = () => {
try {
qrcode.generate(
deviceAuth.verification_uri_complete,
{ small: true },
(qrcode: string) => {
setQrCodeData(qrcode);
},
);
} catch (error) {
console.error('Failed to generate QR code:', error);
setQrCodeData(null);
}
};
generateQR();
}
}, [deviceAuth, qrCodeData]);
generateQR();
}, [deviceAuth?.verification_uri_complete]);
// Countdown timer
useEffect(() => {
@@ -115,11 +187,17 @@ export function QwenOAuthProgress({
return () => clearInterval(dotsTimer);
}, []);
const formatTime = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
};
// Memoize the QR code display to prevent unnecessary re-renders
const qrCodeDisplay = useMemo(() => {
if (!deviceAuth?.verification_uri_complete) return null;
return (
<QrCodeDisplay
verificationUrl={deviceAuth.verification_uri_complete}
qrCodeData={qrCodeData}
/>
);
}, [deviceAuth?.verification_uri_complete, qrCodeData]);
// Handle timeout state
if (authStatus === 'timeout') {
@@ -151,6 +229,7 @@ export function QwenOAuthProgress({
);
}
// Show loading state when no device auth is available yet
if (!deviceAuth) {
return (
<Box
@@ -167,7 +246,8 @@ export function QwenOAuthProgress({
</Box>
<Box marginTop={1} justifyContent="space-between">
<Text color={Colors.Gray}>
Time remaining: {formatTime(timeRemaining)}
Time remaining: {Math.floor(timeRemaining / 60)}:
{(timeRemaining % 60).toString().padStart(2, '0')}
</Text>
<Text color={Colors.AccentPurple}>(Press ESC to cancel)</Text>
</Box>
@@ -176,77 +256,12 @@ export function QwenOAuthProgress({
}
return (
<>
{qrCodeData && (
<Static
items={
[
{
key: 'auth-content',
type: 'auth-content' as const,
url: deviceAuth.verification_uri_complete,
qrCode: qrCodeData,
},
] as StaticItem[]
}
style={{
width: '100%',
}}
>
{(item: StaticItem) => (
<Box
borderStyle="round"
borderColor={Colors.AccentBlue}
flexDirection="column"
padding={1}
width="100%"
key={item.key}
>
<Text bold color={Colors.AccentBlue}>
Qwen OAuth Authentication
</Text>
<Box flexDirection="column" width="100%">
{/* Static QR Code Display */}
{qrCodeDisplay}
<Box marginTop={1}>
<Text>Please visit this URL to authorize:</Text>
</Box>
<Link url={item.url || ''} fallback={false}>
<Text color={Colors.AccentGreen} bold>
{item.url || ''}
</Text>
</Link>
<Box marginTop={1}>
<Text>Or scan the QR code below:</Text>
</Box>
<Box marginTop={1}>
<Text>{item.qrCode || ''}</Text>
</Box>
</Box>
)}
</Static>
)}
<Box
borderStyle="round"
borderColor={Colors.AccentBlue}
flexDirection="column"
padding={1}
width="100%"
>
<Box marginTop={1}>
<Text>
<Spinner type="dots" /> Waiting for authorization{dots}
</Text>
</Box>
<Box marginTop={1} justifyContent="space-between">
<Text color={Colors.Gray}>
Time remaining: {formatTime(timeRemaining)}
</Text>
<Text color={Colors.AccentPurple}>(Press ESC to cancel)</Text>
</Box>
</Box>
</>
{/* Dynamic Status Display */}
<StatusDisplay timeRemaining={timeRemaining} dots={dots} />
</Box>
);
}

View File

@@ -51,6 +51,7 @@ export const useSlashCommandProcessor = (
openPrivacyNotice: () => void,
toggleVimEnabled: () => Promise<boolean>,
setIsProcessing: (isProcessing: boolean) => void,
setGeminiMdFileCount: (count: number) => void,
) => {
const session = useSessionStats();
const [commands, setCommands] = useState<readonly SlashCommand[]>([]);
@@ -163,6 +164,7 @@ export const useSlashCommandProcessor = (
setPendingItem: setPendingCompressionItem,
toggleCorgiMode,
toggleVimEnabled,
setGeminiMdFileCount,
},
session: {
stats: session.stats,
@@ -187,6 +189,7 @@ export const useSlashCommandProcessor = (
toggleCorgiMode,
toggleVimEnabled,
sessionShellAllowlist,
setGeminiMdFileCount,
],
);

View File

@@ -0,0 +1,380 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/** @vitest-environment jsdom */
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { useAtCompletion } from './useAtCompletion.js';
import { Config, FileSearch } from '@qwen-code/qwen-code-core';
import {
createTmpDir,
cleanupTmpDir,
FileSystemStructure,
} from '@qwen-code/qwen-code-test-utils';
import { useState } from 'react';
import { Suggestion } from '../components/SuggestionsDisplay.js';
// Test harness to capture the state from the hook's callbacks.
function useTestHarnessForAtCompletion(
enabled: boolean,
pattern: string,
config: Config | undefined,
cwd: string,
) {
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
useAtCompletion({
enabled,
pattern,
config,
cwd,
setSuggestions,
setIsLoadingSuggestions,
});
return { suggestions, isLoadingSuggestions };
}
describe('useAtCompletion', () => {
let testRootDir: string;
let mockConfig: Config;
beforeEach(() => {
mockConfig = {
getFileFilteringOptions: vi.fn(() => ({
respectGitIgnore: true,
respectGeminiIgnore: true,
})),
} as unknown as Config;
vi.clearAllMocks();
});
afterEach(async () => {
if (testRootDir) {
await cleanupTmpDir(testRootDir);
}
vi.restoreAllMocks();
});
describe('File Search Logic', () => {
it('should perform a recursive search for an empty pattern', async () => {
const structure: FileSystemStructure = {
'file.txt': '',
src: {
'index.js': '',
components: ['Button.tsx', 'Button with spaces.tsx'],
},
};
testRootDir = await createTmpDir(structure);
const { result } = renderHook(() =>
useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir),
);
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
expect(result.current.suggestions.map((s) => s.value)).toEqual([
'src/',
'src/components/',
'file.txt',
'src/components/Button\\ with\\ spaces.tsx',
'src/components/Button.tsx',
'src/index.js',
]);
});
it('should correctly filter the recursive list based on a pattern', async () => {
const structure: FileSystemStructure = {
'file.txt': '',
src: {
'index.js': '',
components: {
'Button.tsx': '',
},
},
};
testRootDir = await createTmpDir(structure);
const { result } = renderHook(() =>
useTestHarnessForAtCompletion(true, 'src/', mockConfig, testRootDir),
);
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
expect(result.current.suggestions.map((s) => s.value)).toEqual([
'src/',
'src/components/',
'src/components/Button.tsx',
'src/index.js',
]);
});
it('should append a trailing slash to directory paths in suggestions', async () => {
const structure: FileSystemStructure = {
'file.txt': '',
dir: {},
};
testRootDir = await createTmpDir(structure);
const { result } = renderHook(() =>
useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir),
);
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
expect(result.current.suggestions.map((s) => s.value)).toEqual([
'dir/',
'file.txt',
]);
});
});
describe('UI State and Loading Behavior', () => {
it('should be in a loading state during initial file system crawl', async () => {
testRootDir = await createTmpDir({});
const { result } = renderHook(() =>
useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir),
);
// It's initially true because the effect runs synchronously.
expect(result.current.isLoadingSuggestions).toBe(true);
// Wait for the loading to complete.
await waitFor(() => {
expect(result.current.isLoadingSuggestions).toBe(false);
});
});
it('should NOT show a loading indicator for subsequent searches that complete under 100ms', async () => {
const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' };
testRootDir = await createTmpDir(structure);
const { result, rerender } = renderHook(
({ pattern }) =>
useTestHarnessForAtCompletion(true, pattern, mockConfig, testRootDir),
{ initialProps: { pattern: 'a' } },
);
await waitFor(() => {
expect(result.current.suggestions.map((s) => s.value)).toEqual([
'a.txt',
]);
});
expect(result.current.isLoadingSuggestions).toBe(false);
rerender({ pattern: 'b' });
// Wait for the final result
await waitFor(() => {
expect(result.current.suggestions.map((s) => s.value)).toEqual([
'b.txt',
]);
});
expect(result.current.isLoadingSuggestions).toBe(false);
});
it('should show a loading indicator and clear old suggestions for subsequent searches that take longer than 100ms', async () => {
const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' };
testRootDir = await createTmpDir(structure);
// Spy on the search method to introduce an artificial delay
const originalSearch = FileSearch.prototype.search;
vi.spyOn(FileSearch.prototype, 'search').mockImplementation(
async function (...args) {
await new Promise((resolve) => setTimeout(resolve, 200));
return originalSearch.apply(this, args);
},
);
const { result, rerender } = renderHook(
({ pattern }) =>
useTestHarnessForAtCompletion(true, pattern, mockConfig, testRootDir),
{ initialProps: { pattern: 'a' } },
);
// Wait for the initial (slow) search to complete
await waitFor(() => {
expect(result.current.suggestions.map((s) => s.value)).toEqual([
'a.txt',
]);
});
// Now, rerender to trigger the second search
rerender({ pattern: 'b' });
// Wait for the loading indicator to appear
await waitFor(() => {
expect(result.current.isLoadingSuggestions).toBe(true);
});
// Suggestions should be cleared while loading
expect(result.current.suggestions).toEqual([]);
// Wait for the final (slow) search to complete
await waitFor(
() => {
expect(result.current.suggestions.map((s) => s.value)).toEqual([
'b.txt',
]);
},
{ timeout: 1000 },
); // Increase timeout for the slow search
expect(result.current.isLoadingSuggestions).toBe(false);
});
it('should abort the previous search when a new one starts', async () => {
const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' };
testRootDir = await createTmpDir(structure);
const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
const searchSpy = vi
.spyOn(FileSearch.prototype, 'search')
.mockImplementation(async (...args) => {
const delay = args[0] === 'a' ? 500 : 50;
await new Promise((resolve) => setTimeout(resolve, delay));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return [args[0] as any];
});
const { result, rerender } = renderHook(
({ pattern }) =>
useTestHarnessForAtCompletion(true, pattern, mockConfig, testRootDir),
{ initialProps: { pattern: 'a' } },
);
// Wait for the hook to be ready (initialization is complete)
await waitFor(() => {
expect(searchSpy).toHaveBeenCalledWith('a', expect.any(Object));
});
// Now that the first search is in-flight, trigger the second one.
act(() => {
rerender({ pattern: 'b' });
});
// The abort should have been called for the first search.
expect(abortSpy).toHaveBeenCalledTimes(1);
// Wait for the final result, which should be from the second, faster search.
await waitFor(
() => {
expect(result.current.suggestions.map((s) => s.value)).toEqual(['b']);
},
{ timeout: 1000 },
);
// The search spy should have been called for both patterns.
expect(searchSpy).toHaveBeenCalledWith('b', expect.any(Object));
vi.restoreAllMocks();
});
});
describe('Filtering and Configuration', () => {
it('should respect .gitignore files', async () => {
const gitignoreContent = ['dist/', '*.log'].join('\n');
const structure: FileSystemStructure = {
'.git': {},
'.gitignore': gitignoreContent,
dist: {},
'test.log': '',
src: {},
};
testRootDir = await createTmpDir(structure);
const { result } = renderHook(() =>
useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir),
);
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
expect(result.current.suggestions.map((s) => s.value)).toEqual([
'src/',
'.gitignore',
]);
});
it('should work correctly when config is undefined', async () => {
const structure: FileSystemStructure = {
node_modules: {},
src: {},
};
testRootDir = await createTmpDir(structure);
const { result } = renderHook(() =>
useTestHarnessForAtCompletion(true, '', undefined, testRootDir),
);
await waitFor(() => {
expect(result.current.suggestions.length).toBeGreaterThan(0);
});
expect(result.current.suggestions.map((s) => s.value)).toEqual([
'node_modules/',
'src/',
]);
});
it('should reset and re-initialize when the cwd changes', async () => {
const structure1: FileSystemStructure = { 'file1.txt': '' };
const rootDir1 = await createTmpDir(structure1);
const structure2: FileSystemStructure = { 'file2.txt': '' };
const rootDir2 = await createTmpDir(structure2);
const { result, rerender } = renderHook(
({ cwd, pattern }) =>
useTestHarnessForAtCompletion(true, pattern, mockConfig, cwd),
{
initialProps: {
cwd: rootDir1,
pattern: 'file',
},
},
);
// Wait for initial suggestions from the first directory
await waitFor(() => {
expect(result.current.suggestions.map((s) => s.value)).toEqual([
'file1.txt',
]);
});
// Change the CWD
act(() => {
rerender({ cwd: rootDir2, pattern: 'file' });
});
// After CWD changes, suggestions should be cleared and it should load again.
await waitFor(() => {
expect(result.current.isLoadingSuggestions).toBe(true);
expect(result.current.suggestions).toEqual([]);
});
// Wait for the new suggestions from the second directory
await waitFor(() => {
expect(result.current.suggestions.map((s) => s.value)).toEqual([
'file2.txt',
]);
});
expect(result.current.isLoadingSuggestions).toBe(false);
await cleanupTmpDir(rootDir1);
await cleanupTmpDir(rootDir2);
});
});
});

View File

@@ -0,0 +1,235 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useReducer, useRef } from 'react';
import { Config, FileSearch, escapePath } from '@qwen-code/qwen-code-core';
import {
Suggestion,
MAX_SUGGESTIONS_TO_SHOW,
} from '../components/SuggestionsDisplay.js';
export enum AtCompletionStatus {
IDLE = 'idle',
INITIALIZING = 'initializing',
READY = 'ready',
SEARCHING = 'searching',
ERROR = 'error',
}
interface AtCompletionState {
status: AtCompletionStatus;
suggestions: Suggestion[];
isLoading: boolean;
pattern: string | null;
}
type AtCompletionAction =
| { type: 'INITIALIZE' }
| { type: 'INITIALIZE_SUCCESS' }
| { type: 'SEARCH'; payload: string }
| { type: 'SEARCH_SUCCESS'; payload: Suggestion[] }
| { type: 'SET_LOADING'; payload: boolean }
| { type: 'ERROR' }
| { type: 'RESET' };
const initialState: AtCompletionState = {
status: AtCompletionStatus.IDLE,
suggestions: [],
isLoading: false,
pattern: null,
};
function atCompletionReducer(
state: AtCompletionState,
action: AtCompletionAction,
): AtCompletionState {
switch (action.type) {
case 'INITIALIZE':
return {
...state,
status: AtCompletionStatus.INITIALIZING,
isLoading: true,
};
case 'INITIALIZE_SUCCESS':
return { ...state, status: AtCompletionStatus.READY, isLoading: false };
case 'SEARCH':
// Keep old suggestions, don't set loading immediately
return {
...state,
status: AtCompletionStatus.SEARCHING,
pattern: action.payload,
};
case 'SEARCH_SUCCESS':
return {
...state,
status: AtCompletionStatus.READY,
suggestions: action.payload,
isLoading: false,
};
case 'SET_LOADING':
// Only show loading if we are still in a searching state
if (state.status === AtCompletionStatus.SEARCHING) {
return { ...state, isLoading: action.payload, suggestions: [] };
}
return state;
case 'ERROR':
return {
...state,
status: AtCompletionStatus.ERROR,
isLoading: false,
suggestions: [],
};
case 'RESET':
return initialState;
default:
return state;
}
}
export interface UseAtCompletionProps {
enabled: boolean;
pattern: string;
config: Config | undefined;
cwd: string;
setSuggestions: (suggestions: Suggestion[]) => void;
setIsLoadingSuggestions: (isLoading: boolean) => void;
}
export function useAtCompletion(props: UseAtCompletionProps): void {
const {
enabled,
pattern,
config,
cwd,
setSuggestions,
setIsLoadingSuggestions,
} = props;
const [state, dispatch] = useReducer(atCompletionReducer, initialState);
const fileSearch = useRef<FileSearch | null>(null);
const searchAbortController = useRef<AbortController | null>(null);
const slowSearchTimer = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
setSuggestions(state.suggestions);
}, [state.suggestions, setSuggestions]);
useEffect(() => {
setIsLoadingSuggestions(state.isLoading);
}, [state.isLoading, setIsLoadingSuggestions]);
useEffect(() => {
dispatch({ type: 'RESET' });
}, [cwd, config]);
// Reacts to user input (`pattern`) ONLY.
useEffect(() => {
if (!enabled) {
// reset when first getting out of completion suggestions
if (
state.status === AtCompletionStatus.READY ||
state.status === AtCompletionStatus.ERROR
) {
dispatch({ type: 'RESET' });
}
return;
}
if (pattern === null) {
dispatch({ type: 'RESET' });
return;
}
if (state.status === AtCompletionStatus.IDLE) {
dispatch({ type: 'INITIALIZE' });
} else if (
(state.status === AtCompletionStatus.READY ||
state.status === AtCompletionStatus.SEARCHING) &&
pattern !== state.pattern // Only search if the pattern has changed
) {
dispatch({ type: 'SEARCH', payload: pattern });
}
}, [enabled, pattern, state.status, state.pattern]);
// The "Worker" that performs async operations based on status.
useEffect(() => {
const initialize = async () => {
try {
const searcher = new FileSearch({
projectRoot: cwd,
ignoreDirs: [],
useGitignore:
config?.getFileFilteringOptions()?.respectGitIgnore ?? true,
useGeminiignore:
config?.getFileFilteringOptions()?.respectGeminiIgnore ?? true,
cache: true,
cacheTtl: 30, // 30 seconds
});
await searcher.initialize();
fileSearch.current = searcher;
dispatch({ type: 'INITIALIZE_SUCCESS' });
if (state.pattern !== null) {
dispatch({ type: 'SEARCH', payload: state.pattern });
}
} catch (_) {
dispatch({ type: 'ERROR' });
}
};
const search = async () => {
if (!fileSearch.current || state.pattern === null) {
return;
}
if (slowSearchTimer.current) {
clearTimeout(slowSearchTimer.current);
}
const controller = new AbortController();
searchAbortController.current = controller;
slowSearchTimer.current = setTimeout(() => {
dispatch({ type: 'SET_LOADING', payload: true });
}, 100);
try {
const results = await fileSearch.current.search(state.pattern, {
signal: controller.signal,
maxResults: MAX_SUGGESTIONS_TO_SHOW * 3,
});
if (slowSearchTimer.current) {
clearTimeout(slowSearchTimer.current);
}
if (controller.signal.aborted) {
return;
}
const suggestions = results.map((p) => ({
label: p,
value: escapePath(p),
}));
dispatch({ type: 'SEARCH_SUCCESS', payload: suggestions });
} catch (error) {
if (!(error instanceof Error && error.name === 'AbortError')) {
dispatch({ type: 'ERROR' });
}
}
};
if (state.status === AtCompletionStatus.INITIALIZING) {
initialize();
} else if (state.status === AtCompletionStatus.SEARCHING) {
search();
}
return () => {
searchAbortController.current?.abort();
if (slowSearchTimer.current) {
clearTimeout(slowSearchTimer.current);
}
};
}, [state.status, state.pattern, config, cwd]);
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,20 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useEffect, useCallback, useMemo, useRef } from 'react';
import * as fs from 'fs/promises';
import * as path from 'path';
import { glob } from 'glob';
import {
isNodeError,
escapePath,
unescapePath,
getErrorMessage,
Config,
FileDiscoveryService,
DEFAULT_FILE_FILTERING_OPTIONS,
SHELL_SPECIAL_CHARS,
} from '@qwen-code/qwen-code-core';
import { useCallback, useMemo, useEffect } from 'react';
import { Suggestion } from '../components/SuggestionsDisplay.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
import {
@@ -26,8 +13,17 @@ import {
} from '../components/shared/text-buffer.js';
import { isSlashCommand } from '../utils/commandUtils.js';
import { toCodePoints } from '../utils/textUtils.js';
import { useAtCompletion } from './useAtCompletion.js';
import { useSlashCompletion } from './useSlashCompletion.js';
import { Config } from '@qwen-code/qwen-code-core';
import { useCompletion } from './useCompletion.js';
export enum CompletionMode {
IDLE = 'IDLE',
AT = 'AT',
SLASH = 'SLASH',
}
export interface UseCommandCompletionReturn {
suggestions: Suggestion[];
activeSuggestionIndex: number;
@@ -72,541 +68,109 @@ export function useCommandCompletion(
navigateDown,
} = useCompletion();
const completionStart = useRef(-1);
const completionEnd = useRef(-1);
const cursorRow = buffer.cursor[0];
const cursorCol = buffer.cursor[1];
// Check if cursor is after @ or / without unescaped spaces
const commandIndex = useMemo(() => {
const currentLine = buffer.lines[cursorRow] || '';
if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
return currentLine.indexOf('/');
}
// For other completions like '@', we search backwards from the cursor.
const codePoints = toCodePoints(currentLine);
for (let i = cursorCol - 1; i >= 0; i--) {
const char = codePoints[i];
if (char === ' ') {
// Check for unescaped spaces.
let backslashCount = 0;
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
backslashCount++;
}
if (backslashCount % 2 === 0) {
return -1; // Inactive on unescaped space.
}
} else if (char === '@') {
// Active if we find an '@' before any unescaped space.
return i;
const { completionMode, query, completionStart, completionEnd } =
useMemo(() => {
const currentLine = buffer.lines[cursorRow] || '';
if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
return {
completionMode: CompletionMode.SLASH,
query: currentLine,
completionStart: 0,
completionEnd: currentLine.length,
};
}
}
return -1;
}, [cursorRow, cursorCol, buffer.lines]);
const codePoints = toCodePoints(currentLine);
for (let i = cursorCol - 1; i >= 0; i--) {
const char = codePoints[i];
if (char === ' ') {
let backslashCount = 0;
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
backslashCount++;
}
if (backslashCount % 2 === 0) {
return {
completionMode: CompletionMode.IDLE,
query: null,
completionStart: -1,
completionEnd: -1,
};
}
} else if (char === '@') {
let end = codePoints.length;
for (let i = cursorCol; i < codePoints.length; i++) {
if (codePoints[i] === ' ') {
let backslashCount = 0;
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
backslashCount++;
}
if (backslashCount % 2 === 0) {
end = i;
break;
}
}
}
const pathStart = i + 1;
const partialPath = currentLine.substring(pathStart, end);
return {
completionMode: CompletionMode.AT,
query: partialPath,
completionStart: pathStart,
completionEnd: end,
};
}
}
return {
completionMode: CompletionMode.IDLE,
query: null,
completionStart: -1,
completionEnd: -1,
};
}, [cursorRow, cursorCol, buffer.lines]);
useAtCompletion({
enabled: completionMode === CompletionMode.AT,
pattern: query || '',
config,
cwd,
setSuggestions,
setIsLoadingSuggestions,
});
const slashCompletionRange = useSlashCompletion({
enabled: completionMode === CompletionMode.SLASH,
query,
slashCommands,
commandContext,
setSuggestions,
setIsLoadingSuggestions,
setIsPerfectMatch,
});
useEffect(() => {
if (commandIndex === -1 || reverseSearchActive) {
setTimeout(resetCompletionState, 0);
return;
}
setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1);
setVisibleStartIndex(0);
}, [suggestions, setActiveSuggestionIndex, setVisibleStartIndex]);
const currentLine = buffer.lines[cursorRow] || '';
const codePoints = toCodePoints(currentLine);
if (codePoints[commandIndex] === '/') {
// Always reset perfect match at the beginning of processing.
setIsPerfectMatch(false);
const fullPath = currentLine.substring(commandIndex + 1);
const hasTrailingSpace = currentLine.endsWith(' ');
// Get all non-empty parts of the command.
const rawParts = fullPath.split(/\s+/).filter((p) => p);
let commandPathParts = rawParts;
let partial = '';
// If there's no trailing space, the last part is potentially a partial segment.
// We tentatively separate it.
if (!hasTrailingSpace && rawParts.length > 0) {
partial = rawParts[rawParts.length - 1];
commandPathParts = rawParts.slice(0, -1);
}
// Traverse the Command Tree using the tentative completed path
let currentLevel: readonly SlashCommand[] | undefined = slashCommands;
let leafCommand: SlashCommand | null = null;
for (const part of commandPathParts) {
if (!currentLevel) {
leafCommand = null;
currentLevel = [];
break;
}
const found: SlashCommand | undefined = currentLevel.find(
(cmd) => cmd.name === part || cmd.altNames?.includes(part),
);
if (found) {
leafCommand = found;
currentLevel = found.subCommands as
| readonly SlashCommand[]
| undefined;
} else {
leafCommand = null;
currentLevel = [];
break;
}
}
let exactMatchAsParent: SlashCommand | undefined;
// Handle the Ambiguous Case
if (!hasTrailingSpace && currentLevel) {
exactMatchAsParent = currentLevel.find(
(cmd) =>
(cmd.name === partial || cmd.altNames?.includes(partial)) &&
cmd.subCommands,
);
if (exactMatchAsParent) {
// It's a perfect match for a parent command. Override our initial guess.
// Treat it as a completed command path.
leafCommand = exactMatchAsParent;
currentLevel = exactMatchAsParent.subCommands;
partial = ''; // We now want to suggest ALL of its sub-commands.
}
}
// Check for perfect, executable match
if (!hasTrailingSpace) {
if (leafCommand && partial === '' && leafCommand.action) {
// Case: /command<enter> - command has action, no sub-commands were suggested
setIsPerfectMatch(true);
} else if (currentLevel) {
// Case: /command subcommand<enter>
const perfectMatch = currentLevel.find(
(cmd) =>
(cmd.name === partial || cmd.altNames?.includes(partial)) &&
cmd.action,
);
if (perfectMatch) {
setIsPerfectMatch(true);
}
}
}
const depth = commandPathParts.length;
const isArgumentCompletion =
leafCommand?.completion &&
(hasTrailingSpace ||
(rawParts.length > depth && depth > 0 && partial !== ''));
// Set completion range
if (hasTrailingSpace || exactMatchAsParent) {
completionStart.current = currentLine.length;
completionEnd.current = currentLine.length;
} else if (partial) {
if (isArgumentCompletion) {
const commandSoFar = `/${commandPathParts.join(' ')}`;
const argStartIndex =
commandSoFar.length + (commandPathParts.length > 0 ? 1 : 0);
completionStart.current = argStartIndex;
} else {
completionStart.current = currentLine.length - partial.length;
}
completionEnd.current = currentLine.length;
} else {
// e.g. /
completionStart.current = commandIndex + 1;
completionEnd.current = currentLine.length;
}
// Provide Suggestions based on the now-corrected context
if (isArgumentCompletion) {
const fetchAndSetSuggestions = async () => {
setIsLoadingSuggestions(true);
const argString = rawParts.slice(depth).join(' ');
const results =
(await leafCommand!.completion!(commandContext, argString)) || [];
const finalSuggestions = results.map((s) => ({ label: s, value: s }));
setSuggestions(finalSuggestions);
setShowSuggestions(finalSuggestions.length > 0);
setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1);
setIsLoadingSuggestions(false);
};
fetchAndSetSuggestions();
return;
}
// Command/Sub-command Completion
const commandsToSearch = currentLevel || [];
if (commandsToSearch.length > 0) {
let potentialSuggestions = commandsToSearch.filter(
(cmd) =>
cmd.description &&
(cmd.name.startsWith(partial) ||
cmd.altNames?.some((alt) => alt.startsWith(partial))),
);
// If a user's input is an exact match and it is a leaf command,
// enter should submit immediately.
if (potentialSuggestions.length > 0 && !hasTrailingSpace) {
const perfectMatch = potentialSuggestions.find(
(s) => s.name === partial || s.altNames?.includes(partial),
);
if (perfectMatch && perfectMatch.action) {
potentialSuggestions = [];
}
}
const finalSuggestions = potentialSuggestions.map((cmd) => ({
label: cmd.name,
value: cmd.name,
description: cmd.description,
}));
setSuggestions(finalSuggestions);
setShowSuggestions(finalSuggestions.length > 0);
setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1);
setIsLoadingSuggestions(false);
return;
}
// If we fall through, no suggestions are available.
useEffect(() => {
if (completionMode === CompletionMode.IDLE || reverseSearchActive) {
resetCompletionState();
return;
}
// Handle At Command Completion
completionEnd.current = codePoints.length;
for (let i = cursorCol; i < codePoints.length; i++) {
if (codePoints[i] === ' ') {
let backslashCount = 0;
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
backslashCount++;
}
if (backslashCount % 2 === 0) {
completionEnd.current = i;
break;
}
}
}
const pathStart = commandIndex + 1;
const partialPath = currentLine.substring(pathStart, completionEnd.current);
const lastSlashIndex = partialPath.lastIndexOf('/');
completionStart.current =
lastSlashIndex === -1 ? pathStart : pathStart + lastSlashIndex + 1;
const baseDirRelative =
lastSlashIndex === -1
? '.'
: partialPath.substring(0, lastSlashIndex + 1);
const prefix = unescapePath(
lastSlashIndex === -1
? partialPath
: partialPath.substring(lastSlashIndex + 1),
);
let isMounted = true;
const findFilesRecursively = async (
startDir: string,
searchPrefix: string,
fileDiscovery: FileDiscoveryService | null,
filterOptions: {
respectGitIgnore?: boolean;
respectGeminiIgnore?: boolean;
},
currentRelativePath = '',
depth = 0,
maxDepth = 10, // Limit recursion depth
maxResults = 50, // Limit number of results
): Promise<Suggestion[]> => {
if (depth > maxDepth) {
return [];
}
const lowerSearchPrefix = searchPrefix.toLowerCase();
let foundSuggestions: Suggestion[] = [];
try {
const entries = await fs.readdir(startDir, { withFileTypes: true });
for (const entry of entries) {
if (foundSuggestions.length >= maxResults) break;
const entryPathRelative = path.join(currentRelativePath, entry.name);
const entryPathFromRoot = path.relative(
startDir,
path.join(startDir, entry.name),
);
// Conditionally ignore dotfiles
if (!searchPrefix.startsWith('.') && entry.name.startsWith('.')) {
continue;
}
// Check if this entry should be ignored by filtering options
if (
fileDiscovery &&
fileDiscovery.shouldIgnoreFile(entryPathFromRoot, filterOptions)
) {
continue;
}
if (entry.name.toLowerCase().startsWith(lowerSearchPrefix)) {
foundSuggestions.push({
label: entryPathRelative + (entry.isDirectory() ? '/' : ''),
value: escapePath(
entryPathRelative + (entry.isDirectory() ? '/' : ''),
),
});
}
if (
entry.isDirectory() &&
entry.name !== 'node_modules' &&
!entry.name.startsWith('.')
) {
if (foundSuggestions.length < maxResults) {
foundSuggestions = foundSuggestions.concat(
await findFilesRecursively(
path.join(startDir, entry.name),
searchPrefix, // Pass original searchPrefix for recursive calls
fileDiscovery,
filterOptions,
entryPathRelative,
depth + 1,
maxDepth,
maxResults - foundSuggestions.length,
),
);
}
}
}
} catch (_err) {
// Ignore errors like permission denied or ENOENT during recursive search
}
return foundSuggestions.slice(0, maxResults);
};
const findFilesWithGlob = async (
searchPrefix: string,
fileDiscoveryService: FileDiscoveryService,
filterOptions: {
respectGitIgnore?: boolean;
respectGeminiIgnore?: boolean;
},
searchDir: string,
maxResults = 50,
): Promise<Suggestion[]> => {
const globPattern = `**/${searchPrefix}*`;
const files = await glob(globPattern, {
cwd: searchDir,
dot: searchPrefix.startsWith('.'),
nocase: true,
});
const suggestions: Suggestion[] = files
.filter((file) => {
if (fileDiscoveryService) {
return !fileDiscoveryService.shouldIgnoreFile(file, filterOptions);
}
return true;
})
.map((file: string) => {
const absolutePath = path.resolve(searchDir, file);
const label = path.relative(cwd, absolutePath);
return {
label,
value: escapePath(label),
};
})
.slice(0, maxResults);
return suggestions;
};
const fetchSuggestions = async () => {
setIsLoadingSuggestions(true);
let fetchedSuggestions: Suggestion[] = [];
const fileDiscoveryService = config ? config.getFileService() : null;
const enableRecursiveSearch =
config?.getEnableRecursiveFileSearch() ?? true;
const filterOptions =
config?.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS;
try {
// If there's no slash, or it's the root, do a recursive search from workspace directories
for (const dir of dirs) {
let fetchedSuggestionsPerDir: Suggestion[] = [];
if (
partialPath.indexOf('/') === -1 &&
prefix &&
enableRecursiveSearch
) {
if (fileDiscoveryService) {
fetchedSuggestionsPerDir = await findFilesWithGlob(
prefix,
fileDiscoveryService,
filterOptions,
dir,
);
} else {
fetchedSuggestionsPerDir = await findFilesRecursively(
dir,
prefix,
null,
filterOptions,
);
}
} else {
// Original behavior: list files in the specific directory
const lowerPrefix = prefix.toLowerCase();
const baseDirAbsolute = path.resolve(dir, baseDirRelative);
const entries = await fs.readdir(baseDirAbsolute, {
withFileTypes: true,
});
// Filter entries using git-aware filtering
const filteredEntries = [];
for (const entry of entries) {
// Conditionally ignore dotfiles
if (!prefix.startsWith('.') && entry.name.startsWith('.')) {
continue;
}
if (!entry.name.toLowerCase().startsWith(lowerPrefix)) continue;
const relativePath = path.relative(
dir,
path.join(baseDirAbsolute, entry.name),
);
if (
fileDiscoveryService &&
fileDiscoveryService.shouldIgnoreFile(
relativePath,
filterOptions,
)
) {
continue;
}
filteredEntries.push(entry);
}
fetchedSuggestionsPerDir = filteredEntries.map((entry) => {
const absolutePath = path.resolve(baseDirAbsolute, entry.name);
const label =
cwd === dir ? entry.name : path.relative(cwd, absolutePath);
const suggestionLabel = entry.isDirectory() ? label + '/' : label;
return {
label: suggestionLabel,
value: escapePath(suggestionLabel),
};
});
}
fetchedSuggestions = [
...fetchedSuggestions,
...fetchedSuggestionsPerDir,
];
}
// Like glob, we always return forward slashes for path separators, even on Windows.
// But preserve backslash escaping for special characters.
const specialCharsLookahead = `(?![${SHELL_SPECIAL_CHARS.source.slice(1, -1)}])`;
const pathSeparatorRegex = new RegExp(
`\\\\${specialCharsLookahead}`,
'g',
);
fetchedSuggestions = fetchedSuggestions.map((suggestion) => ({
...suggestion,
label: suggestion.label.replace(pathSeparatorRegex, '/'),
value: suggestion.value.replace(pathSeparatorRegex, '/'),
}));
// Sort by depth, then directories first, then alphabetically
fetchedSuggestions.sort((a, b) => {
const depthA = (a.label.match(/\//g) || []).length;
const depthB = (b.label.match(/\//g) || []).length;
if (depthA !== depthB) {
return depthA - depthB;
}
const aIsDir = a.label.endsWith('/');
const bIsDir = b.label.endsWith('/');
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
// exclude extension when comparing
const filenameA = a.label.substring(
0,
a.label.length - path.extname(a.label).length,
);
const filenameB = b.label.substring(
0,
b.label.length - path.extname(b.label).length,
);
return (
filenameA.localeCompare(filenameB) || a.label.localeCompare(b.label)
);
});
if (isMounted) {
setSuggestions(fetchedSuggestions);
setShowSuggestions(fetchedSuggestions.length > 0);
setActiveSuggestionIndex(fetchedSuggestions.length > 0 ? 0 : -1);
setVisibleStartIndex(0);
}
} catch (error: unknown) {
if (isNodeError(error) && error.code === 'ENOENT') {
if (isMounted) {
setSuggestions([]);
setShowSuggestions(false);
}
} else {
console.error(
`Error fetching completion suggestions for ${partialPath}: ${getErrorMessage(error)}`,
);
if (isMounted) {
resetCompletionState();
}
}
}
if (isMounted) {
setIsLoadingSuggestions(false);
}
};
const debounceTimeout = setTimeout(fetchSuggestions, 100);
return () => {
isMounted = false;
clearTimeout(debounceTimeout);
};
// Show suggestions if we are loading OR if there are results to display.
setShowSuggestions(isLoadingSuggestions || suggestions.length > 0);
}, [
buffer.text,
cursorRow,
cursorCol,
buffer.lines,
dirs,
cwd,
commandIndex,
resetCompletionState,
slashCommands,
commandContext,
config,
completionMode,
suggestions.length,
isLoadingSuggestions,
reverseSearchActive,
setSuggestions,
resetCompletionState,
setShowSuggestions,
setActiveSuggestionIndex,
setIsLoadingSuggestions,
setIsPerfectMatch,
setVisibleStartIndex,
]);
const handleAutocomplete = useCallback(
@@ -616,18 +180,23 @@ export function useCommandCompletion(
}
const suggestion = suggestions[indexToUse].value;
if (completionStart.current === -1 || completionEnd.current === -1) {
let start = completionStart;
let end = completionEnd;
if (completionMode === CompletionMode.SLASH) {
start = slashCompletionRange.completionStart;
end = slashCompletionRange.completionEnd;
}
if (start === -1 || end === -1) {
return;
}
const isSlash = (buffer.lines[cursorRow] || '')[commandIndex] === '/';
let suggestionText = suggestion;
if (isSlash) {
// If we are inserting (not replacing), and the preceding character is not a space, add one.
if (completionMode === CompletionMode.SLASH) {
if (
completionStart.current === completionEnd.current &&
completionStart.current > commandIndex + 1 &&
(buffer.lines[cursorRow] || '')[completionStart.current - 1] !== ' '
start === end &&
start > 1 &&
(buffer.lines[cursorRow] || '')[start - 1] !== ' '
) {
suggestionText = ' ' + suggestionText;
}
@@ -636,12 +205,20 @@ export function useCommandCompletion(
suggestionText += ' ';
buffer.replaceRangeByOffset(
logicalPosToOffset(buffer.lines, cursorRow, completionStart.current),
logicalPosToOffset(buffer.lines, cursorRow, completionEnd.current),
logicalPosToOffset(buffer.lines, cursorRow, start),
logicalPosToOffset(buffer.lines, cursorRow, end),
suggestionText,
);
},
[cursorRow, buffer, suggestions, commandIndex],
[
cursorRow,
buffer,
suggestions,
completionMode,
completionStart,
completionEnd,
slashCompletionRange,
],
);
return {

View File

@@ -448,6 +448,7 @@ describe('useGeminiStream', () => {
callId: 'call1',
responseParts: [{ text: 'tool 1 response' }],
error: undefined,
errorType: undefined,
resultDisplay: 'Tool 1 success display',
},
tool: {
@@ -655,6 +656,7 @@ describe('useGeminiStream', () => {
],
resultDisplay: undefined,
error: undefined,
errorType: undefined,
},
responseSubmittedToGemini: false,
};
@@ -679,6 +681,7 @@ describe('useGeminiStream', () => {
],
resultDisplay: undefined,
error: undefined,
errorType: undefined,
},
responseSubmittedToGemini: false,
};
@@ -775,6 +778,7 @@ describe('useGeminiStream', () => {
callId: 'call1',
responseParts: toolCallResponseParts,
error: undefined,
errorType: undefined,
resultDisplay: 'Tool 1 success display',
},
endTime: Date.now(),
@@ -1128,6 +1132,7 @@ describe('useGeminiStream', () => {
responseParts: [{ text: 'Memory saved' }],
resultDisplay: 'Success: Memory saved',
error: undefined,
errorType: undefined,
},
tool: {
name: 'save_memory',
@@ -1649,4 +1654,313 @@ describe('useGeminiStream', () => {
);
});
});
describe('Concurrent Execution Prevention', () => {
it('should prevent concurrent submitQuery calls', async () => {
let resolveFirstCall!: () => void;
let resolveSecondCall!: () => void;
const firstCallPromise = new Promise<void>((resolve) => {
resolveFirstCall = resolve;
});
const secondCallPromise = new Promise<void>((resolve) => {
resolveSecondCall = resolve;
});
// Mock a long-running stream for the first call
const firstStream = (async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'First call content',
};
await firstCallPromise; // Wait until we manually resolve
yield { type: ServerGeminiEventType.Finished, value: 'STOP' };
})();
// Mock a stream for the second call (should not be used)
const secondStream = (async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'Second call content',
};
await secondCallPromise;
yield { type: ServerGeminiEventType.Finished, value: 'STOP' };
})();
let callCount = 0;
mockSendMessageStream.mockImplementation(() => {
callCount++;
if (callCount === 1) {
return firstStream;
} else {
return secondStream;
}
});
const { result } = renderTestHook();
// Start first call
const firstCallResult = act(async () => {
await result.current.submitQuery('First query');
});
// Wait a bit to ensure first call has started
await new Promise((resolve) => setTimeout(resolve, 10));
// Try to start second call while first is still running
const secondCallResult = act(async () => {
await result.current.submitQuery('Second query');
});
// Resolve both calls
resolveFirstCall();
resolveSecondCall();
await Promise.all([firstCallResult, secondCallResult]);
// Verify only one call was made to sendMessageStream
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
expect(mockSendMessageStream).toHaveBeenCalledWith(
'First query',
expect.any(AbortSignal),
expect.any(String),
);
// Verify only the first query was added to history
const userMessages = mockAddItem.mock.calls.filter(
(call) => call[0].type === MessageType.USER,
);
expect(userMessages).toHaveLength(1);
expect(userMessages[0][0].text).toBe('First query');
});
it('should allow subsequent calls after first call completes', async () => {
// Mock streams that complete immediately
mockSendMessageStream
.mockReturnValueOnce(
(async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'First response',
};
yield { type: ServerGeminiEventType.Finished, value: 'STOP' };
})(),
)
.mockReturnValueOnce(
(async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'Second response',
};
yield { type: ServerGeminiEventType.Finished, value: 'STOP' };
})(),
);
const { result } = renderTestHook();
// First call
await act(async () => {
await result.current.submitQuery('First query');
});
// Second call after first completes
await act(async () => {
await result.current.submitQuery('Second query');
});
// Both calls should have been made
expect(mockSendMessageStream).toHaveBeenCalledTimes(2);
expect(mockSendMessageStream).toHaveBeenNthCalledWith(
1,
'First query',
expect.any(AbortSignal),
expect.any(String),
);
expect(mockSendMessageStream).toHaveBeenNthCalledWith(
2,
'Second query',
expect.any(AbortSignal),
expect.any(String),
);
});
it('should reset execution flag even when query preparation fails', async () => {
const { result } = renderTestHook();
// First call with empty query (should fail in preparation)
await act(async () => {
await result.current.submitQuery(' '); // Empty trimmed query
});
// Second call should work normally
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'Valid response',
};
yield { type: ServerGeminiEventType.Finished, value: 'STOP' };
})(),
);
await act(async () => {
await result.current.submitQuery('Valid query');
});
// The second call should have been made
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
expect(mockSendMessageStream).toHaveBeenCalledWith(
'Valid query',
expect.any(AbortSignal),
expect.any(String),
);
});
it('should reset execution flag when user cancels', async () => {
let resolveCancelledStream!: () => void;
const cancelledStreamPromise = new Promise<void>((resolve) => {
resolveCancelledStream = resolve;
});
// Mock a stream that can be cancelled
const cancelledStream = (async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'Cancelled content',
};
await cancelledStreamPromise;
yield { type: ServerGeminiEventType.UserCancelled };
})();
mockSendMessageStream.mockReturnValueOnce(cancelledStream);
const { result } = renderTestHook();
// Start first call
const firstCallResult = act(async () => {
await result.current.submitQuery('First query');
});
// Wait a bit then resolve to trigger cancellation
await new Promise((resolve) => setTimeout(resolve, 10));
resolveCancelledStream();
await firstCallResult;
// Now try a second call - should work
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'Second response',
};
yield { type: ServerGeminiEventType.Finished, value: 'STOP' };
})(),
);
await act(async () => {
await result.current.submitQuery('Second query');
});
// Both calls should have been made
expect(mockSendMessageStream).toHaveBeenCalledTimes(2);
});
it('should reset execution flag when an error occurs', async () => {
// Mock a stream that throws an error
mockSendMessageStream.mockReturnValueOnce(
(async function* () {
yield { type: ServerGeminiEventType.Content, value: 'Error content' };
throw new Error('Stream error');
})(),
);
const { result } = renderTestHook();
// First call that will error
await act(async () => {
await result.current.submitQuery('Error query');
});
// Second call should work normally
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'Success response',
};
yield { type: ServerGeminiEventType.Finished, value: 'STOP' };
})(),
);
await act(async () => {
await result.current.submitQuery('Success query');
});
// Both calls should have been attempted
expect(mockSendMessageStream).toHaveBeenCalledTimes(2);
});
it('should handle rapid multiple concurrent calls correctly', async () => {
let resolveStream!: () => void;
const streamPromise = new Promise<void>((resolve) => {
resolveStream = resolve;
});
// Mock a long-running stream
const longStream = (async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'Long running content',
};
await streamPromise;
yield { type: ServerGeminiEventType.Finished, value: 'STOP' };
})();
mockSendMessageStream.mockReturnValue(longStream);
const { result } = renderTestHook();
// Start multiple concurrent calls
const calls = [
act(async () => {
await result.current.submitQuery('Query 1');
}),
act(async () => {
await result.current.submitQuery('Query 2');
}),
act(async () => {
await result.current.submitQuery('Query 3');
}),
act(async () => {
await result.current.submitQuery('Query 4');
}),
act(async () => {
await result.current.submitQuery('Query 5');
}),
];
// Wait a bit then resolve the stream
await new Promise((resolve) => setTimeout(resolve, 10));
resolveStream();
// Wait for all calls to complete
await Promise.all(calls);
// Only the first call should have been made
expect(mockSendMessageStream).toHaveBeenCalledTimes(1);
expect(mockSendMessageStream).toHaveBeenCalledWith(
'Query 1',
expect.any(AbortSignal),
expect.any(String),
);
// Only one user message should have been added
const userMessages = mockAddItem.mock.calls.filter(
(call) => call[0].type === MessageType.USER,
);
expect(userMessages).toHaveLength(1);
expect(userMessages[0][0].text).toBe('Query 1');
});
});
});

View File

@@ -93,10 +93,12 @@ export const useGeminiStream = (
performMemoryRefresh: () => Promise<void>,
modelSwitchedFromQuotaError: boolean,
setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>,
onEditorClose: () => void,
) => {
const [initError, setInitError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const turnCancelledRef = useRef(false);
const isSubmittingQueryRef = useRef(false);
const [isResponding, setIsResponding] = useState<boolean>(false);
const [thought, setThought] = useState<ThoughtSummary | null>(null);
const [pendingHistoryItemRef, setPendingHistoryItem] =
@@ -133,6 +135,7 @@ export const useGeminiStream = (
config,
setPendingHistoryItem,
getPreferredEditor,
onEditorClose,
);
const pendingToolCallGroupDisplay = useMemo(
@@ -622,6 +625,11 @@ export const useGeminiStream = (
options?: { isContinuation: boolean },
prompt_id?: string,
) => {
// Prevent concurrent executions of submitQuery
if (isSubmittingQueryRef.current) {
return;
}
if (
(streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation) &&
@@ -629,6 +637,9 @@ export const useGeminiStream = (
)
return;
// Set the flag to indicate we're now executing
isSubmittingQueryRef.current = true;
const userMessageTimestamp = Date.now();
// Reset quota error flag when starting a new query (not a continuation)
@@ -653,6 +664,7 @@ export const useGeminiStream = (
);
if (!shouldProceed || queryToSend === null) {
isSubmittingQueryRef.current = false;
return;
}
@@ -677,6 +689,7 @@ export const useGeminiStream = (
);
if (processingStatus === StreamProcessingStatus.UserCancelled) {
isSubmittingQueryRef.current = false;
return;
}
@@ -708,6 +721,7 @@ export const useGeminiStream = (
}
} finally {
setIsResponding(false);
isSubmittingQueryRef.current = false;
}
},
[

View File

@@ -38,7 +38,6 @@ export const WITTY_LOADING_PHRASES = [
'Defragmenting memories... both RAM and personal...',
'Rebooting the humor module...',
'Caching the essentials (mostly cat memes)...',
'Running sudo make me a sandwich...',
'Optimizing for ludicrous speed',
"Swapping bits... don't tell the bytes...",
'Garbage collecting... be right back...',
@@ -66,12 +65,10 @@ export const WITTY_LOADING_PHRASES = [
"Just a moment, I'm tuning the algorithms...",
'Warp speed engaged...',
'Mining for more Dilithium crystals...',
"I'm Giving Her all she's got Captain!",
"Don't panic...",
'Following the white rabbit...',
'The truth is in here... somewhere...',
'Blowing on the cartridge...',
'Looking for the princess in another castle...',
'Loading... Do a barrel roll!',
'Waiting for the respawn...',
'Finishing the Kessel Run in less than 12 parsecs...',

View File

@@ -70,6 +70,7 @@ export function useReactToolScheduler(
React.SetStateAction<HistoryItemWithoutId | null>
>,
getPreferredEditor: () => EditorType | undefined,
onEditorClose: () => void,
): [TrackedToolCall[], ScheduleFn, MarkToolsAsSubmittedFn] {
const [toolCallsForDisplay, setToolCallsForDisplay] = useState<
TrackedToolCall[]
@@ -140,6 +141,7 @@ export function useReactToolScheduler(
onToolCallsUpdate: toolCallsUpdateHandler,
getPreferredEditor,
config,
onEditorClose,
}),
[
config,
@@ -147,6 +149,7 @@ export function useReactToolScheduler(
allToolCallsCompleteHandler,
toolCallsUpdateHandler,
getPreferredEditor,
onEditorClose,
],
);

View File

@@ -41,12 +41,17 @@ export function useReverseSearchCompletion(
navigateDown,
} = useCompletion();
// whenever reverseSearchActive is on, filter history
useEffect(() => {
if (!reverseSearchActive) {
resetCompletionState();
}
}, [reverseSearchActive, resetCompletionState]);
useEffect(() => {
if (!reverseSearchActive) {
return;
}
const q = buffer.text.toLowerCase();
const matches = shellHistory.reduce<Suggestion[]>((acc, cmd) => {
const idx = cmd.toLowerCase().indexOf(q);
@@ -62,7 +67,6 @@ export function useReverseSearchCompletion(
buffer.text,
shellHistory,
reverseSearchActive,
resetCompletionState,
setActiveSuggestionIndex,
setShowSuggestions,
setSuggestions,

View File

@@ -0,0 +1,434 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/** @vitest-environment jsdom */
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useSlashCompletion } from './useSlashCompletion.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
import { useState } from 'react';
import { Suggestion } from '../components/SuggestionsDisplay.js';
// Test harness to capture the state from the hook's callbacks.
function useTestHarnessForSlashCompletion(
enabled: boolean,
query: string | null,
slashCommands: readonly SlashCommand[],
commandContext: CommandContext,
) {
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
const [isPerfectMatch, setIsPerfectMatch] = useState(false);
const { completionStart, completionEnd } = useSlashCompletion({
enabled,
query,
slashCommands,
commandContext,
setSuggestions,
setIsLoadingSuggestions,
setIsPerfectMatch,
});
return {
suggestions,
isLoadingSuggestions,
isPerfectMatch,
completionStart,
completionEnd,
};
}
describe('useSlashCompletion', () => {
// A minimal mock is sufficient for these tests.
const mockCommandContext = {} as CommandContext;
describe('Top-Level Commands', () => {
it('should suggest all top-level commands for the root slash', async () => {
const slashCommands = [
{ name: 'help', altNames: ['?'], description: 'Show help' },
{
name: 'stats',
altNames: ['usage'],
description: 'check session stats. Usage: /stats [model|tools]',
},
{ name: 'clear', description: 'Clear the screen' },
{
name: 'memory',
description: 'Manage memory',
subCommands: [{ name: 'show', description: 'Show memory' }],
},
{ name: 'chat', description: 'Manage chat history' },
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/',
slashCommands,
mockCommandContext,
),
);
expect(result.current.suggestions.length).toBe(slashCommands.length);
expect(result.current.suggestions.map((s) => s.label)).toEqual(
expect.arrayContaining(['help', 'clear', 'memory', 'chat', 'stats']),
);
});
it('should filter commands based on partial input', async () => {
const slashCommands = [
{ name: 'memory', description: 'Manage memory' },
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/mem',
slashCommands,
mockCommandContext,
),
);
expect(result.current.suggestions).toEqual([
{ label: 'memory', value: 'memory', description: 'Manage memory' },
]);
});
it('should suggest commands based on partial altNames', async () => {
const slashCommands = [
{
name: 'stats',
altNames: ['usage'],
description: 'check session stats. Usage: /stats [model|tools]',
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/usag',
slashCommands,
mockCommandContext,
),
);
expect(result.current.suggestions).toEqual([
{
label: 'stats',
value: 'stats',
description: 'check session stats. Usage: /stats [model|tools]',
},
]);
});
it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => {
const slashCommands = [
{ name: 'clear', description: 'Clear the screen', action: vi.fn() },
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/clear',
slashCommands,
mockCommandContext,
),
);
expect(result.current.suggestions).toHaveLength(0);
});
it.each([['/?'], ['/usage']])(
'should not suggest commands when altNames is fully typed',
async (query) => {
const mockSlashCommands = [
{
name: 'help',
altNames: ['?'],
description: 'Show help',
action: vi.fn(),
},
{
name: 'stats',
altNames: ['usage'],
description: 'check session stats. Usage: /stats [model|tools]',
action: vi.fn(),
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
query,
mockSlashCommands,
mockCommandContext,
),
);
expect(result.current.suggestions).toHaveLength(0);
},
);
it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => {
const slashCommands = [
{ name: 'clear', description: 'Clear the screen' },
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/clear ',
slashCommands,
mockCommandContext,
),
);
expect(result.current.suggestions).toHaveLength(0);
});
it('should not provide suggestions for an unknown command', async () => {
const slashCommands = [
{ name: 'help', description: 'Show help' },
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/unknown-command',
slashCommands,
mockCommandContext,
),
);
expect(result.current.suggestions).toHaveLength(0);
});
});
describe('Sub-Commands', () => {
it('should suggest sub-commands for a parent command', async () => {
const slashCommands = [
{
name: 'memory',
description: 'Manage memory',
subCommands: [
{ name: 'show', description: 'Show memory' },
{ name: 'add', description: 'Add to memory' },
],
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/memory',
slashCommands,
mockCommandContext,
),
);
expect(result.current.suggestions).toHaveLength(2);
expect(result.current.suggestions).toEqual(
expect.arrayContaining([
{ label: 'show', value: 'show', description: 'Show memory' },
{ label: 'add', value: 'add', description: 'Add to memory' },
]),
);
});
it('should suggest all sub-commands when the query ends with the parent command and a space', async () => {
const slashCommands = [
{
name: 'memory',
description: 'Manage memory',
subCommands: [
{ name: 'show', description: 'Show memory' },
{ name: 'add', description: 'Add to memory' },
],
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/memory ',
slashCommands,
mockCommandContext,
),
);
expect(result.current.suggestions).toHaveLength(2);
expect(result.current.suggestions).toEqual(
expect.arrayContaining([
{ label: 'show', value: 'show', description: 'Show memory' },
{ label: 'add', value: 'add', description: 'Add to memory' },
]),
);
});
it('should filter sub-commands by prefix', async () => {
const slashCommands = [
{
name: 'memory',
description: 'Manage memory',
subCommands: [
{ name: 'show', description: 'Show memory' },
{ name: 'add', description: 'Add to memory' },
],
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/memory a',
slashCommands,
mockCommandContext,
),
);
expect(result.current.suggestions).toEqual([
{ label: 'add', value: 'add', description: 'Add to memory' },
]);
});
it('should provide no suggestions for an invalid sub-command', async () => {
const slashCommands = [
{
name: 'memory',
description: 'Manage memory',
subCommands: [
{ name: 'show', description: 'Show memory' },
{ name: 'add', description: 'Add to memory' },
],
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/memory dothisnow',
slashCommands,
mockCommandContext,
),
);
expect(result.current.suggestions).toHaveLength(0);
});
});
describe('Argument Completion', () => {
it('should call the command.completion function for argument suggestions', async () => {
const availableTags = [
'my-chat-tag-1',
'my-chat-tag-2',
'another-channel',
];
const mockCompletionFn = vi
.fn()
.mockImplementation(
async (_context: CommandContext, partialArg: string) =>
availableTags.filter((tag) => tag.startsWith(partialArg)),
);
const slashCommands = [
{
name: 'chat',
description: 'Manage chat history',
subCommands: [
{
name: 'resume',
description: 'Resume a saved chat',
completion: mockCompletionFn,
},
],
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/chat resume my-ch',
slashCommands,
mockCommandContext,
),
);
await waitFor(() => {
expect(mockCompletionFn).toHaveBeenCalledWith(
mockCommandContext,
'my-ch',
);
});
await waitFor(() => {
expect(result.current.suggestions).toEqual([
{ label: 'my-chat-tag-1', value: 'my-chat-tag-1' },
{ label: 'my-chat-tag-2', value: 'my-chat-tag-2' },
]);
});
});
it('should call command.completion with an empty string when args start with a space', async () => {
const mockCompletionFn = vi
.fn()
.mockResolvedValue(['my-chat-tag-1', 'my-chat-tag-2', 'my-channel']);
const slashCommands = [
{
name: 'chat',
description: 'Manage chat history',
subCommands: [
{
name: 'resume',
description: 'Resume a saved chat',
completion: mockCompletionFn,
},
],
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/chat resume ',
slashCommands,
mockCommandContext,
),
);
await waitFor(() => {
expect(mockCompletionFn).toHaveBeenCalledWith(mockCommandContext, '');
});
await waitFor(() => {
expect(result.current.suggestions).toHaveLength(3);
});
});
it('should handle completion function that returns null', async () => {
const completionFn = vi.fn().mockResolvedValue(null);
const slashCommands = [
{
name: 'chat',
description: 'Manage chat history',
subCommands: [
{
name: 'resume',
description: 'Resume a saved chat',
completion: completionFn,
},
],
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/chat resume ',
slashCommands,
mockCommandContext,
),
);
await waitFor(() => {
expect(result.current.suggestions).toHaveLength(0);
});
});
});
});

View File

@@ -0,0 +1,187 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect } from 'react';
import { Suggestion } from '../components/SuggestionsDisplay.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
export interface UseSlashCompletionProps {
enabled: boolean;
query: string | null;
slashCommands: readonly SlashCommand[];
commandContext: CommandContext;
setSuggestions: (suggestions: Suggestion[]) => void;
setIsLoadingSuggestions: (isLoading: boolean) => void;
setIsPerfectMatch: (isMatch: boolean) => void;
}
export function useSlashCompletion(props: UseSlashCompletionProps): {
completionStart: number;
completionEnd: number;
} {
const {
enabled,
query,
slashCommands,
commandContext,
setSuggestions,
setIsLoadingSuggestions,
setIsPerfectMatch,
} = props;
const [completionStart, setCompletionStart] = useState(-1);
const [completionEnd, setCompletionEnd] = useState(-1);
useEffect(() => {
if (!enabled || query === null) {
return;
}
const fullPath = query?.substring(1) || '';
const hasTrailingSpace = !!query?.endsWith(' ');
const rawParts = fullPath.split(/\s+/).filter((p) => p);
let commandPathParts = rawParts;
let partial = '';
if (!hasTrailingSpace && rawParts.length > 0) {
partial = rawParts[rawParts.length - 1];
commandPathParts = rawParts.slice(0, -1);
}
let currentLevel: readonly SlashCommand[] | undefined = slashCommands;
let leafCommand: SlashCommand | null = null;
for (const part of commandPathParts) {
if (!currentLevel) {
leafCommand = null;
currentLevel = [];
break;
}
const found: SlashCommand | undefined = currentLevel.find(
(cmd) => cmd.name === part || cmd.altNames?.includes(part),
);
if (found) {
leafCommand = found;
currentLevel = found.subCommands as readonly SlashCommand[] | undefined;
} else {
leafCommand = null;
currentLevel = [];
break;
}
}
let exactMatchAsParent: SlashCommand | undefined;
if (!hasTrailingSpace && currentLevel) {
exactMatchAsParent = currentLevel.find(
(cmd) =>
(cmd.name === partial || cmd.altNames?.includes(partial)) &&
cmd.subCommands,
);
if (exactMatchAsParent) {
leafCommand = exactMatchAsParent;
currentLevel = exactMatchAsParent.subCommands;
partial = '';
}
}
setIsPerfectMatch(false);
if (!hasTrailingSpace) {
if (leafCommand && partial === '' && leafCommand.action) {
setIsPerfectMatch(true);
} else if (currentLevel) {
const perfectMatch = currentLevel.find(
(cmd) =>
(cmd.name === partial || cmd.altNames?.includes(partial)) &&
cmd.action,
);
if (perfectMatch) {
setIsPerfectMatch(true);
}
}
}
const depth = commandPathParts.length;
const isArgumentCompletion =
leafCommand?.completion &&
(hasTrailingSpace ||
(rawParts.length > depth && depth > 0 && partial !== ''));
if (hasTrailingSpace || exactMatchAsParent) {
setCompletionStart(query.length);
setCompletionEnd(query.length);
} else if (partial) {
if (isArgumentCompletion) {
const commandSoFar = `/${commandPathParts.join(' ')}`;
const argStartIndex =
commandSoFar.length + (commandPathParts.length > 0 ? 1 : 0);
setCompletionStart(argStartIndex);
} else {
setCompletionStart(query.length - partial.length);
}
setCompletionEnd(query.length);
} else {
setCompletionStart(1);
setCompletionEnd(query.length);
}
if (isArgumentCompletion) {
const fetchAndSetSuggestions = async () => {
setIsLoadingSuggestions(true);
const argString = rawParts.slice(depth).join(' ');
const results =
(await leafCommand!.completion!(commandContext, argString)) || [];
const finalSuggestions = results.map((s) => ({ label: s, value: s }));
setSuggestions(finalSuggestions);
setIsLoadingSuggestions(false);
};
fetchAndSetSuggestions();
return;
}
const commandsToSearch = currentLevel || [];
if (commandsToSearch.length > 0) {
let potentialSuggestions = commandsToSearch.filter(
(cmd) =>
cmd.description &&
(cmd.name.startsWith(partial) ||
cmd.altNames?.some((alt) => alt.startsWith(partial))),
);
if (potentialSuggestions.length > 0 && !hasTrailingSpace) {
const perfectMatch = potentialSuggestions.find(
(s) => s.name === partial || s.altNames?.includes(partial),
);
if (perfectMatch && perfectMatch.action) {
potentialSuggestions = [];
}
}
const finalSuggestions = potentialSuggestions.map((cmd) => ({
label: cmd.name,
value: cmd.name,
description: cmd.description,
}));
setSuggestions(finalSuggestions);
return;
}
setSuggestions([]);
}, [
enabled,
query,
slashCommands,
commandContext,
setSuggestions,
setIsLoadingSuggestions,
setIsPerfectMatch,
]);
return {
completionStart,
completionEnd,
};
}

View File

@@ -1203,7 +1203,9 @@ describe('useVim hook', () => {
});
// Press escape to clear pending state
exitInsertMode(result);
act(() => {
result.current.handleInput({ name: 'escape' });
});
// Now 'w' should just move cursor, not delete
act(() => {
@@ -1215,6 +1217,69 @@ describe('useVim hook', () => {
expect(testBuffer.vimMoveWordForward).toHaveBeenCalledWith(1);
});
});
describe('NORMAL mode escape behavior', () => {
it('should pass escape through when no pending operator is active', () => {
mockVimContext.vimMode = 'NORMAL';
const { result } = renderVimHook();
const handled = result.current.handleInput({ name: 'escape' });
expect(handled).toBe(false);
});
it('should handle escape and clear pending operator', () => {
mockVimContext.vimMode = 'NORMAL';
const { result } = renderVimHook();
act(() => {
result.current.handleInput({ sequence: 'd' });
});
let handled: boolean | undefined;
act(() => {
handled = result.current.handleInput({ name: 'escape' });
});
expect(handled).toBe(true);
});
});
});
describe('Shell command pass-through', () => {
it('should pass through ctrl+r in INSERT mode', () => {
mockVimContext.vimMode = 'INSERT';
const { result } = renderVimHook();
const handled = result.current.handleInput({ name: 'r', ctrl: true });
expect(handled).toBe(false);
});
it('should pass through ! in INSERT mode when buffer is empty', () => {
mockVimContext.vimMode = 'INSERT';
const emptyBuffer = createMockBuffer('');
const { result } = renderVimHook(emptyBuffer);
const handled = result.current.handleInput({ sequence: '!' });
expect(handled).toBe(false);
});
it('should handle ! as input in INSERT mode when buffer is not empty', () => {
mockVimContext.vimMode = 'INSERT';
const nonEmptyBuffer = createMockBuffer('not empty');
const { result } = renderVimHook(nonEmptyBuffer);
const key = { sequence: '!', name: '!' };
act(() => {
result.current.handleInput(key);
});
expect(nonEmptyBuffer.handleInput).toHaveBeenCalledWith(
expect.objectContaining(key),
);
});
});
// Line operations (dd, cc) are tested in text-buffer.test.ts

View File

@@ -260,7 +260,8 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
normalizedKey.name === 'tab' ||
(normalizedKey.name === 'return' && !normalizedKey.ctrl) ||
normalizedKey.name === 'up' ||
normalizedKey.name === 'down'
normalizedKey.name === 'down' ||
(normalizedKey.ctrl && normalizedKey.name === 'r')
) {
return false; // Let InputPrompt handle completion
}
@@ -270,6 +271,11 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
return false; // Let InputPrompt handle clipboard functionality
}
// Let InputPrompt handle shell commands
if (normalizedKey.sequence === '!' && buffer.text.length === 0) {
return false;
}
// Special handling for Enter key to allow command submission (lower priority than completion)
if (
normalizedKey.name === 'return' &&
@@ -399,10 +405,14 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
// Handle NORMAL mode
if (state.mode === 'NORMAL') {
// Handle Escape key in NORMAL mode - clear all pending states
// If in NORMAL mode, allow escape to pass through to other handlers
// if there's no pending operation.
if (normalizedKey.name === 'escape') {
dispatch({ type: 'CLEAR_PENDING_STATES' });
return true; // Handled by vim
if (state.pendingOperator) {
dispatch({ type: 'CLEAR_PENDING_STATES' });
return true; // Handled by vim
}
return false; // Pass through to other handlers
}
// Handle count input (numbers 1-9, and 0 if count > 0)

View File

@@ -8,8 +8,9 @@ import util from 'util';
import { ConsoleMessageItem } from '../types.js';
interface ConsolePatcherParams {
onNewMessage: (message: Omit<ConsoleMessageItem, 'id'>) => void;
onNewMessage?: (message: Omit<ConsoleMessageItem, 'id'>) => void;
debugMode: boolean;
stderr?: boolean;
}
export class ConsolePatcher {
@@ -46,16 +47,22 @@ export class ConsolePatcher {
originalMethod: (...args: unknown[]) => void,
) =>
(...args: unknown[]) => {
if (this.params.debugMode) {
originalMethod.apply(console, args);
}
if (this.params.stderr) {
if (type !== 'debug' || this.params.debugMode) {
this.originalConsoleError(this.formatArgs(args));
}
} else {
if (this.params.debugMode) {
originalMethod.apply(console, args);
}
if (type !== 'debug' || this.params.debugMode) {
this.params.onNewMessage({
type,
content: this.formatArgs(args),
count: 1,
});
if (type !== 'debug' || this.params.debugMode) {
this.params.onNewMessage?.({
type,
content: this.formatArgs(args),
count: 1,
});
}
}
};
}

View File

@@ -0,0 +1,21 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as os from 'os';
import * as path from 'path';
export function resolvePath(p: string): string {
if (!p) {
return '';
}
let expandedPath = p;
if (p.toLowerCase().startsWith('%userprofile%')) {
expandedPath = os.homedir() + p.substring('%userprofile%'.length);
} else if (p === '~' || p.startsWith('~/')) {
expandedPath = os.homedir() + p.substring(1);
}
return path.normalize(expandedPath);
}

View File

@@ -562,6 +562,10 @@ export async function start_sandbox(
if (process.env.OPENAI_API_KEY) {
args.push('--env', `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`);
}
// copy TAVILY_API_KEY for web search tool
if (process.env.TAVILY_API_KEY) {
args.push('--env', `TAVILY_API_KEY=${process.env.TAVILY_API_KEY}`);
}
if (process.env.OPENAI_BASE_URL) {
args.push('--env', `OPENAI_BASE_URL=${process.env.OPENAI_BASE_URL}`);
}

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.0.5",
"version": "0.0.7",
"description": "Qwen Code Core",
"repository": {
"type": "git",
@@ -33,15 +33,18 @@
"chardet": "^2.1.0",
"diff": "^7.0.0",
"dotenv": "^17.1.0",
"fdir": "^6.4.6",
"glob": "^10.4.5",
"google-auth-library": "^9.11.0",
"html-to-text": "^9.0.5",
"https-proxy-agent": "^7.0.6",
"ignore": "^7.0.0",
"jsonrepair": "^3.13.0",
"marked": "^15.0.12",
"micromatch": "^4.0.8",
"open": "^10.1.2",
"openai": "^5.7.0",
"openai": "5.11.0",
"picomatch": "^4.0.1",
"shell-quote": "^1.8.3",
"simple-git": "^3.28.0",
"strip-ansi": "^7.1.0",
@@ -50,10 +53,12 @@
"ws": "^8.18.0"
},
"devDependencies": {
"@qwen-code/qwen-code-test-utils": "file:../test-utils",
"@types/diff": "^7.0.2",
"@types/dotenv": "^6.1.1",
"@types/micromatch": "^4.0.8",
"@types/minimatch": "^5.1.2",
"@types/picomatch": "^4.0.1",
"@types/ws": "^8.5.10",
"typescript": "^5.3.3",
"vitest": "^3.1.1"

View File

@@ -18,7 +18,18 @@ import {
} from '../core/contentGenerator.js';
import { GeminiClient } from '../core/client.js';
import { GitService } from '../services/gitService.js';
import { IdeClient } from '../ide/ide-client.js';
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return {
...actual,
existsSync: vi.fn().mockReturnValue(true),
statSync: vi.fn().mockReturnValue({
isDirectory: vi.fn().mockReturnValue(true),
}),
realpathSync: vi.fn((path) => path),
};
});
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
@@ -120,7 +131,6 @@ describe('Server Config (config.ts)', () => {
telemetry: TELEMETRY_SETTINGS,
sessionId: SESSION_ID,
model: MODEL,
ideClient: IdeClient.getInstance(false),
};
beforeEach(() => {

View File

@@ -48,6 +48,8 @@ import { shouldAttemptBrowserLaunch } from '../utils/browser.js';
import { MCPOAuthConfig } from '../mcp/oauth-provider.js';
import { IdeClient } from '../ide/ide-client.js';
import type { Content } from '@google/genai';
import { logIdeConnection } from '../telemetry/loggers.js';
import { IdeConnectionEvent, IdeConnectionType } from '../telemetry/types.js';
// Re-export OAuth config type
export type { MCPOAuthConfig };
@@ -196,7 +198,6 @@ export interface ConfigParameters {
summarizeToolOutput?: Record<string, SummarizeToolOutputSettings>;
ideModeFeature?: boolean;
ideMode?: boolean;
ideClient?: IdeClient;
enableOpenAILogging?: boolean;
sampling_params?: Record<string, unknown>;
systemPromptMappings?: Array<{
@@ -209,6 +210,9 @@ export interface ConfigParameters {
maxRetries?: number;
};
cliVersion?: string;
loadMemoryFromIncludeDirectories?: boolean;
// Web search providers
tavilyApiKey?: string;
}
export class Config {
@@ -283,6 +287,9 @@ export class Config {
maxRetries?: number;
};
private readonly cliVersion?: string;
private readonly loadMemoryFromIncludeDirectories: boolean = false;
private readonly tavilyApiKey?: string;
constructor(params: ConfigParameters) {
this.sessionId = params.sessionId;
this.embeddingModel =
@@ -345,15 +352,23 @@ export class Config {
this.summarizeToolOutput = params.summarizeToolOutput;
this.ideModeFeature = params.ideModeFeature ?? false;
this.ideMode = params.ideMode ?? false;
this.ideClient =
params.ideClient ??
IdeClient.getInstance(this.ideMode && this.ideModeFeature);
this.ideClient = IdeClient.getInstance();
if (this.ideMode && this.ideModeFeature) {
this.ideClient.connect();
logIdeConnection(this, new IdeConnectionEvent(IdeConnectionType.START));
}
this.systemPromptMappings = params.systemPromptMappings;
this.enableOpenAILogging = params.enableOpenAILogging ?? false;
this.sampling_params = params.sampling_params;
this.contentGenerator = params.contentGenerator;
this.cliVersion = params.cliVersion;
this.loadMemoryFromIncludeDirectories =
params.loadMemoryFromIncludeDirectories ?? false;
// Web search
this.tavilyApiKey = params.tavilyApiKey;
if (params.contextFileName) {
setGeminiMdFilename(params.contextFileName);
}
@@ -415,6 +430,10 @@ export class Config {
return this.sessionId;
}
shouldLoadMemoryFromIncludeDirectories(): boolean {
return this.loadMemoryFromIncludeDirectories;
}
getContentGeneratorConfig(): ContentGeneratorConfig {
return this.contentGeneratorConfig;
}
@@ -682,6 +701,11 @@ export class Config {
return this.summarizeToolOutput;
}
// Web search provider configuration
getTavilyApiKey(): string | undefined {
return this.tavilyApiKey;
}
getIdeModeFeature(): boolean {
return this.ideModeFeature;
}
@@ -698,12 +722,14 @@ export class Config {
this.ideMode = value;
}
setIdeClientDisconnected(): void {
this.ideClient.setDisconnected();
}
setIdeClientConnected(): void {
this.ideClient.reconnect(this.ideMode && this.ideModeFeature);
async setIdeModeAndSyncConnection(value: boolean): Promise<void> {
this.ideMode = value;
if (value) {
await this.ideClient.connect();
logIdeConnection(this, new IdeConnectionEvent(IdeConnectionType.SESSION));
} else {
this.ideClient.disconnect();
}
}
getEnableOpenAILogging(): boolean {
@@ -790,7 +816,10 @@ export class Config {
registerCoreTool(ReadManyFilesTool, this);
registerCoreTool(ShellTool, this);
registerCoreTool(MemoryTool);
registerCoreTool(WebSearchTool, this);
// Conditionally register web search tool only if Tavily API key is set
if (this.getTavilyApiKey()) {
registerCoreTool(WebSearchTool, this);
}
await registry.discoverAllTools();
return registry;

View File

@@ -7,7 +7,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Config } from './config.js';
import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL } from './models.js';
import { IdeClient } from '../ide/ide-client.js';
import fs from 'node:fs';
vi.mock('node:fs');
@@ -26,7 +25,6 @@ describe('Flash Model Fallback Configuration', () => {
debugMode: false,
cwd: '/test',
model: DEFAULT_GEMINI_MODEL,
ideClient: IdeClient.getInstance(false),
});
// Initialize contentGeneratorConfig for testing
@@ -51,7 +49,6 @@ describe('Flash Model Fallback Configuration', () => {
debugMode: false,
cwd: '/test',
model: DEFAULT_GEMINI_MODEL,
ideClient: IdeClient.getInstance(false),
});
// Should not crash when contentGeneratorConfig is undefined
@@ -75,7 +72,6 @@ describe('Flash Model Fallback Configuration', () => {
debugMode: false,
cwd: '/test',
model: 'custom-model',
ideClient: IdeClient.getInstance(false),
});
expect(newConfig.getModel()).toBe('custom-model');

View File

@@ -136,6 +136,7 @@ describe('CoreToolScheduler', () => {
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
onEditorClose: vi.fn(),
});
const abortController = new AbortController();
@@ -205,6 +206,7 @@ describe('CoreToolScheduler with payload', () => {
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
onEditorClose: vi.fn(),
});
const abortController = new AbortController();
@@ -482,6 +484,7 @@ describe('CoreToolScheduler edit cancellation', () => {
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
onEditorClose: vi.fn(),
});
const abortController = new AbortController();
@@ -571,6 +574,7 @@ describe('CoreToolScheduler YOLO mode', () => {
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
onEditorClose: vi.fn(),
});
const abortController = new AbortController();

View File

@@ -224,6 +224,7 @@ interface CoreToolSchedulerOptions {
onToolCallsUpdate?: ToolCallsUpdateHandler;
getPreferredEditor: () => EditorType | undefined;
config: Config;
onEditorClose: () => void;
}
export class CoreToolScheduler {
@@ -234,6 +235,7 @@ export class CoreToolScheduler {
private onToolCallsUpdate?: ToolCallsUpdateHandler;
private getPreferredEditor: () => EditorType | undefined;
private config: Config;
private onEditorClose: () => void;
constructor(options: CoreToolSchedulerOptions) {
this.config = options.config;
@@ -242,6 +244,7 @@ export class CoreToolScheduler {
this.onAllToolCallsComplete = options.onAllToolCallsComplete;
this.onToolCallsUpdate = options.onToolCallsUpdate;
this.getPreferredEditor = options.getPreferredEditor;
this.onEditorClose = options.onEditorClose;
}
private setStatusInternal(
@@ -563,6 +566,7 @@ export class CoreToolScheduler {
modifyContext as ModifyContext<typeof waitingToolCall.request.args>,
editorType,
signal,
this.onEditorClose,
);
this.setArgsInternal(callId, updatedParams);
this.setStatusInternal(callId, 'awaiting_approval', {

View File

@@ -1160,6 +1160,90 @@ describe('OpenAIContentGenerator', () => {
);
});
it('should handle MCP tools with parametersJsonSchema', async () => {
const mockResponse = {
id: 'chatcmpl-123',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Response' },
finish_reason: 'stop',
},
],
created: 1677652288,
model: 'gpt-4',
};
mockOpenAIClient.chat.completions.create.mockResolvedValue(mockResponse);
const request: GenerateContentParameters = {
contents: [{ role: 'user', parts: [{ text: 'Test' }] }],
model: 'gpt-4',
config: {
tools: [
{
callTool: vi.fn(),
tool: () =>
Promise.resolve({
functionDeclarations: [
{
name: 'list-items',
description: 'Get a list of items',
parametersJsonSchema: {
type: 'object',
properties: {
page_number: {
type: 'number',
description: 'Page number',
},
page_size: {
type: 'number',
description: 'Number of items per page',
},
},
additionalProperties: false,
$schema: 'http://json-schema.org/draft-07/schema#',
},
},
],
}),
} as unknown as CallableTool,
],
},
};
await generator.generateContent(request, 'test-prompt-id');
expect(mockOpenAIClient.chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
tools: [
{
type: 'function',
function: {
name: 'list-items',
description: 'Get a list of items',
parameters: {
type: 'object',
properties: {
page_number: {
type: 'number',
description: 'Page number',
},
page_size: {
type: 'number',
description: 'Number of items per page',
},
},
additionalProperties: false,
$schema: 'http://json-schema.org/draft-07/schema#',
},
},
},
],
}),
);
});
it('should handle nested parameter objects', async () => {
const mockResponse = {
id: 'chatcmpl-123',

View File

@@ -26,6 +26,7 @@ import { logApiError, logApiResponse } from '../telemetry/loggers.js';
import { ApiErrorEvent, ApiResponseEvent } from '../telemetry/types.js';
import { Config } from '../config/config.js';
import { openaiLogger } from '../utils/openaiLogger.js';
import { safeJsonParse } from '../utils/safeJsonParse.js';
// OpenAI API type definitions for logging
interface OpenAIToolCall {
@@ -365,8 +366,6 @@ export class OpenAIContentGenerator implements ContentGenerator {
);
}
// console.log('createParams', createParams);
const stream = (await this.client.chat.completions.create(
createParams,
)) as AsyncIterable<OpenAI.Chat.ChatCompletionChunk>;
@@ -564,7 +563,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
// Add combined text if any
if (combinedText) {
combinedParts.push({ text: combinedText });
combinedParts.push({ text: combinedText.trimEnd() });
}
// Add function calls
@@ -741,6 +740,16 @@ export class OpenAIContentGenerator implements ContentGenerator {
return convertTypes(converted) as Record<string, unknown> | undefined;
}
/**
* Converts Gemini tools to OpenAI format for API compatibility.
* Handles both Gemini tools (using 'parameters' field) and MCP tools (using 'parametersJsonSchema' field).
*
* Gemini tools use a custom parameter format that needs conversion to OpenAI JSON Schema format.
* MCP tools already use JSON Schema format in the parametersJsonSchema field and can be used directly.
*
* @param geminiTools - Array of Gemini tools to convert
* @returns Promise resolving to array of OpenAI-compatible tools
*/
private async convertGeminiToolsToOpenAI(
geminiTools: ToolListUnion,
): Promise<OpenAI.Chat.ChatCompletionTool[]> {
@@ -761,14 +770,31 @@ export class OpenAIContentGenerator implements ContentGenerator {
if (actualTool.functionDeclarations) {
for (const func of actualTool.functionDeclarations) {
if (func.name && func.description) {
let parameters: Record<string, unknown> | undefined;
// Handle both Gemini tools (parameters) and MCP tools (parametersJsonSchema)
if (func.parametersJsonSchema) {
// MCP tool format - use parametersJsonSchema directly
if (func.parametersJsonSchema) {
// Create a shallow copy to avoid mutating the original object
const paramsCopy = {
...(func.parametersJsonSchema as Record<string, unknown>),
};
parameters = paramsCopy;
}
} else if (func.parameters) {
// Gemini tool format - convert parameters to OpenAI format
parameters = this.convertGeminiParametersToOpenAI(
func.parameters as Record<string, unknown>,
);
}
openAITools.push({
type: 'function',
function: {
name: func.name,
description: func.description,
parameters: this.convertGeminiParametersToOpenAI(
(func.parameters || {}) as Record<string, unknown>,
),
parameters,
},
});
}
@@ -1138,7 +1164,11 @@ export class OpenAIContentGenerator implements ContentGenerator {
// Handle text content
if (choice.message.content) {
parts.push({ text: choice.message.content });
if (typeof choice.message.content === 'string') {
parts.push({ text: choice.message.content.trimEnd() });
} else {
parts.push({ text: choice.message.content });
}
}
// Handle tool calls
@@ -1147,12 +1177,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
if (toolCall.function) {
let args: Record<string, unknown> = {};
if (toolCall.function.arguments) {
try {
args = JSON.parse(toolCall.function.arguments);
} catch (error) {
console.error('Failed to parse function arguments:', error);
args = {};
}
args = safeJsonParse(toolCall.function.arguments, {});
}
parts.push({
@@ -1228,7 +1253,11 @@ export class OpenAIContentGenerator implements ContentGenerator {
// Handle text content
if (choice.delta?.content) {
parts.push({ text: choice.delta.content });
if (typeof choice.delta.content === 'string') {
parts.push({ text: choice.delta.content.trimEnd() });
} else {
parts.push({ text: choice.delta.content });
}
}
// Handle tool calls - only accumulate during streaming, emit when complete
@@ -1264,19 +1293,14 @@ export class OpenAIContentGenerator implements ContentGenerator {
if (accumulatedCall.name) {
let args: Record<string, unknown> = {};
if (accumulatedCall.arguments) {
try {
args = JSON.parse(accumulatedCall.arguments);
} catch (error) {
console.error(
'Failed to parse final tool call arguments:',
error,
);
}
args = safeJsonParse(accumulatedCall.arguments, {});
}
parts.push({
functionCall: {
id: accumulatedCall.id,
id:
accumulatedCall.id ||
`call_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
name: accumulatedCall.name,
args,
},
@@ -1752,7 +1776,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
}
}
messageContent = textParts.join('');
messageContent = textParts.join('').trimEnd();
}
const choice: OpenAIChoice = {

View File

@@ -33,34 +33,58 @@ export enum IDEConnectionStatus {
* Manages the connection to and interaction with the IDE server.
*/
export class IdeClient {
client: Client | undefined = undefined;
private static instance: IdeClient;
private client: Client | undefined = undefined;
private state: IDEConnectionState = {
status: IDEConnectionStatus.Disconnected,
details:
'IDE integration is currently disabled. To enable it, run /ide enable.',
};
private static instance: IdeClient;
private readonly currentIde: DetectedIde | undefined;
private readonly currentIdeDisplayName: string | undefined;
constructor(ideMode: boolean) {
private constructor() {
this.currentIde = detectIde();
if (this.currentIde) {
this.currentIdeDisplayName = getIdeDisplayName(this.currentIde);
}
if (!ideMode) {
return;
}
this.init().catch((err) => {
logger.debug('Failed to initialize IdeClient:', err);
});
}
static getInstance(ideMode: boolean): IdeClient {
static getInstance(): IdeClient {
if (!IdeClient.instance) {
IdeClient.instance = new IdeClient(ideMode);
IdeClient.instance = new IdeClient();
}
return IdeClient.instance;
}
async connect(): Promise<void> {
this.setState(IDEConnectionStatus.Connecting);
if (!this.currentIde || !this.currentIdeDisplayName) {
this.setState(IDEConnectionStatus.Disconnected);
return;
}
if (!this.validateWorkspacePath()) {
return;
}
const port = this.getPortFromEnv();
if (!port) {
return;
}
await this.establishConnection(port);
}
disconnect() {
this.setState(
IDEConnectionStatus.Disconnected,
'IDE integration disabled. To enable it again, run /ide enable.',
);
this.client?.close();
}
getCurrentIde(): DetectedIde | undefined {
return this.currentIde;
}
@@ -70,45 +94,60 @@ export class IdeClient {
}
private setState(status: IDEConnectionStatus, details?: string) {
this.state = { status, details };
const isAlreadyDisconnected =
this.state.status === IDEConnectionStatus.Disconnected &&
status === IDEConnectionStatus.Disconnected;
// Only update details if the state wasn't already disconnected, so that
// the first detail message is preserved.
if (!isAlreadyDisconnected) {
this.state = { status, details };
}
if (status === IDEConnectionStatus.Disconnected) {
logger.debug('IDE integration is disconnected. ', details);
logger.debug('IDE integration disconnected:', details);
ideContext.clearIdeContext();
}
}
private validateWorkspacePath(): boolean {
const ideWorkspacePath = process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'];
if (ideWorkspacePath === undefined) {
this.setState(
IDEConnectionStatus.Disconnected,
`Failed to connect to IDE companion extension for ${this.currentIdeDisplayName}. Please ensure the extension is running and try refreshing your terminal. To install the extension, run /ide install.`,
);
return false;
}
if (ideWorkspacePath === '') {
this.setState(
IDEConnectionStatus.Disconnected,
`To use this feature, please open a single workspace folder in ${this.currentIdeDisplayName} and try again.`,
);
return false;
}
if (ideWorkspacePath !== process.cwd()) {
this.setState(
IDEConnectionStatus.Disconnected,
`Directory mismatch. Gemini CLI is running in a different location than the open workspace in ${this.currentIdeDisplayName}. Please run the CLI from the same directory as your project's root folder.`,
);
return false;
}
return true;
}
private getPortFromEnv(): string | undefined {
const port = process.env['GEMINI_CLI_IDE_SERVER_PORT'];
if (!port) {
this.setState(
IDEConnectionStatus.Disconnected,
'Gemini CLI Companion extension not found. Install via /ide install and restart the CLI in a fresh terminal window.',
`Failed to connect to IDE companion extension for ${this.currentIdeDisplayName}. Please ensure the extension is running and try refreshing your terminal. To install the extension, run /ide install.`,
);
return undefined;
}
return port;
}
private validateWorkspacePath(): boolean {
const ideWorkspacePath = process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'];
if (!ideWorkspacePath) {
this.setState(
IDEConnectionStatus.Disconnected,
'IDE integration requires a single workspace folder to be open in the IDE. Please ensure one folder is open and try again.',
);
return false;
}
if (ideWorkspacePath !== process.cwd()) {
this.setState(
IDEConnectionStatus.Disconnected,
`Gemini CLI is running in a different directory (${process.cwd()}) from the IDE's open workspace (${ideWorkspacePath}). Please run Gemini CLI in the same directory.`,
);
return false;
}
return true;
}
private registerClientHandlers() {
if (!this.client) {
return;
@@ -120,20 +159,20 @@ export class IdeClient {
ideContext.setIdeContext(notification.params);
},
);
this.client.onerror = (_error) => {
this.setState(IDEConnectionStatus.Disconnected, 'Client error.');
this.setState(
IDEConnectionStatus.Disconnected,
`IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable`,
);
};
this.client.onclose = () => {
this.setState(IDEConnectionStatus.Disconnected, 'Connection closed.');
this.setState(
IDEConnectionStatus.Disconnected,
`IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable`,
);
};
}
async reconnect(ideMode: boolean) {
IdeClient.instance = new IdeClient(ideMode);
}
private async establishConnection(port: string) {
let transport: StreamableHTTPClientTransport | undefined;
try {
@@ -150,12 +189,12 @@ export class IdeClient {
this.registerClientHandlers();
await this.client.connect(transport);
this.registerClientHandlers();
this.setState(IDEConnectionStatus.Connected);
} catch (error) {
} catch (_error) {
this.setState(
IDEConnectionStatus.Disconnected,
`Failed to connect to IDE server: ${error}`,
`Failed to connect to IDE companion extension for ${this.currentIdeDisplayName}. Please ensure the extension is running and try refreshing your terminal. To install the extension, run /ide install.`,
);
if (transport) {
try {

View File

@@ -41,6 +41,7 @@ export * from './utils/shell-utils.js';
export * from './utils/systemEncoding.js';
export * from './utils/textUtils.js';
export * from './utils/formatters.js';
export * from './utils/filesearch/fileSearch.js';
// Export services
export * from './services/fileDiscoveryService.js';

View File

@@ -4,7 +4,17 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { vi } from 'vitest';
// Mock dependencies AT THE TOP
const mockOpenBrowserSecurely = vi.hoisted(() => vi.fn());
vi.mock('../utils/secure-browser-launcher.js', () => ({
openBrowserSecurely: mockOpenBrowserSecurely,
}));
vi.mock('node:crypto');
vi.mock('./oauth-token-storage.js');
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as http from 'node:http';
import * as crypto from 'node:crypto';
import {
@@ -15,14 +25,6 @@ import {
} from './oauth-provider.js';
import { MCPOAuthTokenStorage, MCPOAuthToken } from './oauth-token-storage.js';
// Mock dependencies
const mockOpenBrowserSecurely = vi.hoisted(() => vi.fn());
vi.mock('../utils/secure-browser-launcher.js', () => ({
openBrowserSecurely: mockOpenBrowserSecurely,
}));
vi.mock('node:crypto');
vi.mock('./oauth-token-storage.js');
// Mock fetch globally
const mockFetch = vi.fn();
global.fetch = mockFetch;
@@ -46,6 +48,7 @@ describe('MCPOAuthProvider', () => {
tokenUrl: 'https://auth.example.com/token',
scopes: ['read', 'write'],
redirectUri: 'http://localhost:7777/oauth/callback',
audiences: ['https://api.example.com'],
};
const mockToken: MCPOAuthToken = {
@@ -720,6 +723,105 @@ describe('MCPOAuthProvider', () => {
expect(capturedUrl!).toContain('code_challenge_method=S256');
expect(capturedUrl!).toContain('scope=read+write');
expect(capturedUrl!).toContain('resource=https%3A%2F%2Fauth.example.com');
expect(capturedUrl!).toContain('audience=https%3A%2F%2Fapi.example.com');
});
it('should correctly append parameters to an authorization URL that already has query params', async () => {
// Mock to capture the URL that would be opened
let capturedUrl: string;
mockOpenBrowserSecurely.mockImplementation((url: string) => {
capturedUrl = url;
return Promise.resolve();
});
let callbackHandler: unknown;
vi.mocked(http.createServer).mockImplementation((handler) => {
callbackHandler = handler;
return mockHttpServer as unknown as http.Server;
});
mockHttpServer.listen.mockImplementation((port, callback) => {
callback?.();
setTimeout(() => {
const mockReq = {
url: '/oauth/callback?code=auth_code_123&state=bW9ja19zdGF0ZV8xNl9ieXRlcw',
};
const mockRes = {
writeHead: vi.fn(),
end: vi.fn(),
};
(callbackHandler as (req: unknown, res: unknown) => void)(
mockReq,
mockRes,
);
}, 10);
});
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockTokenResponse),
});
const configWithParamsInUrl = {
...mockConfig,
authorizationUrl: 'https://auth.example.com/authorize?audience=1234',
};
await MCPOAuthProvider.authenticate('test-server', configWithParamsInUrl);
const url = new URL(capturedUrl!);
expect(url.searchParams.get('audience')).toBe('1234');
expect(url.searchParams.get('client_id')).toBe('test-client-id');
expect(url.search.startsWith('?audience=1234&')).toBe(true);
});
it('should correctly append parameters to a URL with a fragment', async () => {
// Mock to capture the URL that would be opened
let capturedUrl: string;
mockOpenBrowserSecurely.mockImplementation((url: string) => {
capturedUrl = url;
return Promise.resolve();
});
let callbackHandler: unknown;
vi.mocked(http.createServer).mockImplementation((handler) => {
callbackHandler = handler;
return mockHttpServer as unknown as http.Server;
});
mockHttpServer.listen.mockImplementation((port, callback) => {
callback?.();
setTimeout(() => {
const mockReq = {
url: '/oauth/callback?code=auth_code_123&state=bW9ja19zdGF0ZV8xNl9ieXRlcw',
};
const mockRes = {
writeHead: vi.fn(),
end: vi.fn(),
};
(callbackHandler as (req: unknown, res: unknown) => void)(
mockReq,
mockRes,
);
}, 10);
});
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockTokenResponse),
});
const configWithFragment = {
...mockConfig,
authorizationUrl: 'https://auth.example.com/authorize#login',
};
await MCPOAuthProvider.authenticate('test-server', configWithFragment);
const url = new URL(capturedUrl!);
expect(url.searchParams.get('client_id')).toBe('test-client-id');
expect(url.hash).toBe('#login');
expect(url.pathname).toBe('/authorize');
});
});
});

View File

@@ -22,6 +22,7 @@ export interface MCPOAuthConfig {
authorizationUrl?: string;
tokenUrl?: string;
scopes?: string[];
audiences?: string[];
redirectUri?: string;
tokenParamName?: string; // For SSE connections, specifies the query parameter name for the token
}
@@ -297,6 +298,10 @@ export class MCPOAuthProvider {
params.append('scope', config.scopes.join(' '));
}
if (config.audiences && config.audiences.length > 0) {
params.append('audience', config.audiences.join(' '));
}
// Add resource parameter for MCP OAuth spec compliance
// Use the MCP server URL if provided, otherwise fall back to authorization URL
const resourceUrl = mcpServerUrl || config.authorizationUrl!;
@@ -308,7 +313,11 @@ export class MCPOAuthProvider {
);
}
return `${config.authorizationUrl}?${params.toString()}`;
const url = new URL(config.authorizationUrl!);
params.forEach((value, key) => {
url.searchParams.append(key, value);
});
return url.toString();
}
/**
@@ -342,6 +351,10 @@ export class MCPOAuthProvider {
params.append('client_secret', config.clientSecret);
}
if (config.audiences && config.audiences.length > 0) {
params.append('audience', config.audiences.join(' '));
}
// Add resource parameter for MCP OAuth spec compliance
// Use the MCP server URL if provided, otherwise fall back to token URL
const resourceUrl = mcpServerUrl || config.tokenUrl!;
@@ -400,6 +413,10 @@ export class MCPOAuthProvider {
params.append('scope', config.scopes.join(' '));
}
if (config.audiences && config.audiences.length > 0) {
params.append('audience', config.audiences.join(' '));
}
// Add resource parameter for MCP OAuth spec compliance
// Use the MCP server URL if provided, otherwise fall back to token URL
const resourceUrl = mcpServerUrl || tokenUrl;

View File

@@ -53,4 +53,22 @@ export class PromptRegistry {
}
return serverPrompts.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Clears all the prompts from the registry.
*/
clear(): void {
this.prompts.clear();
}
/**
* Removes all prompts from a specific server.
*/
removePromptsByServer(serverName: string): void {
for (const [name, prompt] of this.prompts.entries()) {
if (prompt.serverName === serverName) {
this.prompts.delete(name);
}
}
}
}

View File

@@ -21,6 +21,7 @@ import {
NextSpeakerCheckEvent,
SlashCommandEvent,
MalformedJsonResponseEvent,
IdeConnectionEvent,
} from '../types.js';
import { EventMetadataKey } from './event-metadata-key.js';
import { Config } from '../../config/config.js';
@@ -44,6 +45,7 @@ const loop_detected_event_name = 'loop_detected';
const next_speaker_check_event_name = 'next_speaker_check';
const slash_command_event_name = 'slash_command';
const malformed_json_response_event_name = 'malformed_json_response';
const ide_connection_event_name = 'ide_connection';
export interface LogResponse {
nextRequestWaitMs?: number;
@@ -578,6 +580,18 @@ export class ClearcutLogger {
this.flushIfNeeded();
}
logIdeConnectionEvent(event: IdeConnectionEvent): void {
const data = [
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_IDE_CONNECTION_TYPE,
value: JSON.stringify(event.connection_type),
},
];
this.enqueueLogEvent(this.createLogEvent(ide_connection_event_name, data));
this.flushIfNeeded();
}
logEndSessionEvent(event: EndSessionEvent): void {
const data = [
{

View File

@@ -190,6 +190,13 @@ export enum EventMetadataKey {
// Logs the model that produced the malformed JSON response.
GEMINI_CLI_MALFORMED_JSON_RESPONSE_MODEL = 45,
// ==========================================================================
// IDE Connection Event Keys
// ===========================================================================
// Logs the type of the IDE connection.
GEMINI_CLI_IDE_CONNECTION_TYPE = 46,
}
export function getEventMetadataKey(

View File

@@ -15,6 +15,7 @@ export const EVENT_CLI_CONFIG = 'qwen-code.config';
export const EVENT_FLASH_FALLBACK = 'qwen-code.flash_fallback';
export const EVENT_NEXT_SPEAKER_CHECK = 'qwen-code.next_speaker_check';
export const EVENT_SLASH_COMMAND = 'qwen-code.slash_command';
export const EVENT_IDE_CONNECTION = 'qwen-code.ide_connection';
export const METRIC_TOOL_CALL_COUNT = 'qwen-code.tool.call.count';
export const METRIC_TOOL_CALL_LATENCY = 'qwen-code.tool.call.latency';

View File

@@ -12,6 +12,7 @@ import {
EVENT_API_REQUEST,
EVENT_API_RESPONSE,
EVENT_CLI_CONFIG,
EVENT_IDE_CONNECTION,
EVENT_TOOL_CALL,
EVENT_USER_PROMPT,
EVENT_FLASH_FALLBACK,
@@ -23,6 +24,7 @@ import {
ApiErrorEvent,
ApiRequestEvent,
ApiResponseEvent,
IdeConnectionEvent,
StartSessionEvent,
ToolCallEvent,
UserPromptEvent,
@@ -355,3 +357,23 @@ export function logSlashCommand(
};
logger.emit(logRecord);
}
export function logIdeConnection(
config: Config,
event: IdeConnectionEvent,
): void {
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
...getCommonAttributes(config),
...event,
'event.name': EVENT_IDE_CONNECTION,
};
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: `Ide connection. Type: ${event.connection_type}.`,
attributes,
};
logger.emit(logRecord);
}

View File

@@ -82,10 +82,12 @@ export class QwenLogger {
return undefined;
if (!QwenLogger.instance) {
QwenLogger.instance = new QwenLogger(config);
process.on(
'exit',
QwenLogger.instance.shutdown.bind(QwenLogger.instance),
);
}
process.on('exit', QwenLogger.instance.shutdown.bind(QwenLogger.instance));
return QwenLogger.instance;
}
@@ -322,19 +324,16 @@ export class QwenLogger {
this.flushIfNeeded();
}
logApiRequestEvent(_event: ApiRequestEvent): void {
// ignore for now
return;
logApiRequestEvent(event: ApiRequestEvent): void {
const rumEvent = this.createResourceEvent('api', 'api_request', {
properties: {
model: event.model,
prompt_id: event.prompt_id,
},
});
// const rumEvent = this.createResourceEvent('api', 'api_request', {
// properties: {
// model: event.model,
// prompt_id: event.prompt_id,
// },
// });
// this.enqueueLogEvent(rumEvent);
// this.flushIfNeeded();
this.enqueueLogEvent(rumEvent);
this.flushIfNeeded();
}
logApiResponseEvent(event: ApiResponseEvent): void {

View File

@@ -12,7 +12,6 @@ import {
} from './sdk.js';
import { Config } from '../config/config.js';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { IdeClient } from '../ide/ide-client.js';
vi.mock('@opentelemetry/sdk-node');
vi.mock('../config/config.js');
@@ -30,7 +29,6 @@ describe('telemetry', () => {
targetDir: '/test/dir',
debugMode: false,
cwd: '/test/dir',
ideClient: IdeClient.getInstance(false),
});
vi.spyOn(mockConfig, 'getTelemetryEnabled').mockReturnValue(true);
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(

View File

@@ -314,6 +314,23 @@ export class MalformedJsonResponseEvent {
}
}
export enum IdeConnectionType {
START = 'start',
SESSION = 'session',
}
export class IdeConnectionEvent {
'event.name': 'ide_connection';
'event.timestamp': string; // ISO 8601
connection_type: IdeConnectionType;
constructor(connection_type: IdeConnectionType) {
this['event.name'] = 'ide_connection';
this['event.timestamp'] = new Date().toISOString();
this.connection_type = connection_type;
}
}
export type TelemetryEvent =
| StartSessionEvent
| EndSessionEvent
@@ -326,4 +343,5 @@ export type TelemetryEvent =
| LoopDetectedEvent
| NextSpeakerCheckEvent
| SlashCommandEvent
| MalformedJsonResponseEvent;
| MalformedJsonResponseEvent
| IdeConnectionEvent;

View File

@@ -58,9 +58,7 @@ describe('mcp-client', () => {
const mockedClient = {} as unknown as ClientLib.Client;
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {
// no-op
});
.mockImplementation(() => {});
const testError = new Error('Invalid tool name');
vi.mocked(DiscoveredMCPTool).mockImplementation(
@@ -113,12 +111,17 @@ describe('mcp-client', () => {
{ name: 'prompt2' },
],
});
const mockGetServerCapabilities = vi.fn().mockReturnValue({
prompts: {},
});
const mockedClient = {
getServerCapabilities: mockGetServerCapabilities,
request: mockRequest,
} as unknown as ClientLib.Client;
await discoverPrompts('test-server', mockedClient, mockedPromptRegistry);
expect(mockGetServerCapabilities).toHaveBeenCalledOnce();
expect(mockRequest).toHaveBeenCalledWith(
{ method: 'prompts/list', params: {} },
expect.anything(),
@@ -129,37 +132,67 @@ describe('mcp-client', () => {
const mockRequest = vi.fn().mockResolvedValue({
prompts: [],
});
const mockGetServerCapabilities = vi.fn().mockReturnValue({
prompts: {},
});
const mockedClient = {
getServerCapabilities: mockGetServerCapabilities,
request: mockRequest,
} as unknown as ClientLib.Client;
const consoleLogSpy = vi
.spyOn(console, 'debug')
.mockImplementation(() => {
// no-op
});
.mockImplementation(() => {});
await discoverPrompts('test-server', mockedClient, mockedPromptRegistry);
expect(mockGetServerCapabilities).toHaveBeenCalledOnce();
expect(mockRequest).toHaveBeenCalledOnce();
expect(consoleLogSpy).not.toHaveBeenCalled();
consoleLogSpy.mockRestore();
});
it('should do nothing if the server has no prompt support', async () => {
const mockRequest = vi.fn().mockResolvedValue({
prompts: [],
});
const mockGetServerCapabilities = vi.fn().mockReturnValue({});
const mockedClient = {
getServerCapabilities: mockGetServerCapabilities,
request: mockRequest,
} as unknown as ClientLib.Client;
const consoleLogSpy = vi
.spyOn(console, 'debug')
.mockImplementation(() => {});
await discoverPrompts('test-server', mockedClient, mockedPromptRegistry);
expect(mockGetServerCapabilities).toHaveBeenCalledOnce();
expect(mockRequest).not.toHaveBeenCalled();
expect(consoleLogSpy).not.toHaveBeenCalled();
consoleLogSpy.mockRestore();
});
it('should log an error if discovery fails', async () => {
const testError = new Error('test error');
testError.message = 'test error';
const mockRequest = vi.fn().mockRejectedValue(testError);
const mockGetServerCapabilities = vi.fn().mockReturnValue({
prompts: {},
});
const mockedClient = {
getServerCapabilities: mockGetServerCapabilities,
request: mockRequest,
} as unknown as ClientLib.Client;
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {
// no-op
});
.mockImplementation(() => {});
await discoverPrompts('test-server', mockedClient, mockedPromptRegistry);

View File

@@ -496,6 +496,9 @@ export async function discoverPrompts(
promptRegistry: PromptRegistry,
): Promise<Prompt[]> {
try {
// Only request prompts if the server supports them.
if (mcpClient.getServerCapabilities()?.prompts == null) return [];
const response = await mcpClient.request(
{ method: 'prompts/list', params: {} },
ListPromptsResultSchema,

View File

@@ -131,8 +131,11 @@ describe('DiscoveredMCPTool', () => {
success: true,
details: 'executed',
};
const mockFunctionResponseContent: Part[] = [
{ text: JSON.stringify(mockToolSuccessResultObject) },
const mockFunctionResponseContent = [
{
type: 'text',
text: JSON.stringify(mockToolSuccessResultObject),
},
];
const mockMcpToolResponseParts: Part[] = [
{
@@ -149,11 +152,13 @@ describe('DiscoveredMCPTool', () => {
expect(mockCallTool).toHaveBeenCalledWith([
{ name: serverToolName, args: params },
]);
expect(toolResult.llmContent).toEqual(mockMcpToolResponseParts);
const stringifiedResponseContent = JSON.stringify(
mockToolSuccessResultObject,
);
expect(toolResult.llmContent).toEqual([
{ text: stringifiedResponseContent },
]);
expect(toolResult.returnDisplay).toBe(stringifiedResponseContent);
});
@@ -170,6 +175,9 @@ describe('DiscoveredMCPTool', () => {
mockCallTool.mockResolvedValue(mockMcpToolResponsePartsEmpty);
const toolResult: ToolResult = await tool.execute(params);
expect(toolResult.returnDisplay).toBe('```json\n[]\n```');
expect(toolResult.llmContent).toEqual([
{ text: '[Error: Could not parse tool response]' },
]);
});
it('should propagate rejection if mcpTool.callTool rejects', async () => {
@@ -186,6 +194,361 @@ describe('DiscoveredMCPTool', () => {
await expect(tool.execute(params)).rejects.toThrow(expectedError);
});
it('should handle a simple text response correctly', async () => {
const tool = new DiscoveredMCPTool(
mockCallableToolInstance,
serverName,
serverToolName,
baseDescription,
inputSchema,
);
const params = { query: 'test' };
const successMessage = 'This is a success message.';
// Simulate the response from the GenAI SDK, which wraps the MCP
// response in a functionResponse Part.
const sdkResponse: Part[] = [
{
functionResponse: {
name: serverToolName,
response: {
// The `content` array contains MCP ContentBlocks.
content: [{ type: 'text', text: successMessage }],
},
},
},
];
mockCallTool.mockResolvedValue(sdkResponse);
const toolResult = await tool.execute(params);
// 1. Assert that the llmContent sent to the scheduler is a clean Part array.
expect(toolResult.llmContent).toEqual([{ text: successMessage }]);
// 2. Assert that the display output is the simple text message.
expect(toolResult.returnDisplay).toBe(successMessage);
// 3. Verify that the underlying callTool was made correctly.
expect(mockCallTool).toHaveBeenCalledWith([
{ name: serverToolName, args: params },
]);
});
it('should handle an AudioBlock response', async () => {
const tool = new DiscoveredMCPTool(
mockCallableToolInstance,
serverName,
serverToolName,
baseDescription,
inputSchema,
);
const params = { action: 'play' };
const sdkResponse: Part[] = [
{
functionResponse: {
name: serverToolName,
response: {
content: [
{
type: 'audio',
data: 'BASE64_AUDIO_DATA',
mimeType: 'audio/mp3',
},
],
},
},
},
];
mockCallTool.mockResolvedValue(sdkResponse);
const toolResult = await tool.execute(params);
expect(toolResult.llmContent).toEqual([
{
text: `[Tool '${serverToolName}' provided the following audio data with mime-type: audio/mp3]`,
},
{
inlineData: {
mimeType: 'audio/mp3',
data: 'BASE64_AUDIO_DATA',
},
},
]);
expect(toolResult.returnDisplay).toBe('[Audio: audio/mp3]');
});
it('should handle a ResourceLinkBlock response', async () => {
const tool = new DiscoveredMCPTool(
mockCallableToolInstance,
serverName,
serverToolName,
baseDescription,
inputSchema,
);
const params = { resource: 'get' };
const sdkResponse: Part[] = [
{
functionResponse: {
name: serverToolName,
response: {
content: [
{
type: 'resource_link',
uri: 'file:///path/to/thing',
name: 'resource-name',
title: 'My Resource',
},
],
},
},
},
];
mockCallTool.mockResolvedValue(sdkResponse);
const toolResult = await tool.execute(params);
expect(toolResult.llmContent).toEqual([
{
text: 'Resource Link: My Resource at file:///path/to/thing',
},
]);
expect(toolResult.returnDisplay).toBe(
'[Link to My Resource: file:///path/to/thing]',
);
});
it('should handle an embedded text ResourceBlock response', async () => {
const tool = new DiscoveredMCPTool(
mockCallableToolInstance,
serverName,
serverToolName,
baseDescription,
inputSchema,
);
const params = { resource: 'get' };
const sdkResponse: Part[] = [
{
functionResponse: {
name: serverToolName,
response: {
content: [
{
type: 'resource',
resource: {
uri: 'file:///path/to/text.txt',
text: 'This is the text content.',
mimeType: 'text/plain',
},
},
],
},
},
},
];
mockCallTool.mockResolvedValue(sdkResponse);
const toolResult = await tool.execute(params);
expect(toolResult.llmContent).toEqual([
{ text: 'This is the text content.' },
]);
expect(toolResult.returnDisplay).toBe('This is the text content.');
});
it('should handle an embedded binary ResourceBlock response', async () => {
const tool = new DiscoveredMCPTool(
mockCallableToolInstance,
serverName,
serverToolName,
baseDescription,
inputSchema,
);
const params = { resource: 'get' };
const sdkResponse: Part[] = [
{
functionResponse: {
name: serverToolName,
response: {
content: [
{
type: 'resource',
resource: {
uri: 'file:///path/to/data.bin',
blob: 'BASE64_BINARY_DATA',
mimeType: 'application/octet-stream',
},
},
],
},
},
},
];
mockCallTool.mockResolvedValue(sdkResponse);
const toolResult = await tool.execute(params);
expect(toolResult.llmContent).toEqual([
{
text: `[Tool '${serverToolName}' provided the following embedded resource with mime-type: application/octet-stream]`,
},
{
inlineData: {
mimeType: 'application/octet-stream',
data: 'BASE64_BINARY_DATA',
},
},
]);
expect(toolResult.returnDisplay).toBe(
'[Embedded Resource: application/octet-stream]',
);
});
it('should handle a mix of content block types', async () => {
const tool = new DiscoveredMCPTool(
mockCallableToolInstance,
serverName,
serverToolName,
baseDescription,
inputSchema,
);
const params = { action: 'complex' };
const sdkResponse: Part[] = [
{
functionResponse: {
name: serverToolName,
response: {
content: [
{ type: 'text', text: 'First part.' },
{
type: 'image',
data: 'BASE64_IMAGE_DATA',
mimeType: 'image/jpeg',
},
{ type: 'text', text: 'Second part.' },
],
},
},
},
];
mockCallTool.mockResolvedValue(sdkResponse);
const toolResult = await tool.execute(params);
expect(toolResult.llmContent).toEqual([
{ text: 'First part.' },
{
text: `[Tool '${serverToolName}' provided the following image data with mime-type: image/jpeg]`,
},
{
inlineData: {
mimeType: 'image/jpeg',
data: 'BASE64_IMAGE_DATA',
},
},
{ text: 'Second part.' },
]);
expect(toolResult.returnDisplay).toBe(
'First part.\n[Image: image/jpeg]\nSecond part.',
);
});
it('should ignore unknown content block types', async () => {
const tool = new DiscoveredMCPTool(
mockCallableToolInstance,
serverName,
serverToolName,
baseDescription,
inputSchema,
);
const params = { action: 'test' };
const sdkResponse: Part[] = [
{
functionResponse: {
name: serverToolName,
response: {
content: [
{ type: 'text', text: 'Valid part.' },
{ type: 'future_block', data: 'some-data' },
],
},
},
},
];
mockCallTool.mockResolvedValue(sdkResponse);
const toolResult = await tool.execute(params);
expect(toolResult.llmContent).toEqual([{ text: 'Valid part.' }]);
expect(toolResult.returnDisplay).toBe(
'Valid part.\n[Unknown content type: future_block]',
);
});
it('should handle a complex mix of content block types', async () => {
const tool = new DiscoveredMCPTool(
mockCallableToolInstance,
serverName,
serverToolName,
baseDescription,
inputSchema,
);
const params = { action: 'super-complex' };
const sdkResponse: Part[] = [
{
functionResponse: {
name: serverToolName,
response: {
content: [
{ type: 'text', text: 'Here is a resource.' },
{
type: 'resource_link',
uri: 'file:///path/to/resource',
name: 'resource-name',
title: 'My Resource',
},
{
type: 'resource',
resource: {
uri: 'file:///path/to/text.txt',
text: 'Embedded text content.',
mimeType: 'text/plain',
},
},
{
type: 'image',
data: 'BASE64_IMAGE_DATA',
mimeType: 'image/jpeg',
},
],
},
},
},
];
mockCallTool.mockResolvedValue(sdkResponse);
const toolResult = await tool.execute(params);
expect(toolResult.llmContent).toEqual([
{ text: 'Here is a resource.' },
{
text: 'Resource Link: My Resource at file:///path/to/resource',
},
{ text: 'Embedded text content.' },
{
text: `[Tool '${serverToolName}' provided the following image data with mime-type: image/jpeg]`,
},
{
inlineData: {
mimeType: 'image/jpeg',
data: 'BASE64_IMAGE_DATA',
},
},
]);
expect(toolResult.returnDisplay).toBe(
'Here is a resource.\n[Link to My Resource: file:///path/to/resource]\nEmbedded text content.\n[Image: image/jpeg]',
);
});
});
describe('shouldConfirmExecute', () => {

View File

@@ -22,6 +22,40 @@ import {
type ToolParams = Record<string, unknown>;
// Discriminated union for MCP Content Blocks to ensure type safety.
type McpTextBlock = {
type: 'text';
text: string;
};
type McpMediaBlock = {
type: 'image' | 'audio';
mimeType: string;
data: string;
};
type McpResourceBlock = {
type: 'resource';
resource: {
text?: string;
blob?: string;
mimeType?: string;
};
};
type McpResourceLinkBlock = {
type: 'resource_link';
uri: string;
title?: string;
name?: string;
};
type McpContentBlock =
| McpTextBlock
| McpMediaBlock
| McpResourceBlock
| McpResourceLinkBlock;
export class DiscoveredMCPTool extends BaseTool<ToolParams, ToolResult> {
private static readonly allowlist: Set<string> = new Set();
@@ -114,70 +148,145 @@ export class DiscoveredMCPTool extends BaseTool<ToolParams, ToolResult> {
},
];
const responseParts: Part[] = await this.mcpTool.callTool(functionCalls);
const rawResponseParts = await this.mcpTool.callTool(functionCalls);
const transformedParts = transformMcpContentToParts(rawResponseParts);
return {
llmContent: responseParts,
returnDisplay: getStringifiedResultForDisplay(responseParts),
llmContent: transformedParts,
returnDisplay: getStringifiedResultForDisplay(rawResponseParts),
};
}
}
/**
* Processes an array of `Part` objects, primarily from a tool's execution result,
* to generate a user-friendly string representation, typically for display in a CLI.
*
* The `result` array can contain various types of `Part` objects:
* 1. `FunctionResponse` parts:
* - If the `response.content` of a `FunctionResponse` is an array consisting solely
* of `TextPart` objects, their text content is concatenated into a single string.
* This is to present simple textual outputs directly.
* - If `response.content` is an array but contains other types of `Part` objects (or a mix),
* the `content` array itself is preserved. This handles structured data like JSON objects or arrays
* returned by a tool.
* - If `response.content` is not an array or is missing, the entire `functionResponse`
* object is preserved.
* 2. Other `Part` types (e.g., `TextPart` directly in the `result` array):
* - These are preserved as is.
*
* All processed parts are then collected into an array, which is JSON.stringify-ed
* with indentation and wrapped in a markdown JSON code block.
*/
function getStringifiedResultForDisplay(result: Part[]) {
if (!result || result.length === 0) {
return '```json\n[]\n```';
function transformTextBlock(block: McpTextBlock): Part {
return { text: block.text };
}
function transformImageAudioBlock(
block: McpMediaBlock,
toolName: string,
): Part[] {
return [
{
text: `[Tool '${toolName}' provided the following ${
block.type
} data with mime-type: ${block.mimeType}]`,
},
{
inlineData: {
mimeType: block.mimeType,
data: block.data,
},
},
];
}
function transformResourceBlock(
block: McpResourceBlock,
toolName: string,
): Part | Part[] | null {
const resource = block.resource;
if (resource?.text) {
return { text: resource.text };
}
if (resource?.blob) {
const mimeType = resource.mimeType || 'application/octet-stream';
return [
{
text: `[Tool '${toolName}' provided the following embedded resource with mime-type: ${mimeType}]`,
},
{
inlineData: {
mimeType,
data: resource.blob,
},
},
];
}
return null;
}
const processFunctionResponse = (part: Part) => {
if (part.functionResponse) {
const responseContent = part.functionResponse.response?.content;
if (responseContent && Array.isArray(responseContent)) {
// Check if all parts in responseContent are simple TextParts
const allTextParts = responseContent.every(
(p: Part) => p.text !== undefined,
);
if (allTextParts) {
return responseContent.map((p: Part) => p.text).join('');
}
// If not all simple text parts, return the array of these content parts for JSON stringification
return responseContent;
}
// If no content, or not an array, or not a functionResponse, stringify the whole functionResponse part for inspection
return part.functionResponse;
}
return part; // Fallback for unexpected structure or non-FunctionResponsePart
function transformResourceLinkBlock(block: McpResourceLinkBlock): Part {
return {
text: `Resource Link: ${block.title || block.name} at ${block.uri}`,
};
}
const processedResults =
result.length === 1
? processFunctionResponse(result[0])
: result.map(processFunctionResponse);
if (typeof processedResults === 'string') {
return processedResults;
/**
* Transforms the raw MCP content blocks from the SDK response into a
* standard GenAI Part array.
* @param sdkResponse The raw Part[] array from `mcpTool.callTool()`.
* @returns A clean Part[] array ready for the scheduler.
*/
function transformMcpContentToParts(sdkResponse: Part[]): Part[] {
const funcResponse = sdkResponse?.[0]?.functionResponse;
const mcpContent = funcResponse?.response?.content as McpContentBlock[];
const toolName = funcResponse?.name || 'unknown tool';
if (!Array.isArray(mcpContent)) {
return [{ text: '[Error: Could not parse tool response]' }];
}
return '```json\n' + JSON.stringify(processedResults, null, 2) + '\n```';
const transformed = mcpContent.flatMap(
(block: McpContentBlock): Part | Part[] | null => {
switch (block.type) {
case 'text':
return transformTextBlock(block);
case 'image':
case 'audio':
return transformImageAudioBlock(block, toolName);
case 'resource':
return transformResourceBlock(block, toolName);
case 'resource_link':
return transformResourceLinkBlock(block);
default:
return null;
}
},
);
return transformed.filter((part): part is Part => part !== null);
}
/**
* Processes the raw response from the MCP tool to generate a clean,
* human-readable string for display in the CLI. It summarizes non-text
* content and presents text directly.
*
* @param rawResponse The raw Part[] array from the GenAI SDK.
* @returns A formatted string representing the tool's output.
*/
function getStringifiedResultForDisplay(rawResponse: Part[]): string {
const mcpContent = rawResponse?.[0]?.functionResponse?.response
?.content as McpContentBlock[];
if (!Array.isArray(mcpContent)) {
return '```json\n' + JSON.stringify(rawResponse, null, 2) + '\n```';
}
const displayParts = mcpContent.map((block: McpContentBlock): string => {
switch (block.type) {
case 'text':
return block.text;
case 'image':
return `[Image: ${block.mimeType}]`;
case 'audio':
return `[Audio: ${block.mimeType}]`;
case 'resource_link':
return `[Link to ${block.title || block.name}: ${block.uri}]`;
case 'resource':
if (block.resource?.text) {
return block.resource.text;
}
return `[Embedded Resource: ${
block.resource?.mimeType || 'unknown type'
}]`;
default:
return `[Unknown content type: ${(block as { type: string }).type}]`;
}
});
return displayParts.join('\n');
}
/** Visible for testing */

View File

@@ -94,6 +94,7 @@ describe('modifyWithEditor', () => {
mockModifyContext,
'vscode' as EditorType,
abortSignal,
vi.fn(),
);
expect(mockModifyContext.getCurrentContent).toHaveBeenCalledWith(
@@ -148,6 +149,7 @@ describe('modifyWithEditor', () => {
mockModifyContext,
'vscode' as EditorType,
abortSignal,
vi.fn(),
);
const stats = await fsp.stat(diffDir);
@@ -165,6 +167,7 @@ describe('modifyWithEditor', () => {
mockModifyContext,
'vscode' as EditorType,
abortSignal,
vi.fn(),
);
expect(mkdirSpy).not.toHaveBeenCalled();
@@ -183,6 +186,7 @@ describe('modifyWithEditor', () => {
mockModifyContext,
'vscode' as EditorType,
abortSignal,
vi.fn(),
);
expect(mockCreatePatch).toHaveBeenCalledWith(
@@ -211,6 +215,7 @@ describe('modifyWithEditor', () => {
mockModifyContext,
'vscode' as EditorType,
abortSignal,
vi.fn(),
);
expect(mockCreatePatch).toHaveBeenCalledWith(
@@ -241,6 +246,7 @@ describe('modifyWithEditor', () => {
mockModifyContext,
'vscode' as EditorType,
abortSignal,
vi.fn(),
),
).rejects.toThrow('Editor failed to open');
@@ -267,6 +273,7 @@ describe('modifyWithEditor', () => {
mockModifyContext,
'vscode' as EditorType,
abortSignal,
vi.fn(),
);
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
@@ -290,6 +297,7 @@ describe('modifyWithEditor', () => {
mockModifyContext,
'vscode' as EditorType,
abortSignal,
vi.fn(),
);
expect(mockOpenDiff).toHaveBeenCalledOnce();
@@ -311,6 +319,7 @@ describe('modifyWithEditor', () => {
mockModifyContext,
'vscode' as EditorType,
abortSignal,
vi.fn(),
);
expect(mockOpenDiff).toHaveBeenCalledOnce();

View File

@@ -138,6 +138,7 @@ export async function modifyWithEditor<ToolParams>(
modifyContext: ModifyContext<ToolParams>,
editorType: EditorType,
_abortSignal: AbortSignal,
onEditorClose: () => void,
): Promise<ModifyResult<ToolParams>> {
const currentContent = await modifyContext.getCurrentContent(originalParams);
const proposedContent =
@@ -150,7 +151,7 @@ export async function modifyWithEditor<ToolParams>(
);
try {
await openDiff(oldPath, newPath, editorType);
await openDiff(oldPath, newPath, editorType, onEditorClose);
const result = getUpdatedParams(
oldPath,
newPath,

View File

@@ -477,4 +477,139 @@ describe('ReadManyFilesTool', () => {
fs.rmSync(tempDir2, { recursive: true, force: true });
});
});
describe('Batch Processing', () => {
const createMultipleFiles = (count: number, contentPrefix = 'Content') => {
const files: string[] = [];
for (let i = 0; i < count; i++) {
const fileName = `file${i}.txt`;
createFile(fileName, `${contentPrefix} ${i}`);
files.push(fileName);
}
return files;
};
const createFile = (filePath: string, content = '') => {
const fullPath = path.join(tempRootDir, filePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
};
it('should process files in parallel for performance', async () => {
// Mock detectFileType to add artificial delay to simulate I/O
const detectFileTypeSpy = vi.spyOn(
await import('../utils/fileUtils.js'),
'detectFileType',
);
// Create files
const fileCount = 4;
const files = createMultipleFiles(fileCount, 'Batch test');
// Mock with 100ms delay per file to simulate I/O operations
detectFileTypeSpy.mockImplementation(async (_filePath: string) => {
await new Promise((resolve) => setTimeout(resolve, 100));
return 'text';
});
const startTime = Date.now();
const params = { paths: files };
const result = await tool.execute(params, new AbortController().signal);
const endTime = Date.now();
const processingTime = endTime - startTime;
console.log(
`Processing time: ${processingTime}ms for ${fileCount} files`,
);
// Verify parallel processing performance improvement
// Parallel processing should complete in ~100ms (single file time)
// Sequential would take ~400ms (4 files × 100ms each)
expect(processingTime).toBeLessThan(200); // Should PASS with parallel implementation
// Verify all files were processed
const content = result.llmContent as string[];
expect(content).toHaveLength(fileCount);
// Cleanup mock
detectFileTypeSpy.mockRestore();
});
it('should handle batch processing errors gracefully', async () => {
// Create mix of valid and problematic files
createFile('valid1.txt', 'Valid content 1');
createFile('valid2.txt', 'Valid content 2');
createFile('valid3.txt', 'Valid content 3');
const params = {
paths: [
'valid1.txt',
'valid2.txt',
'nonexistent-file.txt', // This will fail
'valid3.txt',
],
};
const result = await tool.execute(params, new AbortController().signal);
const content = result.llmContent as string[];
// Should successfully process valid files despite one failure
expect(content.length).toBeGreaterThanOrEqual(3);
expect(result.returnDisplay).toContain('Successfully read');
// Verify valid files were processed
const expectedPath1 = path.join(tempRootDir, 'valid1.txt');
const expectedPath3 = path.join(tempRootDir, 'valid3.txt');
expect(content.some((c) => c.includes(expectedPath1))).toBe(true);
expect(content.some((c) => c.includes(expectedPath3))).toBe(true);
});
it('should execute file operations concurrently', async () => {
// Track execution order to verify concurrency
const executionOrder: string[] = [];
const detectFileTypeSpy = vi.spyOn(
await import('../utils/fileUtils.js'),
'detectFileType',
);
const files = ['file1.txt', 'file2.txt', 'file3.txt'];
files.forEach((file) => createFile(file, 'test content'));
// Mock to track concurrent vs sequential execution
detectFileTypeSpy.mockImplementation(async (filePath: string) => {
const fileName = filePath.split('/').pop() || '';
executionOrder.push(`start:${fileName}`);
// Add delay to make timing differences visible
await new Promise((resolve) => setTimeout(resolve, 50));
executionOrder.push(`end:${fileName}`);
return 'text';
});
await tool.execute({ paths: files }, new AbortController().signal);
console.log('Execution order:', executionOrder);
// Verify concurrent execution pattern
// In parallel execution: all "start:" events should come before all "end:" events
// In sequential execution: "start:file1", "end:file1", "start:file2", "end:file2", etc.
const startEvents = executionOrder.filter((e) =>
e.startsWith('start:'),
).length;
const firstEndIndex = executionOrder.findIndex((e) =>
e.startsWith('end:'),
);
const startsBeforeFirstEnd = executionOrder
.slice(0, firstEndIndex)
.filter((e) => e.startsWith('start:')).length;
// For parallel processing, ALL start events should happen before the first end event
expect(startsBeforeFirstEnd).toBe(startEvents); // Should PASS with parallel implementation
detectFileTypeSpy.mockRestore();
});
});
});

View File

@@ -70,6 +70,27 @@ export interface ReadManyFilesParams {
};
}
/**
* Result type for file processing operations
*/
type FileProcessingResult =
| {
success: true;
filePath: string;
relativePathForDisplay: string;
fileReadResult: NonNullable<
Awaited<ReturnType<typeof processSingleFileContent>>
>;
reason?: undefined;
}
| {
success: false;
filePath: string;
relativePathForDisplay: string;
fileReadResult?: undefined;
reason: string;
};
/**
* Default exclusion patterns for commonly ignored directories and binary file types.
* These are compatible with glob ignore patterns.
@@ -413,66 +434,124 @@ Use this tool when the user's query implies needing the content of several files
const sortedFiles = Array.from(filesToConsider).sort();
for (const filePath of sortedFiles) {
const relativePathForDisplay = path
.relative(this.config.getTargetDir(), filePath)
.replace(/\\/g, '/');
const fileProcessingPromises = sortedFiles.map(
async (filePath): Promise<FileProcessingResult> => {
try {
const relativePathForDisplay = path
.relative(this.config.getTargetDir(), filePath)
.replace(/\\/g, '/');
const fileType = await detectFileType(filePath);
const fileType = await detectFileType(filePath);
if (fileType === 'image' || fileType === 'pdf') {
const fileExtension = path.extname(filePath).toLowerCase();
const fileNameWithoutExtension = path.basename(filePath, fileExtension);
const requestedExplicitly = inputPatterns.some(
(pattern: string) =>
pattern.toLowerCase().includes(fileExtension) ||
pattern.includes(fileNameWithoutExtension),
);
if (fileType === 'image' || fileType === 'pdf') {
const fileExtension = path.extname(filePath).toLowerCase();
const fileNameWithoutExtension = path.basename(
filePath,
fileExtension,
);
const requestedExplicitly = inputPatterns.some(
(pattern: string) =>
pattern.toLowerCase().includes(fileExtension) ||
pattern.includes(fileNameWithoutExtension),
);
if (!requestedExplicitly) {
skippedFiles.push({
path: relativePathForDisplay,
reason:
'asset file (image/pdf) was not explicitly requested by name or extension',
});
continue;
}
}
if (!requestedExplicitly) {
return {
success: false,
filePath,
relativePathForDisplay,
reason:
'asset file (image/pdf) was not explicitly requested by name or extension',
};
}
}
// Use processSingleFileContent for all file types now
const fileReadResult = await processSingleFileContent(
filePath,
this.config.getTargetDir(),
);
if (fileReadResult.error) {
skippedFiles.push({
path: relativePathForDisplay,
reason: `Read error: ${fileReadResult.error}`,
});
} else {
if (typeof fileReadResult.llmContent === 'string') {
const separator = DEFAULT_OUTPUT_SEPARATOR_FORMAT.replace(
'{filePath}',
// Use processSingleFileContent for all file types now
const fileReadResult = await processSingleFileContent(
filePath,
this.config.getTargetDir(),
);
contentParts.push(`${separator}\n\n${fileReadResult.llmContent}\n\n`);
} else {
contentParts.push(fileReadResult.llmContent); // This is a Part for image/pdf
if (fileReadResult.error) {
return {
success: false,
filePath,
relativePathForDisplay,
reason: `Read error: ${fileReadResult.error}`,
};
}
return {
success: true,
filePath,
relativePathForDisplay,
fileReadResult,
};
} catch (error) {
const relativePathForDisplay = path
.relative(this.config.getTargetDir(), filePath)
.replace(/\\/g, '/');
return {
success: false,
filePath,
relativePathForDisplay,
reason: `Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
};
}
processedFilesRelativePaths.push(relativePathForDisplay);
const lines =
typeof fileReadResult.llmContent === 'string'
? fileReadResult.llmContent.split('\n').length
: undefined;
const mimetype = getSpecificMimeType(filePath);
recordFileOperationMetric(
this.config,
FileOperation.READ,
lines,
mimetype,
path.extname(filePath),
);
},
);
const results = await Promise.allSettled(fileProcessingPromises);
for (const result of results) {
if (result.status === 'fulfilled') {
const fileResult = result.value;
if (!fileResult.success) {
// Handle skipped files (images/PDFs not requested or read errors)
skippedFiles.push({
path: fileResult.relativePathForDisplay,
reason: fileResult.reason,
});
} else {
// Handle successfully processed files
const { filePath, relativePathForDisplay, fileReadResult } =
fileResult;
if (typeof fileReadResult.llmContent === 'string') {
const separator = DEFAULT_OUTPUT_SEPARATOR_FORMAT.replace(
'{filePath}',
filePath,
);
contentParts.push(
`${separator}\n\n${fileReadResult.llmContent}\n\n`,
);
} else {
contentParts.push(fileReadResult.llmContent); // This is a Part for image/pdf
}
processedFilesRelativePaths.push(relativePathForDisplay);
const lines =
typeof fileReadResult.llmContent === 'string'
? fileReadResult.llmContent.split('\n').length
: undefined;
const mimetype = getSpecificMimeType(filePath);
recordFileOperationMetric(
this.config,
FileOperation.READ,
lines,
mimetype,
path.extname(filePath),
);
}
} else {
// Handle Promise rejection (unexpected errors)
skippedFiles.push({
path: 'unknown',
reason: `Unexpected error: ${result.reason}`,
});
}
}

View File

@@ -543,3 +543,37 @@ describe('validateToolParams', () => {
expect(result).toContain('is not a registered workspace directory');
});
});
describe('validateToolParams', () => {
it('should return null for valid directory', () => {
const config = {
getCoreTools: () => undefined,
getExcludeTools: () => undefined,
getTargetDir: () => '/root',
getWorkspaceContext: () =>
createMockWorkspaceContext('/root', ['/users/test']),
} as unknown as Config;
const shellTool = new ShellTool(config);
const result = shellTool.validateToolParams({
command: 'ls',
directory: 'test',
});
expect(result).toBeNull();
});
it('should return error for directory outside workspace', () => {
const config = {
getCoreTools: () => undefined,
getExcludeTools: () => undefined,
getTargetDir: () => '/root',
getWorkspaceContext: () =>
createMockWorkspaceContext('/root', ['/users/test']),
} as unknown as Config;
const shellTool = new ShellTool(config);
const result = shellTool.validateToolParams({
command: 'ls',
directory: 'test2',
});
expect(result).toContain('is not a registered workspace directory');
});
});

View File

@@ -30,7 +30,6 @@ import {
Schema,
} from '@google/genai';
import { spawn } from 'node:child_process';
import { IdeClient } from '../ide/ide-client.js';
import fs from 'node:fs';
vi.mock('node:fs');
@@ -140,7 +139,6 @@ const baseConfigParams: ConfigParameters = {
geminiMdFileCount: 0,
approvalMode: ApprovalMode.DEFAULT,
sessionId: 'test-session-id',
ideClient: IdeClient.getInstance(false),
};
describe('ToolRegistry', () => {
@@ -172,6 +170,10 @@ describe('ToolRegistry', () => {
);
vi.spyOn(config, 'getMcpServers');
vi.spyOn(config, 'getMcpServerCommand');
vi.spyOn(config, 'getPromptRegistry').mockReturnValue({
clear: vi.fn(),
removePromptsByServer: vi.fn(),
} as any);
mockDiscoverMcpTools.mockReset().mockResolvedValue(undefined);
});
@@ -353,7 +355,7 @@ describe('ToolRegistry', () => {
mcpServerConfigVal,
undefined,
toolRegistry,
undefined,
config.getPromptRegistry(),
false,
);
});
@@ -376,7 +378,7 @@ describe('ToolRegistry', () => {
mcpServerConfigVal,
undefined,
toolRegistry,
undefined,
config.getPromptRegistry(),
false,
);
});

View File

@@ -150,6 +150,14 @@ export class ToolRegistry {
this.tools.set(tool.name, tool);
}
private removeDiscoveredTools(): void {
for (const tool of this.tools.values()) {
if (tool instanceof DiscoveredTool || tool instanceof DiscoveredMCPTool) {
this.tools.delete(tool.name);
}
}
}
/**
* Discovers tools from project (if available and configured).
* Can be called multiple times to update discovered tools.
@@ -157,11 +165,9 @@ export class ToolRegistry {
*/
async discoverAllTools(): Promise<void> {
// remove any previously discovered tools
for (const tool of this.tools.values()) {
if (tool instanceof DiscoveredTool || tool instanceof DiscoveredMCPTool) {
this.tools.delete(tool.name);
}
}
this.removeDiscoveredTools();
this.config.getPromptRegistry().clear();
await this.discoverAndRegisterToolsFromCommand();
@@ -182,11 +188,9 @@ export class ToolRegistry {
*/
async discoverMcpTools(): Promise<void> {
// remove any previously discovered tools
for (const tool of this.tools.values()) {
if (tool instanceof DiscoveredMCPTool) {
this.tools.delete(tool.name);
}
}
this.removeDiscoveredTools();
this.config.getPromptRegistry().clear();
// discover tools using MCP servers, if configured
await discoverMcpTools(
@@ -210,6 +214,8 @@ export class ToolRegistry {
}
}
this.config.getPromptRegistry().removePromptsByServer(serverName);
const mcpServers = this.config.getMcpServers() ?? {};
const serverConfig = mcpServers[serverName];
if (serverConfig) {

View File

@@ -18,24 +18,10 @@ describe('WebFetchTool', () => {
describe('shouldConfirmExecute', () => {
it('should return confirmation details with the correct prompt and urls', async () => {
const tool = new WebFetchTool(mockConfig);
const params = { prompt: 'fetch https://example.com' };
const confirmationDetails = await tool.shouldConfirmExecute(params);
expect(confirmationDetails).toEqual({
type: 'info',
title: 'Confirm Web Fetch',
prompt: 'fetch https://example.com',
urls: ['https://example.com'],
onConfirm: expect.any(Function),
});
});
it('should convert github urls to raw format', async () => {
const tool = new WebFetchTool(mockConfig);
const params = {
prompt:
'fetch https://github.com/google/gemini-react/blob/main/README.md',
url: 'https://example.com',
prompt: 'summarize this page',
};
const confirmationDetails = await tool.shouldConfirmExecute(params);
@@ -43,10 +29,26 @@ describe('WebFetchTool', () => {
type: 'info',
title: 'Confirm Web Fetch',
prompt:
'fetch https://github.com/google/gemini-react/blob/main/README.md',
urls: [
'https://raw.githubusercontent.com/google/gemini-react/main/README.md',
],
'Fetch content from https://example.com and process with: summarize this page',
urls: ['https://example.com'],
onConfirm: expect.any(Function),
});
});
it('should return github urls as-is in confirmation details', async () => {
const tool = new WebFetchTool(mockConfig);
const params = {
url: 'https://github.com/google/gemini-react/blob/main/README.md',
prompt: 'summarize the README',
};
const confirmationDetails = await tool.shouldConfirmExecute(params);
expect(confirmationDetails).toEqual({
type: 'info',
title: 'Confirm Web Fetch',
prompt:
'Fetch content from https://github.com/google/gemini-react/blob/main/README.md and process with: summarize the README',
urls: ['https://github.com/google/gemini-react/blob/main/README.md'],
onConfirm: expect.any(Function),
});
});
@@ -56,7 +58,10 @@ describe('WebFetchTool', () => {
...mockConfig,
getApprovalMode: () => ApprovalMode.AUTO_EDIT,
} as unknown as Config);
const params = { prompt: 'fetch https://example.com' };
const params = {
url: 'https://example.com',
prompt: 'summarize this page',
};
const confirmationDetails = await tool.shouldConfirmExecute(params);
expect(confirmationDetails).toBe(false);
@@ -68,7 +73,10 @@ describe('WebFetchTool', () => {
...mockConfig,
setApprovalMode,
} as unknown as Config);
const params = { prompt: 'fetch https://example.com' };
const params = {
url: 'https://example.com',
prompt: 'summarize this page',
};
const confirmationDetails = await tool.shouldConfirmExecute(params);
if (

View File

@@ -13,49 +13,25 @@ import {
Icon,
} from './tools.js';
import { Type } from '@google/genai';
import { getErrorMessage } from '../utils/errors.js';
import { Config, ApprovalMode } from '../config/config.js';
import { getResponseText } from '../utils/generateContentResponseUtilities.js';
import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js';
import { fetchWithTimeout } from '../utils/fetch.js';
import { convert } from 'html-to-text';
import { ProxyAgent, setGlobalDispatcher } from 'undici';
const URL_FETCH_TIMEOUT_MS = 10000;
const MAX_CONTENT_LENGTH = 100000;
// Helper function to extract URLs from a string
function extractUrls(text: string): string[] {
const urlRegex = /(https?:\/\/[^\s]+)/g;
return text.match(urlRegex) || [];
}
// Interfaces for grounding metadata (similar to web-search.ts)
interface GroundingChunkWeb {
uri?: string;
title?: string;
}
interface GroundingChunkItem {
web?: GroundingChunkWeb;
}
interface GroundingSupportSegment {
startIndex: number;
endIndex: number;
text?: string;
}
interface GroundingSupportItem {
segment?: GroundingSupportSegment;
groundingChunkIndices?: number[];
}
/**
* Parameters for the WebFetch tool
*/
export interface WebFetchToolParams {
/**
* The prompt containing URL(s) (up to 20) and instructions for processing their content.
* The URL to fetch content from
*/
url: string;
/**
* The prompt to run on the fetched content
*/
prompt: string;
}
@@ -70,17 +46,20 @@ export class WebFetchTool extends BaseTool<WebFetchToolParams, ToolResult> {
super(
WebFetchTool.Name,
'WebFetch',
"Processes content from URL(s), including local and private network addresses (e.g., localhost), embedded in a prompt. Include up to 20 URLs and instructions (e.g., summarize, extract specific data) directly in the 'prompt' parameter.",
'Fetches content from a specified URL and processes it using an AI model\n- Takes a URL and a prompt as input\n- Fetches the URL content, converts HTML to markdown\n- Processes the content with the prompt using a small, fast model\n- Returns the model\'s response about the content\n- Use this tool when you need to retrieve and analyze web content\n\nUsage notes:\n - IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions. All MCP-provided tools start with "mcp__".\n - The URL must be a fully-formed valid URL\n - The prompt should describe what information you want to extract from the page\n - This tool is read-only and does not modify any files\n - Results may be summarized if the content is very large',
Icon.Globe,
{
properties: {
url: {
description: 'The URL to fetch content from',
type: Type.STRING,
},
prompt: {
description:
'A comprehensive prompt that includes the URL(s) (up to 20) to fetch and specific instructions on how to process their content (e.g., "Summarize https://example.com/article and extract key points from https://another.com/data"). Must contain as least one URL starting with http:// or https://.',
description: 'The prompt to run on the fetched content',
type: Type.STRING,
},
},
required: ['prompt'],
required: ['url', 'prompt'],
type: Type.OBJECT,
},
);
@@ -90,19 +69,11 @@ export class WebFetchTool extends BaseTool<WebFetchToolParams, ToolResult> {
}
}
private async executeFallback(
private async executeFetch(
params: WebFetchToolParams,
signal: AbortSignal,
): Promise<ToolResult> {
const urls = extractUrls(params.prompt);
if (urls.length === 0) {
return {
llmContent: 'Error: No URL found in the prompt for fallback.',
returnDisplay: 'Error: No URL found in the prompt for fallback.',
};
}
// For now, we only support one URL for fallback
let url = urls[0];
let url = params.url;
// Convert GitHub blob URL to raw URL
if (url.includes('github.com') && url.includes('/blob/')) {
@@ -130,7 +101,7 @@ export class WebFetchTool extends BaseTool<WebFetchToolParams, ToolResult> {
const geminiClient = this.config.getGeminiClient();
const fallbackPrompt = `The user requested the following: "${params.prompt}".
I was unable to access the URL directly. Instead, I have fetched the raw content of the page. Please use the following content to answer the user's request. Do not attempt to access the URL again.
I have fetched the content from ${params.url}. Please use the following content to answer the user's request.
---
${textContent}
@@ -143,11 +114,11 @@ ${textContent}
const resultText = getResponseText(result) || '';
return {
llmContent: resultText,
returnDisplay: `Content for ${url} processed using fallback fetch.`,
returnDisplay: `Content from ${params.url} processed successfully.`,
};
} catch (e) {
const error = e as Error;
const errorMessage = `Error during fallback fetch for ${url}: ${error.message}`;
const errorMessage = `Error during fetch for ${url}: ${error.message}`;
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
@@ -160,14 +131,17 @@ ${textContent}
if (errors) {
return errors;
}
if (!params.prompt || params.prompt.trim() === '') {
return "The 'prompt' parameter cannot be empty and must contain URL(s) and instructions.";
if (!params.url || params.url.trim() === '') {
return "The 'url' parameter cannot be empty.";
}
if (
!params.prompt.includes('http://') &&
!params.prompt.includes('https://')
!params.url.startsWith('http://') &&
!params.url.startsWith('https://')
) {
return "The 'prompt' must contain at least one valid URL (starting with http:// or https://).";
return "The 'url' must be a valid URL starting with http:// or https://.";
}
if (!params.prompt || params.prompt.trim() === '') {
return "The 'prompt' parameter cannot be empty.";
}
return null;
}
@@ -177,7 +151,7 @@ ${textContent}
params.prompt.length > 100
? params.prompt.substring(0, 97) + '...'
: params.prompt;
return `Processing URLs and instructions from prompt: "${displayPrompt}"`;
return `Fetching content from ${params.url} and processing with prompt: "${displayPrompt}"`;
}
async shouldConfirmExecute(
@@ -192,22 +166,11 @@ ${textContent}
return false;
}
// Perform GitHub URL conversion here to differentiate between user-provided
// URL and the actual URL to be fetched.
const urls = extractUrls(params.prompt).map((url) => {
if (url.includes('github.com') && url.includes('/blob/')) {
return url
.replace('github.com', 'raw.githubusercontent.com')
.replace('/blob/', '/');
}
return url;
});
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'info',
title: `Confirm Web Fetch`,
prompt: params.prompt,
urls,
prompt: `Fetch content from ${params.url} and process with: ${params.prompt}`,
urls: [params.url],
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
@@ -229,132 +192,6 @@ ${textContent}
};
}
const userPrompt = params.prompt;
const urls = extractUrls(userPrompt);
const url = urls[0];
const isPrivate = isPrivateIp(url);
if (isPrivate) {
return this.executeFallback(params, signal);
}
const geminiClient = this.config.getGeminiClient();
try {
const response = await geminiClient.generateContent(
[{ role: 'user', parts: [{ text: userPrompt }] }],
{ tools: [{ urlContext: {} }] },
signal, // Pass signal
);
console.debug(
`[WebFetchTool] Full response for prompt "${userPrompt.substring(
0,
50,
)}...":`,
JSON.stringify(response, null, 2),
);
let responseText = getResponseText(response) || '';
const urlContextMeta = response.candidates?.[0]?.urlContextMetadata;
const groundingMetadata = response.candidates?.[0]?.groundingMetadata;
const sources = groundingMetadata?.groundingChunks as
| GroundingChunkItem[]
| undefined;
const groundingSupports = groundingMetadata?.groundingSupports as
| GroundingSupportItem[]
| undefined;
// Error Handling
let processingError = false;
if (
urlContextMeta?.urlMetadata &&
urlContextMeta.urlMetadata.length > 0
) {
const allStatuses = urlContextMeta.urlMetadata.map(
(m) => m.urlRetrievalStatus,
);
if (allStatuses.every((s) => s !== 'URL_RETRIEVAL_STATUS_SUCCESS')) {
processingError = true;
}
} else if (!responseText.trim() && !sources?.length) {
// No URL metadata and no content/sources
processingError = true;
}
if (
!processingError &&
!responseText.trim() &&
(!sources || sources.length === 0)
) {
// Successfully retrieved some URL (or no specific error from urlContextMeta), but no usable text or grounding data.
processingError = true;
}
if (processingError) {
return this.executeFallback(params, signal);
}
const sourceListFormatted: string[] = [];
if (sources && sources.length > 0) {
sources.forEach((source: GroundingChunkItem, index: number) => {
const title = source.web?.title || 'Untitled';
const uri = source.web?.uri || 'Unknown URI'; // Fallback if URI is missing
sourceListFormatted.push(`[${index + 1}] ${title} (${uri})`);
});
if (groundingSupports && groundingSupports.length > 0) {
const insertions: Array<{ index: number; marker: string }> = [];
groundingSupports.forEach((support: GroundingSupportItem) => {
if (support.segment && support.groundingChunkIndices) {
const citationMarker = support.groundingChunkIndices
.map((chunkIndex: number) => `[${chunkIndex + 1}]`)
.join('');
insertions.push({
index: support.segment.endIndex,
marker: citationMarker,
});
}
});
insertions.sort((a, b) => b.index - a.index);
const responseChars = responseText.split('');
insertions.forEach((insertion) => {
responseChars.splice(insertion.index, 0, insertion.marker);
});
responseText = responseChars.join('');
}
if (sourceListFormatted.length > 0) {
responseText += `
Sources:
${sourceListFormatted.join('\n')}`;
}
}
const llmContent = responseText;
console.debug(
`[WebFetchTool] Formatted tool response for prompt "${userPrompt}:\n\n":`,
llmContent,
);
return {
llmContent,
returnDisplay: `Content processed from prompt.`,
};
} catch (error: unknown) {
const errorMessage = `Error processing web content for prompt "${userPrompt.substring(
0,
50,
)}...": ${getErrorMessage(error)}`;
console.error(errorMessage, error);
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
};
}
return this.executeFetch(params, signal);
}
}

View File

@@ -4,35 +4,24 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { GroundingMetadata } from '@google/genai';
import { BaseTool, Icon, ToolResult } from './tools.js';
import { Type } from '@google/genai';
import { SchemaValidator } from '../utils/schemaValidator.js';
import { getErrorMessage } from '../utils/errors.js';
import { Config } from '../config/config.js';
import { getResponseText } from '../utils/generateContentResponseUtilities.js';
interface GroundingChunkWeb {
uri?: string;
title?: string;
interface TavilyResultItem {
title: string;
url: string;
content?: string;
score?: number;
published_date?: string;
}
interface GroundingChunkItem {
web?: GroundingChunkWeb;
// Other properties might exist if needed in the future
}
interface GroundingSupportSegment {
startIndex: number;
endIndex: number;
text?: string; // text is optional as per the example
}
interface GroundingSupportItem {
segment?: GroundingSupportSegment;
groundingChunkIndices?: number[];
confidenceScores?: number[]; // Optional as per example
interface TavilySearchResponse {
query: string;
answer?: string;
results: TavilyResultItem[];
}
/**
@@ -42,7 +31,6 @@ export interface WebSearchToolParams {
/**
* The search query.
*/
query: string;
}
@@ -50,25 +38,23 @@ export interface WebSearchToolParams {
* Extends ToolResult to include sources for web search.
*/
export interface WebSearchToolResult extends ToolResult {
sources?: GroundingMetadata extends { groundingChunks: GroundingChunkItem[] }
? GroundingMetadata['groundingChunks']
: GroundingChunkItem[];
sources?: Array<{ title: string; url: string }>;
}
/**
* A tool to perform web searches using Google Search via the Gemini API.
* A tool to perform web searches using Tavily API.
*/
export class WebSearchTool extends BaseTool<
WebSearchToolParams,
WebSearchToolResult
> {
static readonly Name: string = 'google_web_search';
static readonly Name: string = 'web_search';
constructor(private readonly config: Config) {
super(
WebSearchTool.Name,
'GoogleSearch',
'Performs a web search using Google Search (via the Gemini API) and returns the results. This tool is useful for finding information on the internet based on a query.',
'TavilySearch',
'Performs a web search using the Tavily API and returns a concise answer with sources. Requires the TAVILY_API_KEY environment variable.',
Icon.Globe,
{
type: Type.OBJECT,
@@ -106,7 +92,7 @@ export class WebSearchTool extends BaseTool<
async execute(
params: WebSearchToolParams,
signal: AbortSignal,
_signal: AbortSignal,
): Promise<WebSearchToolResult> {
const validationError = this.validateToolParams(params);
if (validationError) {
@@ -115,79 +101,83 @@ export class WebSearchTool extends BaseTool<
returnDisplay: validationError,
};
}
const geminiClient = this.config.getGeminiClient();
const apiKey = this.config.getTavilyApiKey() || process.env.TAVILY_API_KEY;
if (!apiKey) {
return {
llmContent:
'Web search is disabled because TAVILY_API_KEY is not configured. Please set it in your settings.json, .env file, or via --tavily-api-key command line argument to enable web search.',
returnDisplay:
'Web search disabled. Configure TAVILY_API_KEY to enable Tavily search.',
};
}
try {
const response = await geminiClient.generateContent(
[{ role: 'user', parts: [{ text: params.query }] }],
{ tools: [{ googleSearch: {} }] },
signal,
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
const response = await fetch('https://api.tavily.com/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
api_key: apiKey,
query: params.query,
search_depth: 'advanced',
max_results: 5,
include_answer: true,
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(
`Tavily API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`,
);
}
const data = (await response.json()) as TavilySearchResponse;
const sources = (data.results || []).map((r) => ({
title: r.title,
url: r.url,
}));
const sourceListFormatted = sources.map(
(s, i) => `[${i + 1}] ${s.title || 'Untitled'} (${s.url})`,
);
const responseText = getResponseText(response);
const groundingMetadata = response.candidates?.[0]?.groundingMetadata;
const sources = groundingMetadata?.groundingChunks as
| GroundingChunkItem[]
| undefined;
const groundingSupports = groundingMetadata?.groundingSupports as
| GroundingSupportItem[]
| undefined;
let content = data.answer?.trim() || '';
if (!content) {
// Fallback: build a concise summary from top results
content = sources
.slice(0, 3)
.map((s, i) => `${i + 1}. ${s.title} - ${s.url}`)
.join('\n');
}
if (!responseText || !responseText.trim()) {
if (sourceListFormatted.length > 0) {
content += `\n\nSources:\n${sourceListFormatted.join('\n')}`;
}
if (!content.trim()) {
return {
llmContent: `No search results or information found for query: "${params.query}"`,
returnDisplay: 'No information found.',
};
}
let modifiedResponseText = responseText;
const sourceListFormatted: string[] = [];
if (sources && sources.length > 0) {
sources.forEach((source: GroundingChunkItem, index: number) => {
const title = source.web?.title || 'Untitled';
const uri = source.web?.uri || 'No URI';
sourceListFormatted.push(`[${index + 1}] ${title} (${uri})`);
});
if (groundingSupports && groundingSupports.length > 0) {
const insertions: Array<{ index: number; marker: string }> = [];
groundingSupports.forEach((support: GroundingSupportItem) => {
if (support.segment && support.groundingChunkIndices) {
const citationMarker = support.groundingChunkIndices
.map((chunkIndex: number) => `[${chunkIndex + 1}]`)
.join('');
insertions.push({
index: support.segment.endIndex,
marker: citationMarker,
});
}
});
// Sort insertions by index in descending order to avoid shifting subsequent indices
insertions.sort((a, b) => b.index - a.index);
const responseChars = modifiedResponseText.split(''); // Use new variable
insertions.forEach((insertion) => {
// Fixed arrow function syntax
responseChars.splice(insertion.index, 0, insertion.marker);
});
modifiedResponseText = responseChars.join(''); // Assign back to modifiedResponseText
}
if (sourceListFormatted.length > 0) {
modifiedResponseText +=
'\n\nSources:\n' + sourceListFormatted.join('\n'); // Fixed string concatenation
}
}
return {
llmContent: `Web search results for "${params.query}":\n\n${modifiedResponseText}`,
llmContent: `Web search results for "${params.query}":\n\n${content}`,
returnDisplay: `Search results for "${params.query}" returned.`,
sources,
};
} catch (error: unknown) {
const errorMessage = `Error during web search for query "${params.query}": ${getErrorMessage(error)}`;
const errorMessage = `Error during web search for query "${params.query}": ${getErrorMessage(
error,
)}`;
console.error(errorMessage, error);
return {
llmContent: `Error: ${errorMessage}`,

View File

@@ -331,7 +331,7 @@ describe('editor utils', () => {
}),
};
(spawn as Mock).mockReturnValue(mockSpawn);
await openDiff('old.txt', 'new.txt', editor);
await openDiff('old.txt', 'new.txt', editor, () => {});
const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!;
expect(spawn).toHaveBeenCalledWith(
diffCommand.command,
@@ -361,9 +361,9 @@ describe('editor utils', () => {
}),
};
(spawn as Mock).mockReturnValue(mockSpawn);
await expect(openDiff('old.txt', 'new.txt', editor)).rejects.toThrow(
'spawn error',
);
await expect(
openDiff('old.txt', 'new.txt', editor, () => {}),
).rejects.toThrow('spawn error');
});
it(`should reject if ${editor} exits with non-zero code`, async () => {
@@ -375,9 +375,9 @@ describe('editor utils', () => {
}),
};
(spawn as Mock).mockReturnValue(mockSpawn);
await expect(openDiff('old.txt', 'new.txt', editor)).rejects.toThrow(
`${editor} exited with code 1`,
);
await expect(
openDiff('old.txt', 'new.txt', editor, () => {}),
).rejects.toThrow(`${editor} exited with code 1`);
});
}
@@ -385,7 +385,7 @@ describe('editor utils', () => {
for (const editor of execSyncEditors) {
it(`should call execSync for ${editor} on non-windows`, async () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
await openDiff('old.txt', 'new.txt', editor);
await openDiff('old.txt', 'new.txt', editor, () => {});
expect(execSync).toHaveBeenCalledTimes(1);
const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!;
const expectedCommand = `${
@@ -399,7 +399,7 @@ describe('editor utils', () => {
it(`should call execSync for ${editor} on windows`, async () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
await openDiff('old.txt', 'new.txt', editor);
await openDiff('old.txt', 'new.txt', editor, () => {});
expect(execSync).toHaveBeenCalledTimes(1);
const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!;
const expectedCommand = `${diffCommand.command} ${diffCommand.args.join(
@@ -417,11 +417,46 @@ describe('editor utils', () => {
.spyOn(console, 'error')
.mockImplementation(() => {});
// @ts-expect-error Testing unsupported editor
await openDiff('old.txt', 'new.txt', 'foobar');
await openDiff('old.txt', 'new.txt', 'foobar', () => {});
expect(consoleErrorSpy).toHaveBeenCalledWith(
'No diff tool available. Install a supported editor.',
);
});
describe('onEditorClose callback', () => {
it('should call onEditorClose for execSync editors', async () => {
(execSync as Mock).mockReturnValue(Buffer.from(`/usr/bin/`));
const onEditorClose = vi.fn();
await openDiff('old.txt', 'new.txt', 'vim', onEditorClose);
expect(execSync).toHaveBeenCalledTimes(1);
expect(onEditorClose).toHaveBeenCalledTimes(1);
});
it('should call onEditorClose for execSync editors when an error is thrown', async () => {
(execSync as Mock).mockImplementation(() => {
throw new Error('test error');
});
const onEditorClose = vi.fn();
openDiff('old.txt', 'new.txt', 'vim', onEditorClose);
expect(execSync).toHaveBeenCalledTimes(1);
expect(onEditorClose).toHaveBeenCalledTimes(1);
});
it('should not call onEditorClose for spawn editors', async () => {
const onEditorClose = vi.fn();
const mockSpawn = {
on: vi.fn((event, cb) => {
if (event === 'close') {
cb(0);
}
}),
};
(spawn as Mock).mockReturnValue(mockSpawn);
await openDiff('old.txt', 'new.txt', 'vscode', onEditorClose);
expect(spawn).toHaveBeenCalledTimes(1);
expect(onEditorClose).not.toHaveBeenCalled();
});
});
});
describe('allowEditorTypeInSandbox', () => {

View File

@@ -164,6 +164,7 @@ export async function openDiff(
oldPath: string,
newPath: string,
editor: EditorType,
onEditorClose: () => void,
): Promise<void> {
const diffCommand = getDiffCommand(oldPath, newPath, editor);
if (!diffCommand) {
@@ -206,10 +207,16 @@ export async function openDiff(
process.platform === 'win32'
? `${diffCommand.command} ${diffCommand.args.join(' ')}`
: `${diffCommand.command} ${diffCommand.args.map((arg) => `"${arg}"`).join(' ')}`;
execSync(command, {
stdio: 'inherit',
encoding: 'utf8',
});
try {
execSync(command, {
stdio: 'inherit',
encoding: 'utf8',
});
} catch (e) {
console.error('Error in onEditorClose callback:', e);
} finally {
onEditorClose();
}
break;
}

View File

@@ -426,6 +426,29 @@ describe('fileUtils', () => {
expect(result.linesShown).toEqual([6, 10]);
});
it('should identify truncation when reading the end of a file', async () => {
const lines = Array.from({ length: 20 }, (_, i) => `Line ${i + 1}`);
actualNodeFs.writeFileSync(testTextFilePath, lines.join('\n'));
// Read from line 11 to 20. The start is not 0, so it's truncated.
const result = await processSingleFileContent(
testTextFilePath,
tempRootDir,
10,
10,
);
const expectedContent = lines.slice(10, 20).join('\n');
expect(result.llmContent).toContain(expectedContent);
expect(result.llmContent).toContain(
'[File content truncated: showing lines 11-20 of 20 total lines. Use offset/limit parameters to view more.]',
);
expect(result.returnDisplay).toBe('Read lines 11-20 of 20 from test.txt');
expect(result.isTruncated).toBe(true); // This is the key check for the bug
expect(result.originalLineCount).toBe(20);
expect(result.linesShown).toEqual([11, 20]);
});
it('should handle limit exceeding file length', async () => {
const lines = ['Line 1', 'Line 2'];
actualNodeFs.writeFileSync(testTextFilePath, lines.join('\n'));

View File

@@ -299,7 +299,8 @@ export async function processSingleFileContent(
return line;
});
const contentRangeTruncated = endLine < originalLineCount;
const contentRangeTruncated =
startLine > 0 || endLine < originalLineCount;
const isTruncated = contentRangeTruncated || linesWereTruncatedInLength;
let llmTextContent = '';

View File

@@ -0,0 +1,112 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { getCacheKey, read, write, clear } from './crawlCache.js';
describe('CrawlCache', () => {
describe('getCacheKey', () => {
it('should generate a consistent hash', () => {
const key1 = getCacheKey('/foo', 'bar');
const key2 = getCacheKey('/foo', 'bar');
expect(key1).toBe(key2);
});
it('should generate a different hash for different directories', () => {
const key1 = getCacheKey('/foo', 'bar');
const key2 = getCacheKey('/bar', 'bar');
expect(key1).not.toBe(key2);
});
it('should generate a different hash for different ignore content', () => {
const key1 = getCacheKey('/foo', 'bar');
const key2 = getCacheKey('/foo', 'baz');
expect(key1).not.toBe(key2);
});
});
describe('in-memory cache operations', () => {
beforeEach(() => {
// Ensure a clean slate before each test
clear();
});
afterEach(() => {
// Restore real timers after each test that uses fake ones
vi.useRealTimers();
});
it('should write and read data from the cache', () => {
const key = 'test-key';
const data = ['foo', 'bar'];
write(key, data, 10000); // 10 second TTL
const cachedData = read(key);
expect(cachedData).toEqual(data);
});
it('should return undefined for a nonexistent key', () => {
const cachedData = read('nonexistent-key');
expect(cachedData).toBeUndefined();
});
it('should clear the cache', () => {
const key = 'test-key';
const data = ['foo', 'bar'];
write(key, data, 10000);
clear();
const cachedData = read(key);
expect(cachedData).toBeUndefined();
});
it('should automatically evict a cache entry after its TTL expires', async () => {
vi.useFakeTimers();
const key = 'ttl-key';
const data = ['foo'];
const ttl = 5000; // 5 seconds
write(key, data, ttl);
// Should exist immediately after writing
expect(read(key)).toEqual(data);
// Advance time just before expiration
await vi.advanceTimersByTimeAsync(ttl - 1);
expect(read(key)).toEqual(data);
// Advance time past expiration
await vi.advanceTimersByTimeAsync(1);
expect(read(key)).toBeUndefined();
});
it('should reset the timer when an entry is updated', async () => {
vi.useFakeTimers();
const key = 'update-key';
const initialData = ['initial'];
const updatedData = ['updated'];
const ttl = 5000; // 5 seconds
// Write initial data
write(key, initialData, ttl);
// Advance time, but not enough to expire
await vi.advanceTimersByTimeAsync(3000);
expect(read(key)).toEqual(initialData);
// Update the data, which should reset the timer
write(key, updatedData, ttl);
expect(read(key)).toEqual(updatedData);
// Advance time again. If the timer wasn't reset, the total elapsed
// time (3000 + 3000 = 6000) would cause an eviction.
await vi.advanceTimersByTimeAsync(3000);
expect(read(key)).toEqual(updatedData);
// Advance past the new expiration time
await vi.advanceTimersByTimeAsync(2001);
expect(read(key)).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,65 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import crypto from 'node:crypto';
const crawlCache = new Map<string, string[]>();
const cacheTimers = new Map<string, NodeJS.Timeout>();
/**
* Generates a unique cache key based on the project directory and the content
* of ignore files. This ensures that the cache is invalidated if the project
* or ignore rules change.
*/
export const getCacheKey = (
directory: string,
ignoreContent: string,
): string => {
const hash = crypto.createHash('sha256');
hash.update(directory);
hash.update(ignoreContent);
return hash.digest('hex');
};
/**
* Reads cached data from the in-memory cache.
* Returns undefined if the key is not found.
*/
export const read = (key: string): string[] | undefined => crawlCache.get(key);
/**
* Writes data to the in-memory cache and sets a timer to evict it after the TTL.
*/
export const write = (key: string, results: string[], ttlMs: number): void => {
// Clear any existing timer for this key to prevent premature deletion
if (cacheTimers.has(key)) {
clearTimeout(cacheTimers.get(key)!);
}
// Store the new data
crawlCache.set(key, results);
// Set a timer to automatically delete the cache entry after the TTL
const timerId = setTimeout(() => {
crawlCache.delete(key);
cacheTimers.delete(key);
}, ttlMs);
// Store the timer handle so we can clear it if the entry is updated
cacheTimers.set(key, timerId);
};
/**
* Clears the entire cache and all active timers.
* Primarily used for testing.
*/
export const clear = (): void => {
for (const timerId of cacheTimers.values()) {
clearTimeout(timerId);
}
crawlCache.clear();
cacheTimers.clear();
};

View File

@@ -0,0 +1,642 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as cache from './crawlCache.js';
import { FileSearch, AbortError, filter } from './fileSearch.js';
import { createTmpDir, cleanupTmpDir } from '@qwen-code/qwen-code-test-utils';
type FileSearchWithPrivateMethods = FileSearch & {
performCrawl: () => Promise<void>;
};
describe('FileSearch', () => {
let tmpDir: string;
afterEach(async () => {
if (tmpDir) {
await cleanupTmpDir(tmpDir);
}
vi.restoreAllMocks();
});
it('should use .geminiignore rules', async () => {
tmpDir = await createTmpDir({
'.geminiignore': 'dist/',
dist: ['ignored.js'],
src: ['not-ignored.js'],
});
const fileSearch = new FileSearch({
projectRoot: tmpDir,
useGitignore: false,
useGeminiignore: true,
ignoreDirs: [],
cache: false,
cacheTtl: 0,
});
await fileSearch.initialize();
const results = await fileSearch.search('');
expect(results).toEqual(['src/', '.geminiignore', 'src/not-ignored.js']);
});
it('should combine .gitignore and .geminiignore rules', async () => {
tmpDir = await createTmpDir({
'.gitignore': 'dist/',
'.geminiignore': 'build/',
dist: ['ignored-by-git.js'],
build: ['ignored-by-gemini.js'],
src: ['not-ignored.js'],
});
const fileSearch = new FileSearch({
projectRoot: tmpDir,
useGitignore: true,
useGeminiignore: true,
ignoreDirs: [],
cache: false,
cacheTtl: 0,
});
await fileSearch.initialize();
const results = await fileSearch.search('');
expect(results).toEqual([
'src/',
'.geminiignore',
'.gitignore',
'src/not-ignored.js',
]);
});
it('should use ignoreDirs option', async () => {
tmpDir = await createTmpDir({
logs: ['some.log'],
src: ['main.js'],
});
const fileSearch = new FileSearch({
projectRoot: tmpDir,
useGitignore: false,
useGeminiignore: false,
ignoreDirs: ['logs'],
cache: false,
cacheTtl: 0,
});
await fileSearch.initialize();
const results = await fileSearch.search('');
expect(results).toEqual(['src/', 'src/main.js']);
});
it('should handle negated directories', async () => {
tmpDir = await createTmpDir({
'.gitignore': ['build/**', '!build/public', '!build/public/**'].join(
'\n',
),
build: {
'private.js': '',
public: ['index.html'],
},
src: ['main.js'],
});
const fileSearch = new FileSearch({
projectRoot: tmpDir,
useGitignore: true,
useGeminiignore: false,
ignoreDirs: [],
cache: false,
cacheTtl: 0,
});
await fileSearch.initialize();
const results = await fileSearch.search('');
expect(results).toEqual([
'build/',
'build/public/',
'src/',
'.gitignore',
'build/public/index.html',
'src/main.js',
]);
});
it('should filter results with a search pattern', async () => {
tmpDir = await createTmpDir({
src: {
'main.js': '',
'util.ts': '',
'style.css': '',
},
});
const fileSearch = new FileSearch({
projectRoot: tmpDir,
useGitignore: false,
useGeminiignore: false,
ignoreDirs: [],
cache: false,
cacheTtl: 0,
});
await fileSearch.initialize();
const results = await fileSearch.search('**/*.js');
expect(results).toEqual(['src/main.js']);
});
it('should handle root-level file negation', async () => {
tmpDir = await createTmpDir({
'.gitignore': ['*.mk', '!Foo.mk'].join('\n'),
'bar.mk': '',
'Foo.mk': '',
});
const fileSearch = new FileSearch({
projectRoot: tmpDir,
useGitignore: true,
useGeminiignore: false,
ignoreDirs: [],
cache: false,
cacheTtl: 0,
});
await fileSearch.initialize();
const results = await fileSearch.search('');
expect(results).toEqual(['.gitignore', 'Foo.mk']);
});
it('should handle directory negation with glob', async () => {
tmpDir = await createTmpDir({
'.gitignore': [
'third_party/**',
'!third_party/foo',
'!third_party/foo/bar',
'!third_party/foo/bar/baz_buffer',
].join('\n'),
third_party: {
foo: {
bar: {
baz_buffer: '',
},
},
ignore_this: '',
},
});
const fileSearch = new FileSearch({
projectRoot: tmpDir,
useGitignore: true,
useGeminiignore: false,
ignoreDirs: [],
cache: false,
cacheTtl: 0,
});
await fileSearch.initialize();
const results = await fileSearch.search('');
expect(results).toEqual([
'third_party/',
'third_party/foo/',
'third_party/foo/bar/',
'.gitignore',
'third_party/foo/bar/baz_buffer',
]);
});
it('should correctly handle negated patterns in .gitignore', async () => {
tmpDir = await createTmpDir({
'.gitignore': ['dist/**', '!dist/keep.js'].join('\n'),
dist: ['ignore.js', 'keep.js'],
src: ['main.js'],
});
const fileSearch = new FileSearch({
projectRoot: tmpDir,
useGitignore: true,
useGeminiignore: false,
ignoreDirs: [],
cache: false,
cacheTtl: 0,
});
await fileSearch.initialize();
const results = await fileSearch.search('');
expect(results).toEqual([
'dist/',
'src/',
'.gitignore',
'dist/keep.js',
'src/main.js',
]);
});
// New test cases start here
it('should initialize correctly when ignore files are missing', async () => {
tmpDir = await createTmpDir({
src: ['file1.js'],
});
const fileSearch = new FileSearch({
projectRoot: tmpDir,
useGitignore: true,
useGeminiignore: true,
ignoreDirs: [],
cache: false,
cacheTtl: 0,
});
// Expect no errors to be thrown during initialization
await expect(fileSearch.initialize()).resolves.toBeUndefined();
const results = await fileSearch.search('');
expect(results).toEqual(['src/', 'src/file1.js']);
});
it('should respect maxResults option in search', async () => {
tmpDir = await createTmpDir({
src: {
'file1.js': '',
'file2.js': '',
'file3.js': '',
'file4.js': '',
},
});
const fileSearch = new FileSearch({
projectRoot: tmpDir,
useGitignore: false,
useGeminiignore: false,
ignoreDirs: [],
cache: false,
cacheTtl: 0,
});
await fileSearch.initialize();
const results = await fileSearch.search('**/*.js', { maxResults: 2 });
expect(results).toEqual(['src/file1.js', 'src/file2.js']); // Assuming alphabetical sort
});
it('should return empty array when no matches are found', async () => {
tmpDir = await createTmpDir({
src: ['file1.js'],
});
const fileSearch = new FileSearch({
projectRoot: tmpDir,
useGitignore: false,
useGeminiignore: false,
ignoreDirs: [],
cache: false,
cacheTtl: 0,
});
await fileSearch.initialize();
const results = await fileSearch.search('nonexistent-file.xyz');
expect(results).toEqual([]);
});
it('should throw AbortError when filter is aborted', async () => {
const controller = new AbortController();
const dummyPaths = Array.from({ length: 5000 }, (_, i) => `file${i}.js`); // Large array to ensure yielding
const filterPromise = filter(dummyPaths, '*.js', controller.signal);
// Abort after a short delay to ensure filter has started
setTimeout(() => controller.abort(), 1);
await expect(filterPromise).rejects.toThrow(AbortError);
});
describe('with in-memory cache', () => {
beforeEach(() => {
cache.clear();
});
afterEach(() => {
vi.useRealTimers();
});
it('should throw an error if search is called before initialization', async () => {
tmpDir = await createTmpDir({});
const fileSearch = new FileSearch({
projectRoot: tmpDir,
useGitignore: false,
useGeminiignore: false,
ignoreDirs: [],
cache: false,
cacheTtl: 0,
});
await expect(fileSearch.search('')).rejects.toThrow(
'Engine not initialized. Call initialize() first.',
);
});
it('should hit the cache for subsequent searches', async () => {
tmpDir = await createTmpDir({ 'file1.js': '' });
const getOptions = () => ({
projectRoot: tmpDir,
useGitignore: false,
useGeminiignore: false,
ignoreDirs: [],
cache: true,
cacheTtl: 10,
});
const fs1 = new FileSearch(getOptions());
const crawlSpy1 = vi.spyOn(
fs1 as FileSearchWithPrivateMethods,
'performCrawl',
);
await fs1.initialize();
expect(crawlSpy1).toHaveBeenCalledTimes(1);
// Second search should hit the cache because the options are identical
const fs2 = new FileSearch(getOptions());
const crawlSpy2 = vi.spyOn(
fs2 as FileSearchWithPrivateMethods,
'performCrawl',
);
await fs2.initialize();
expect(crawlSpy2).not.toHaveBeenCalled();
});
it('should miss the cache when ignore rules change', async () => {
tmpDir = await createTmpDir({
'.gitignore': 'a.txt',
'a.txt': '',
'b.txt': '',
});
const options = {
projectRoot: tmpDir,
useGitignore: true,
useGeminiignore: false,
ignoreDirs: [],
cache: true,
cacheTtl: 10000,
};
// Initial search to populate the cache
const fs1 = new FileSearch(options);
const crawlSpy1 = vi.spyOn(
fs1 as FileSearchWithPrivateMethods,
'performCrawl',
);
await fs1.initialize();
const results1 = await fs1.search('');
expect(crawlSpy1).toHaveBeenCalledTimes(1);
expect(results1).toEqual(['.gitignore', 'b.txt']);
// Modify the ignore file
await fs.writeFile(path.join(tmpDir, '.gitignore'), 'b.txt');
// Second search should miss the cache and trigger a recrawl
const fs2 = new FileSearch(options);
const crawlSpy2 = vi.spyOn(
fs2 as FileSearchWithPrivateMethods,
'performCrawl',
);
await fs2.initialize();
const results2 = await fs2.search('');
expect(crawlSpy2).toHaveBeenCalledTimes(1);
expect(results2).toEqual(['.gitignore', 'a.txt']);
});
it('should miss the cache after TTL expires', async () => {
vi.useFakeTimers();
tmpDir = await createTmpDir({ 'file1.js': '' });
const options = {
projectRoot: tmpDir,
useGitignore: false,
useGeminiignore: false,
ignoreDirs: [],
cache: true,
cacheTtl: 10, // 10 seconds
};
// Initial search to populate the cache
const fs1 = new FileSearch(options);
await fs1.initialize();
// Advance time past the TTL
await vi.advanceTimersByTimeAsync(11000);
// Second search should miss the cache and trigger a recrawl
const fs2 = new FileSearch(options);
const crawlSpy = vi.spyOn(
fs2 as FileSearchWithPrivateMethods,
'performCrawl',
);
await fs2.initialize();
expect(crawlSpy).toHaveBeenCalledTimes(1);
});
});
it('should handle empty or commented-only ignore files', async () => {
tmpDir = await createTmpDir({
'.gitignore': '# This is a comment\n\n \n',
src: ['main.js'],
});
const fileSearch = new FileSearch({
projectRoot: tmpDir,
useGitignore: true,
useGeminiignore: false,
ignoreDirs: [],
cache: false,
cacheTtl: 0,
});
await fileSearch.initialize();
const results = await fileSearch.search('');
expect(results).toEqual(['src/', '.gitignore', 'src/main.js']);
});
it('should always ignore the .git directory', async () => {
tmpDir = await createTmpDir({
'.git': ['config', 'HEAD'],
src: ['main.js'],
});
const fileSearch = new FileSearch({
projectRoot: tmpDir,
useGitignore: false, // Explicitly disable .gitignore to isolate this rule
useGeminiignore: false,
ignoreDirs: [],
cache: false,
cacheTtl: 0,
});
await fileSearch.initialize();
const results = await fileSearch.search('');
expect(results).toEqual(['src/', 'src/main.js']);
});
it('should be cancellable via AbortSignal', async () => {
const largeDir: Record<string, string> = {};
for (let i = 0; i < 100; i++) {
largeDir[`file${i}.js`] = '';
}
tmpDir = await createTmpDir(largeDir);
const fileSearch = new FileSearch({
projectRoot: tmpDir,
useGitignore: false,
useGeminiignore: false,
ignoreDirs: [],
cache: false,
cacheTtl: 0,
});
await fileSearch.initialize();
const controller = new AbortController();
const searchPromise = fileSearch.search('**/*.js', {
signal: controller.signal,
});
// Yield to allow the search to start before aborting.
await new Promise((resolve) => setImmediate(resolve));
controller.abort();
await expect(searchPromise).rejects.toThrow(AbortError);
});
it('should leverage ResultCache for bestBaseQuery optimization', async () => {
tmpDir = await createTmpDir({
src: {
'foo.js': '',
'bar.ts': '',
nested: {
'baz.js': '',
},
},
});
const fileSearch = new FileSearch({
projectRoot: tmpDir,
useGitignore: false,
useGeminiignore: false,
ignoreDirs: [],
cache: true, // Enable caching for this test
cacheTtl: 0,
});
await fileSearch.initialize();
// Perform a broad search to prime the cache
const broadResults = await fileSearch.search('src/**');
expect(broadResults).toEqual([
'src/',
'src/nested/',
'src/bar.ts',
'src/foo.js',
'src/nested/baz.js',
]);
// Perform a more specific search that should leverage the broad search's cached results
const specificResults = await fileSearch.search('src/**/*.js');
expect(specificResults).toEqual(['src/foo.js', 'src/nested/baz.js']);
// Although we can't directly inspect ResultCache.hits/misses from here,
// the correctness of specificResults after a broad search implicitly
// verifies that the caching mechanism, including bestBaseQuery, is working.
});
it('should be case-insensitive by default', async () => {
tmpDir = await createTmpDir({
'File1.Js': '',
'file2.js': '',
'FILE3.JS': '',
'other.txt': '',
});
const fileSearch = new FileSearch({
projectRoot: tmpDir,
useGitignore: false,
useGeminiignore: false,
ignoreDirs: [],
cache: false,
cacheTtl: 0,
});
await fileSearch.initialize();
// Search with a lowercase pattern
let results = await fileSearch.search('file*.js');
expect(results).toHaveLength(3);
expect(results).toEqual(
expect.arrayContaining(['File1.Js', 'file2.js', 'FILE3.JS']),
);
// Search with an uppercase pattern
results = await fileSearch.search('FILE*.JS');
expect(results).toHaveLength(3);
expect(results).toEqual(
expect.arrayContaining(['File1.Js', 'file2.js', 'FILE3.JS']),
);
// Search with a mixed-case pattern
results = await fileSearch.search('FiLe*.Js');
expect(results).toHaveLength(3);
expect(results).toEqual(
expect.arrayContaining(['File1.Js', 'file2.js', 'FILE3.JS']),
);
});
it('should respect maxResults even when the cache returns an exact match', async () => {
tmpDir = await createTmpDir({
'file1.js': '',
'file2.js': '',
'file3.js': '',
'file4.js': '',
'file5.js': '',
});
const fileSearch = new FileSearch({
projectRoot: tmpDir,
useGitignore: false,
useGeminiignore: false,
ignoreDirs: [],
cache: true, // Ensure caching is enabled
cacheTtl: 10000,
});
await fileSearch.initialize();
// 1. Perform a broad search to populate the cache with an exact match.
const initialResults = await fileSearch.search('*.js');
expect(initialResults).toEqual([
'file1.js',
'file2.js',
'file3.js',
'file4.js',
'file5.js',
]);
// 2. Perform the same search again, but this time with a maxResults limit.
const limitedResults = await fileSearch.search('*.js', { maxResults: 2 });
// 3. Assert that the maxResults limit was respected, even with a cache hit.
expect(limitedResults).toEqual(['file1.js', 'file2.js']);
});
});

View File

@@ -0,0 +1,269 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import fs from 'node:fs';
import { fdir } from 'fdir';
import picomatch from 'picomatch';
import { Ignore } from './ignore.js';
import { ResultCache } from './result-cache.js';
import * as cache from './crawlCache.js';
export type FileSearchOptions = {
projectRoot: string;
ignoreDirs: string[];
useGitignore: boolean;
useGeminiignore: boolean;
cache: boolean;
cacheTtl: number;
};
export class AbortError extends Error {
constructor(message = 'Search aborted') {
super(message);
this.name = 'AbortError';
}
}
/**
* Filters a list of paths based on a given pattern.
* @param allPaths The list of all paths to filter.
* @param pattern The picomatch pattern to filter by.
* @param signal An AbortSignal to cancel the operation.
* @returns A promise that resolves to the filtered and sorted list of paths.
*/
export async function filter(
allPaths: string[],
pattern: string,
signal: AbortSignal | undefined,
): Promise<string[]> {
const patternFilter = picomatch(pattern, {
dot: true,
contains: true,
nocase: true,
});
const results: string[] = [];
for (const [i, p] of allPaths.entries()) {
// Yield control to the event loop periodically to prevent blocking.
if (i % 1000 === 0) {
await new Promise((resolve) => setImmediate(resolve));
if (signal?.aborted) {
throw new AbortError();
}
}
if (patternFilter(p)) {
results.push(p);
}
}
results.sort((a, b) => {
const aIsDir = a.endsWith('/');
const bIsDir = b.endsWith('/');
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
// This is 40% faster than localeCompare and the only thing we would really
// gain from localeCompare is case-sensitive sort
return a < b ? -1 : a > b ? 1 : 0;
});
return results;
}
export type SearchOptions = {
signal?: AbortSignal;
maxResults?: number;
};
/**
* Provides a fast and efficient way to search for files within a project,
* respecting .gitignore and .geminiignore rules, and utilizing caching
* for improved performance.
*/
export class FileSearch {
private readonly absoluteDir: string;
private readonly ignore: Ignore = new Ignore();
private resultCache: ResultCache | undefined;
private allFiles: string[] = [];
/**
* Constructs a new `FileSearch` instance.
* @param options Configuration options for the file search.
*/
constructor(private readonly options: FileSearchOptions) {
this.absoluteDir = path.resolve(options.projectRoot);
}
/**
* Initializes the file search engine by loading ignore rules, crawling the
* file system, and building the in-memory cache. This method must be called
* before performing any searches.
*/
async initialize(): Promise<void> {
this.loadIgnoreRules();
await this.crawlFiles();
this.buildResultCache();
}
/**
* Searches for files matching a given pattern.
* @param pattern The picomatch pattern to search for (e.g., '*.js', 'src/**').
* @param options Search options, including an AbortSignal and maxResults.
* @returns A promise that resolves to a list of matching file paths, relative
* to the project root.
*/
async search(
pattern: string,
options: SearchOptions = {},
): Promise<string[]> {
if (!this.resultCache) {
throw new Error('Engine not initialized. Call initialize() first.');
}
pattern = pattern || '*';
const { files: candidates, isExactMatch } =
await this.resultCache!.get(pattern);
let filteredCandidates;
if (isExactMatch) {
filteredCandidates = candidates;
} else {
// Apply the user's picomatch pattern filter
filteredCandidates = await filter(candidates, pattern, options.signal);
this.resultCache!.set(pattern, filteredCandidates);
}
// Trade-off: We apply a two-stage filtering process.
// 1. During the file system crawl (`performCrawl`), we only apply directory-level
// ignore rules (e.g., `node_modules/`, `dist/`). This is because applying
// a full ignore filter (which includes file-specific patterns like `*.log`)
// during the crawl can significantly slow down `fdir`.
// 2. Here, in the `search` method, we apply the full ignore filter
// (including file patterns) to the `filteredCandidates` (which have already
// been filtered by the user's search pattern and sorted). For autocomplete,
// the number of displayed results is small (MAX_SUGGESTIONS_TO_SHOW),
// so applying the full filter to this truncated list is much more efficient
// than applying it to every file during the initial crawl.
const fileFilter = this.ignore.getFileFilter();
const results: string[] = [];
for (const [i, candidate] of filteredCandidates.entries()) {
// Yield to the event loop to avoid blocking on large result sets.
if (i % 1000 === 0) {
await new Promise((resolve) => setImmediate(resolve));
if (options.signal?.aborted) {
throw new AbortError();
}
}
if (results.length >= (options.maxResults ?? Infinity)) {
break;
}
// The `ignore` library throws an error if the path is '.', so we skip it.
if (candidate === '.') {
continue;
}
if (!fileFilter(candidate)) {
results.push(candidate);
}
}
return results;
}
/**
* Loads ignore rules from .gitignore and .geminiignore files, and applies
* any additional ignore directories specified in the options.
*/
private loadIgnoreRules(): void {
if (this.options.useGitignore) {
const gitignorePath = path.join(this.absoluteDir, '.gitignore');
if (fs.existsSync(gitignorePath)) {
this.ignore.add(fs.readFileSync(gitignorePath, 'utf8'));
}
}
if (this.options.useGeminiignore) {
const geminiignorePath = path.join(this.absoluteDir, '.geminiignore');
if (fs.existsSync(geminiignorePath)) {
this.ignore.add(fs.readFileSync(geminiignorePath, 'utf8'));
}
}
const ignoreDirs = ['.git', ...this.options.ignoreDirs];
this.ignore.add(
ignoreDirs.map((dir) => {
if (dir.endsWith('/')) {
return dir;
}
return `${dir}/`;
}),
);
}
/**
* Crawls the file system to get a list of all files and directories,
* optionally using a cache for faster initialization.
*/
private async crawlFiles(): Promise<void> {
if (this.options.cache) {
const cacheKey = cache.getCacheKey(
this.absoluteDir,
this.ignore.getFingerprint(),
);
const cachedResults = cache.read(cacheKey);
if (cachedResults) {
this.allFiles = cachedResults;
return;
}
}
this.allFiles = await this.performCrawl();
if (this.options.cache) {
const cacheKey = cache.getCacheKey(
this.absoluteDir,
this.ignore.getFingerprint(),
);
cache.write(cacheKey, this.allFiles, this.options.cacheTtl * 1000);
}
}
/**
* Performs the actual file system crawl using `fdir`, applying directory
* ignore rules.
* @returns A promise that resolves to a list of all files and directories.
*/
private async performCrawl(): Promise<string[]> {
const dirFilter = this.ignore.getDirectoryFilter();
// We use `fdir` for fast file system traversal. A key performance
// optimization for large workspaces is to exclude entire directories
// early in the traversal process. This is why we apply directory-specific
// ignore rules (e.g., `node_modules/`, `dist/`) directly to `fdir`'s
// exclude filter.
const api = new fdir()
.withRelativePaths()
.withDirs()
.withPathSeparator('/') // Always use unix style paths
.exclude((_, dirPath) => {
const relativePath = path.relative(this.absoluteDir, dirPath);
return dirFilter(`${relativePath}/`);
});
return api.crawl(this.absoluteDir).withPromise();
}
/**
* Builds the in-memory cache for fast pattern matching.
*/
private buildResultCache(): void {
this.resultCache = new ResultCache(this.allFiles, this.absoluteDir);
}
}

View File

@@ -0,0 +1,65 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { Ignore } from './ignore.js';
describe('Ignore', () => {
describe('getDirectoryFilter', () => {
it('should ignore directories matching directory patterns', () => {
const ig = new Ignore().add(['foo/', 'bar/']);
const dirFilter = ig.getDirectoryFilter();
expect(dirFilter('foo/')).toBe(true);
expect(dirFilter('bar/')).toBe(true);
expect(dirFilter('baz/')).toBe(false);
});
it('should not ignore directories with file patterns', () => {
const ig = new Ignore().add(['foo.js', '*.log']);
const dirFilter = ig.getDirectoryFilter();
expect(dirFilter('foo.js')).toBe(false);
expect(dirFilter('foo.log')).toBe(false);
});
});
describe('getFileFilter', () => {
it('should not ignore files with directory patterns', () => {
const ig = new Ignore().add(['foo/', 'bar/']);
const fileFilter = ig.getFileFilter();
expect(fileFilter('foo')).toBe(false);
expect(fileFilter('foo/file.txt')).toBe(false);
});
it('should ignore files matching file patterns', () => {
const ig = new Ignore().add(['*.log', 'foo.js']);
const fileFilter = ig.getFileFilter();
expect(fileFilter('foo.log')).toBe(true);
expect(fileFilter('foo.js')).toBe(true);
expect(fileFilter('bar.txt')).toBe(false);
});
});
it('should accumulate patterns across multiple add() calls', () => {
const ig = new Ignore().add('foo.js');
ig.add('bar.js');
const fileFilter = ig.getFileFilter();
expect(fileFilter('foo.js')).toBe(true);
expect(fileFilter('bar.js')).toBe(true);
expect(fileFilter('baz.js')).toBe(false);
});
it('should return a stable and consistent fingerprint', () => {
const ig1 = new Ignore().add(['foo', '!bar']);
const ig2 = new Ignore().add('foo\n!bar');
// Fingerprints should be identical for the same rules.
expect(ig1.getFingerprint()).toBe(ig2.getFingerprint());
// Adding a new rule should change the fingerprint.
ig2.add('baz');
expect(ig1.getFingerprint()).not.toBe(ig2.getFingerprint());
});
});

View File

@@ -0,0 +1,93 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import ignore from 'ignore';
import picomatch from 'picomatch';
const hasFileExtension = picomatch('**/*[*.]*');
export class Ignore {
private readonly allPatterns: string[] = [];
private dirIgnorer = ignore();
private fileIgnorer = ignore();
/**
* Adds one or more ignore patterns.
* @param patterns A single pattern string or an array of pattern strings.
* Each pattern can be a glob-like string similar to .gitignore rules.
* @returns The `Ignore` instance for chaining.
*/
add(patterns: string | string[]): this {
if (typeof patterns === 'string') {
patterns = patterns.split(/\r?\n/);
}
for (const p of patterns) {
const pattern = p.trim();
if (pattern === '' || pattern.startsWith('#')) {
continue;
}
this.allPatterns.push(pattern);
const isPositiveDirPattern =
pattern.endsWith('/') && !pattern.startsWith('!');
if (isPositiveDirPattern) {
this.dirIgnorer.add(pattern);
} else {
// An ambiguous pattern (e.g., "build") could match a file or a
// directory. To optimize the file system crawl, we use a heuristic:
// patterns without a dot in the last segment are included in the
// directory exclusion check.
//
// This heuristic can fail. For example, an ignore pattern of "my.assets"
// intended to exclude a directory will not be treated as a directory
// pattern because it contains a ".". This results in crawling a
// directory that should have been excluded, reducing efficiency.
// Correctness is still maintained. The incorrectly crawled directory
// will be filtered out by the final ignore check.
//
// For maximum crawl efficiency, users should explicitly mark directory
// patterns with a trailing slash (e.g., "my.assets/").
this.fileIgnorer.add(pattern);
if (!hasFileExtension(pattern)) {
this.dirIgnorer.add(pattern);
}
}
}
return this;
}
/**
* Returns a predicate that matches explicit directory ignore patterns (patterns ending with '/').
* @returns {(dirPath: string) => boolean}
*/
getDirectoryFilter(): (dirPath: string) => boolean {
return (dirPath: string) => this.dirIgnorer.ignores(dirPath);
}
/**
* Returns a predicate that matches file ignore patterns (all patterns not ending with '/').
* Note: This may also match directories if a file pattern matches a directory name, but all explicit directory patterns are handled by getDirectoryFilter.
* @returns {(filePath: string) => boolean}
*/
getFileFilter(): (filePath: string) => boolean {
return (filePath: string) => this.fileIgnorer.ignores(filePath);
}
/**
* Returns a string representing the current set of ignore patterns.
* This can be used to generate a unique identifier for the ignore configuration,
* useful for caching purposes.
* @returns A string fingerprint of the ignore patterns.
*/
getFingerprint(): string {
return this.allPatterns.join('\n');
}
}

View File

@@ -0,0 +1,56 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { test, expect } from 'vitest';
import { ResultCache } from './result-cache.js';
test('ResultCache basic usage', async () => {
const files = [
'foo.txt',
'bar.js',
'baz.md',
'subdir/file.txt',
'subdir/other.js',
'subdir/nested/file.md',
];
const cache = new ResultCache(files, path.resolve('.'));
const { files: resultFiles, isExactMatch } = await cache.get('*.js');
expect(resultFiles).toEqual(files);
expect(isExactMatch).toBe(false);
});
test('ResultCache cache hit/miss', async () => {
const files = ['foo.txt', 'bar.js', 'baz.md'];
const cache = new ResultCache(files, path.resolve('.'));
// First call: miss
const { files: result1Files, isExactMatch: isExactMatch1 } =
await cache.get('*.js');
expect(result1Files).toEqual(files);
expect(isExactMatch1).toBe(false);
// Simulate FileSearch applying the filter and setting the result
cache.set('*.js', ['bar.js']);
// Second call: hit
const { files: result2Files, isExactMatch: isExactMatch2 } =
await cache.get('*.js');
expect(result2Files).toEqual(['bar.js']);
expect(isExactMatch2).toBe(true);
});
test('ResultCache best base query', async () => {
const files = ['foo.txt', 'foobar.js', 'baz.md'];
const cache = new ResultCache(files, path.resolve('.'));
// Cache a broader query
cache.set('foo', ['foo.txt', 'foobar.js']);
// Search for a more specific query that starts with the broader one
const { files: resultFiles, isExactMatch } = await cache.get('foobar');
expect(resultFiles).toEqual(['foo.txt', 'foobar.js']);
expect(isExactMatch).toBe(false);
});

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