Compare commits

...

113 Commits

Author SHA1 Message Date
github-actions[bot]
929ec3882a chore(release): v0.0.5-nightly.3 2025-08-06 09:20:45 +00:00
Yiheng Xu
f0c60b90ea Merge pull request #206 from feature/yiheng/sync-gemini-cli-0.1.17
sync gemini cli 0.1.17
2025-08-06 17:09:54 +08:00
Yiheng Xu
14a3be7976 fix generateJson with respond in schema
Co-Authored-By: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-08-06 17:03:57 +08:00
Yiheng Xu
9ffeacc0f9 fix tool
Co-Authored-By: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-08-05 17:09:25 +08:00
Yiheng Xu
cd375fefe5 sync gemini-cli 0.1.17
Co-Authored-By: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-08-05 17:09:19 +08:00
github-actions[bot]
42a0336876 chore(release): v0.1.17 2025-08-05 00:30:08 +00:00
Harold Mciver
a7ea4ce0c8 Update MCP client to connect to servers with only prompts (#5290) 2025-08-04 21:38:23 +00:00
christine betts
d54780edda [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
3562ab8f5c 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
7b03d057ea ide-mode flag cleanup (#5531) 2025-08-04 21:06:50 +00:00
Shreya Keshive
0895e29c1b Improve user-facing error messages for IDE mode (#5522) 2025-08-04 21:06:17 +00:00
Richie Foreman
fb6d9cbd36 fix(core): Allow model to be set from settings.json (#5527) 2025-08-04 20:41:58 +00:00
Sandy Tao
48fa6f84c8 refactor(core): Rename useSlashCompletion to useCommandCompletion (#5532) 2025-08-04 20:35:26 +00:00
Seth Vargo
016a263409 Use new URLs for downloading workflows (#5524) 2025-08-04 20:10:36 +00:00
Jacob MacDonald
12fc17bc8c remove unnecessary checks in WriteFileChecks.getDescription (#5526) 2025-08-04 19:12:33 +00:00
Sandy Tao
8ba12269d5 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
02e44e5db2 feat(cli): Handle Punctuation in @ Command Parsing (#5482) 2025-08-04 17:49:15 +00:00
Pyush Sinha
ca19aa9125 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
16d29e2d6f Cloud Shell surface logging fix (#5364) 2025-08-04 16:48:46 +00:00
matt korwel
cdbe26b811 feat(docs): create new documentation for automation and triage (#5363) 2025-08-04 08:49:14 -07:00
koalazf.99
f1146c4b2e fix: ci 2025-08-04 22:06:35 +08:00
koalazf.99
0af8b65407 test pr 2025-08-04 18:21:25 +08:00
koalazf.99
db1e358081 add: @qwen pr review 2025-08-04 17:58:01 +08:00
koalazf.99
a28bf81185 update: github workflow actions: pr triage 2025-08-04 17:35:06 +08:00
koalazf.99
d1964200f9 update: github workflow actions 2025-08-04 17:30:46 +08:00
koalazf.99
42ab185890 replace github token 2025-08-04 17:09:57 +08:00
koalazf.99
b2bff47fc7 update action version 2025-08-04 17:02:03 +08:00
koalazf.99
f1328b8437 fix: package dependency && issue traige 2025-08-04 16:20:24 +08:00
koalazf.99
54e41e3b31 skip create app token 2025-08-04 16:12:22 +08:00
koalazf.99
c306cd89fc skip create app token 2025-08-04 16:10:30 +08:00
koalazf.99
0414768cf8 try: github actions 2025-08-04 15:32:20 +08:00
Kumbham Ajay Goud
bdfff529aa 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
f83c6168ad docs(fix): Update themes documentation to include new color keys for… (#5467) 2025-08-03 21:56:27 +00:00
N. Taylor Mullen
c7d1a28ac6 chore(release): v0.1.16 (#5478) 2025-08-03 13:38:03 -07:00
Shreya Keshive
4f69b2d8dc Generate NOTICES.TXT and surface via command (#5310) 2025-08-03 20:19:34 +00:00
Ayesha Shafique
0335ce5ecc feat: Add reverse search capability for shell commands (#4793) 2025-08-03 19:53:24 +00:00
Oleksandr Gotgelf
c0b4fc9506 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
ae8a8f6062 Add a setting to disable the version update nag message (#5449) 2025-08-03 18:20:55 +00:00
奕桁
055c0d8374 version v0.0.4 2025-08-03 18:33:55 +08:00
Gal Zahavi
8d5fa18893 feat(autoupdate): Improve update check and refactor for testability (#5389) 2025-08-02 03:17:32 +00:00
TIRUMALASETTI PRANITH
f50ec186b5 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
321e1e25c7 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
82972e4b03 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
8484730cd6 Propagate prompt (#5033) 2025-08-01 19:37:56 +00:00
joshualitt
e5ce7d4872 bug(cli): Prefer IPv4 dns resolution by default. (#5338) 2025-08-01 19:30:39 +00:00
Santhosh Kumar
786750b1b5 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
e7699ddfb1 feat(core): Use completionStart/End for slash command auto-completion (#5374) 2025-08-01 18:51:38 +00:00
Billy Biggs
cab60a38a1 Add a setting to disable auth mode validation on startup (#5358) 2025-08-01 18:49:03 +00:00
andrea-berling
a2db3d1b38 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
eafcfcd169 doc: use standard Google security policy for GitHub projects (#5062) 2025-08-01 16:12:32 +00:00
Silvio Junior
0d23195624 [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
138e52b61e Add missing emacs entry in UI. (#5351) 2025-08-01 14:40:05 +00:00
Brian Ray
78435ab0bf New browser launcher for MCP OAuth. (#5261) 2025-08-01 05:47:22 +00:00
Sandy Tao
ef445212f6 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
c1157352b7 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
9397336a15 Fix bug executing commands in windows whose flags contain spaces (#5317) 2025-08-01 00:27:07 +00:00
Miguel Solorio
8e6c715b0f Highlight slash commands in history (#5323) 2025-07-31 23:24:23 +00:00
Jacob Richman
750e647988 fix(ux) bug in replaceRange dealing with newLines that was breaking vim support (#5320) 2025-07-31 23:16:29 +00:00
Sandy Tao
150a2568b4 feat(cli): Improve @ autocompletion for mid-sentence edits (#5321) 2025-07-31 23:07:12 +00:00
Paige Bailey
598b2cf7f4 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
8be10b4c09 feat: Implement /setup-github command (#5069) 2025-07-31 22:14:22 +00:00
Yuki Okita
0c0881348d feat: Multi-Directory Workspace Support (part2: add "directory" command) (#5241) 2025-07-31 19:02:08 +00:00
Niladri Das
8550d70a57 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
c80607ac15 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
ceccdf9d2c 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
7ca978f3a0 Replace FlashDecidedToContinueEvent with NextSpeakerCheckEvent (#5257) 2025-07-31 04:47:04 +00:00
Kazunari001
54ec18141c 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
72af6e077f 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
152de2b6d8 Improve error message for discoverTools function (#4157) 2025-07-31 00:16:21 +00:00
Shreya Keshive
8b645ff688 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
aad8893322 Add toggleable IDE mode setting (#5146) 2025-07-30 22:36:24 +00:00
Olcan
e70d2bf6d5 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
5984eba070 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
3e1b2dc33a Introduce IDE mode installer (#4877) 2025-07-30 21:26:31 +00:00
Yuki Okita
cb6a2161fe 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
8fabce2c04 Srithreepo Fixes for Scheduled triage (#5158) 2025-07-30 20:38:02 +00:00
shamso-goog
f7c2091389 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
35811d534a perf(core): parallelize bfsFileSearch for 40% faster CLI startup (#5185) 2025-07-30 17:32:03 +00:00
Olcan
8378fbf7b2 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
658a7b49df 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
f0d80dfe23 chore(release): v0.1.15 (#5163) 2025-07-29 22:03:54 -07:00
Sandy Tao
85a0ed27f6 Skip and reset loop checking around code blocks (#5144) 2025-07-30 04:05:03 +00:00
Jenna Inouye
61107ef19d 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
0b912e2e09 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
c156fb0e8b feat(docs): document GEMINI.md import syntax (#5145) 2025-07-29 23:41:31 +00:00
Ava
1a92614c84 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
ed2b4c6aa4 Fix typo in RFC 9728 impl (#5126) 2025-07-29 23:03:39 +00:00
Srinath Padmanabhan
32c7070d7f Update Triage Logic to improve issue categorization. (#5110) 2025-07-29 21:44:48 +00:00
Shreya Keshive
a2c3dbd189 Adds centralized support to log slash commands + sub commands (#5128) 2025-07-29 20:20:37 +00:00
shamso-goog
72d6ef2d3c 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
bf7fd08f7e feat(commands): add custom commands support for extensions (#4703) 2025-07-29 01:40:47 +00:00
Gal Zahavi
c42d3b58e1 feat: Add auto update functionality (#4686) 2025-07-29 00:56:52 +00:00
Shreya Keshive
69c6808b14 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
3091980de2 feat: Add tests for checkpoint tag sanitization (#4882) 2025-07-28 20:43:39 +00:00
Abhi
cb39eef7b5 Add new fallback state as prefactor for routing (#5065) 2025-07-28 19:55:50 +00:00
Danny
40db8cde97 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
787aa624da 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
56c2d95a4c Revert "feat: Add /config refresh command" (#5060) 2025-07-28 18:13:46 +00:00
Neha Prasad
4b3e407d49 fix: Clear previous thoughts when starting new prompts (#4966) 2025-07-28 17:57:33 +00:00
christine betts
f1f0da6dc9 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
4de893da0d 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
02bf8c16c7 feat: Add /config refresh command (#4993)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2025-07-28 15:46:43 +00:00
Shreya Keshive
165b29c3b1 Updates schema, UX and prompt for IDE context (#5046) 2025-07-28 15:03:22 +00:00
James Woo
16322ed0b2 Fix author attribution (#5042) 2025-07-28 14:45:23 +00:00
N. Taylor Mullen
e1f9f90660 Revert "Propagate user_prompt_id to GenerateConentRequest for logging" (#5007) 2025-07-27 19:28:20 -07:00
Jenna Inouye
0371f638c0 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
79703c8ecb Clarify ToS and privacy documentation FAQs. (#4899) 2025-07-27 22:24:53 +00:00
Hiroaki Mitsuyoshi
f3ffb00ed0 feat(chat): Implement /chat delete command (#2401) 2025-07-27 22:18:12 +00:00
Leeroy Ding
9d07de7a5b fix: yolo mode not respected (#4972) 2025-07-27 21:42:26 +00:00
Abhi
3a384784d7 (model) - Use Flash Lite For Next Speaker Checks (#4991) 2025-07-27 21:40:55 +00:00
owenofbrien
e7b90f54e6 Propagate user_prompt_id to GenerateConentRequest for logging (#4741) 2025-07-27 21:34:39 +00:00
Abhi
8e983466f8 (docs) - Fix small markdown mistake for custom commands docs (#4983) 2025-07-27 21:33:58 +00:00
Hyeladi Bassi
1f013c969f 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
248 changed files with 17297 additions and 3888 deletions

View File

@@ -1,4 +1,4 @@
name: Gemini Automated Issue Triage
name: Qwen Automated Issue Triage
on:
issues:
@@ -7,7 +7,7 @@ on:
jobs:
triage-issue:
timeout-minutes: 5
if: ${{ github.repository == 'google-gemini/gemini-cli' }}
if: ${{ github.repository == 'QwenLM/qwen-code' }}
permissions:
issues: write
contents: read
@@ -17,22 +17,15 @@ jobs:
cancel-in-progress: true
runs-on: ubuntu-latest
steps:
- name: Generate GitHub App Token
id: generate_token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
- name: Run Gemini Issue Triage
uses: google-gemini/gemini-cli-action@df3f890f003d28c60a2a09d2c29e0126e4d1e2ff
- name: Run Qwen Issue Triage
uses: QwenLM/qwen-code-action@5fd6818d04d64e87d255ee4d5f77995e32fbf4c2
env:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
with:
version: 0.1.8-rc.0
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
OTLP_GCP_WIF_PROVIDER: ${{ secrets.OTLP_GCP_WIF_PROVIDER }}
OTLP_GOOGLE_CLOUD_PROJECT: ${{ secrets.OTLP_GOOGLE_CLOUD_PROJECT }}
version: 0.0.4
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
settings_json: |
{
"coreTools": [
@@ -40,24 +33,100 @@ jobs:
"run_shell_command(gh issue edit)",
"run_shell_command(gh issue list)"
],
"telemetry": {
"enabled": true,
"target": "gcp"
},
"sandbox": false
}
prompt: |
You are an issue triage assistant. Analyze the current GitHub issue and apply the most appropriate existing labels.
You are an issue triage assistant. Analyze the current GitHub issues apply the most appropriate existing labels. Do not remove labels titled help wanted or good first issue.
Steps:
1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels.
2. Review the issue title and body provided in the environment variables.
3. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, and priority/*.
4. Apply the selected labels to this issue using: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --add-label "label1,label2"`
5. If the issue has a "status/need-triage" label, remove it after applying the appropriate labels: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --remove-label "status/need-triage"`
2. Review the issue title, body and any comments provided in the environment variables.
3. Ignore any existing priorities or tags on the issue. Just report your findings.
4. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*. For area/* and kind/* limit yourself to only the single most applicable label in each case.
6. Apply the selected labels to this issue using: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --add-label "label1,label2"`
7. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5 for anything more than 6 versions older than the most recent should add the status/need-retesting label
8. If you see that the issue doesnt look like it has sufficient information recommend the status/need-information label
9. Use Area definitions mentioned below to help you narrow down issues
Guidelines:
- Only use labels that already exist in the repository.
- Do not add comments or modify the issue content.
- Triage only the current issue.
- Assign all applicable kind/*, area/*, and priority/* labels based on the issue content.
- Apply only one area/ label
- Apply only one kind/ label
- Apply all applicable sub-area/* and priority/* labels based on the issue content. It's ok to have multiple of these.
- Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario.
Categorization Guidelines:
P0: Critical / Blocker
- A P0 bug is a catastrophic failure that demands immediate attention. It represents a complete showstopper for a significant portion of users or for the development process itself.
Impact:
- Blocks development or testing for the entire team.
- Major security vulnerability that could compromise user data or system integrity.
- Causes data loss or corruption with no workaround.
- Crashes the application or makes a core feature completely unusable for all or most users in a production environment. Will it cause severe quality degration? Is it preventing contributors from contributing to the repository or is it a release blocker?
Qualifier: Is the main function of the software broken?
Example: The gemini auth login command fails with an unrecoverable error, preventing any user from authenticating and using the rest of the CLI.
P1: High
- A P1 bug is a serious issue that significantly degrades the user experience or impacts a core feature. While not a complete blocker, it's a major problem that needs a fast resolution. Feature requests are almost never P1.
Impact:
- A core feature is broken or behaving incorrectly for a large number of users or large number of use cases.
- Review the bug details and comments to try figure out if this issue affects a large set of use cases or if it's a narrow set of use cases.
- Severe performance degradation making the application frustratingly slow.
- No straightforward workaround exists, or the workaround is difficult and non-obvious.
Qualifier: Is a key feature unusable or giving very wrong results?
Example: The gemini -p "..." command consistently returns a malformed JSON response or an empty result, making the CLI's primary generation feature unreliable.
P2: Medium
- A P2 bug is a moderately impactful issue. It's a noticeable problem but doesn't prevent the use of the software's main functionality.
Impact:
- Affects a non-critical feature or a smaller, specific subset of users.
- An inconvenient but functional workaround is available and easy to execute.
- Noticeable UI/UX problems that don't break functionality but look unprofessional (e.g., elements are misaligned or overlapping).
Qualifier: Is it an annoying but non-blocking problem?
Example: An error message is unclear or contains a typo, causing user confusion but not halting their workflow.
P3: Low
- A P3 bug is a minor, low-impact issue that is trivial or cosmetic. It has little to no effect on the overall functionality of the application.
Impact:
- Minor cosmetic issues like color inconsistencies, typos in documentation, or slight alignment problems on a non-critical page.
- An edge-case bug that is very difficult to reproduce and affects a tiny fraction of users.
Qualifier: Is it a "nice-to-fix" issue?
Example: Spelling mistakes etc.
Things you should know:
- If users are talking about issues where the model gets downgraded from pro to flash then i want you to categorize that as a performance issue
- This product is designed to use different models eg.. using pro, downgrading to flash etc. when users report that they dont expect the model to change those would be categorized as feature requests.
Definition of Areas
area/ux:
- Issues concerning user-facing elements like command usability, interactive features, help docs, and perceived performance.
- I am seeing my screen flicker when using Gemini CLI
- I am seeing the output malformed
- Theme changes aren't taking effect
- My keyboard inputs arent' being recognzied
area/platform:
- Issues related to installation, packaging, OS compatibility (Windows, macOS, Linux), and the underlying CLI framework.
area/background: Issues related to long-running background tasks, daemons, and autonomous or proactive agent features.
area/models:
- i am not getting a response that is reasonable or expected. this can include things like
- I am calling a tool and the tool is not performing as expected.
- i am expecting a tool to be called and it is not getting called ,
- Including experience when using
- built-in tools (e.g., web search, code interpreter, read file, writefile, etc..),
- Function calling issues should be under this area
- i am getting responses from the model that are malformed.
- Issues concerning Gemini quality of response and inference,
- Issues talking about unnecessary token consumption.
- Issues talking about Model getting stuck in a loop be watchful as this could be the root cause for issues that otherwise seem like model performance issues.
- Memory compression
- unexpected responses,
- poor quality of generated code
area/tools:
- These are primarily issues related to Model Context Protocol
- These are issues that mention MCP support
- feature requests asking for support for new tools.
area/core: Issues with fundamental components like command parsing, configuration management, session state, and the main API client logic. Introducing multi-modality
area/contribution: Issues related to improving the developer contribution experience, such as CI/CD pipelines, build scripts, and test automation infrastructure.
area/authentication: Issues related to user identity, login flows, API key handling, credential storage, and access token management, unable to sign in selecting wrong authentication path etc..
area/security-privacy: Issues concerning vulnerability patching, dependency security, data sanitization, privacy controls, and preventing unauthorized data access.
area/extensibility: Issues related to the plugin system, extension APIs, or making the CLI's functionality available in other applications, github actions, ide support etc..
area/performance: Issues focused on model performance
- Issues with running out of capacity,
- 429 errors etc..
- could also pertain to latency,
- other general software performance like, memory usage, CPU consumption, and algorithmic efficiency.
- Switching models from one to the other unexpectedly.

View File

@@ -1,4 +1,4 @@
name: Gemini Scheduled Issue Triage
name: Qwen Scheduled Issue Triage
on:
schedule:
@@ -8,24 +8,17 @@ on:
jobs:
triage-issues:
timeout-minutes: 10
if: ${{ github.repository == 'google-gemini/gemini-cli' }}
if: ${{ github.repository == 'QwenLM/qwen-code' }}
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
issues: write
steps:
- name: Generate GitHub App Token
id: generate_token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
- name: Find untriaged issues
id: find_issues
env:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "🔍 Finding issues without labels..."
NO_LABEL_ISSUES=$(gh issue list --repo ${{ github.repository }} --search "is:open is:issue no:label" --json number,title,body)
@@ -41,60 +34,147 @@ jobs:
echo "✅ Found $(echo "$ISSUES" | jq 'length') issues to triage! 🎯"
- name: Run Gemini Issue Triage
- name: Run Qwen Issue Triage
if: steps.find_issues.outputs.issues_to_triage != '[]'
uses: google-gemini/gemini-cli-action@df3f890f003d28c60a2a09d2c29e0126e4d1e2ff
uses: QwenLM/qwen-code-action@5fd6818d04d64e87d255ee4d5f77995e32fbf4c2
env:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUES_TO_TRIAGE: ${{ steps.find_issues.outputs.issues_to_triage }}
REPOSITORY: ${{ github.repository }}
with:
version: 0.1.8-rc.0
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
OTLP_GCP_WIF_PROVIDER: ${{ secrets.OTLP_GCP_WIF_PROVIDER }}
OTLP_GOOGLE_CLOUD_PROJECT: ${{ secrets.OTLP_GOOGLE_CLOUD_PROJECT }}
version: 0.0.4
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
settings_json: |
{
"coreTools": [
"run_shell_command(echo)",
"run_shell_command(gh label list)",
"run_shell_command(gh issue edit)",
"run_shell_command(gh issue list)"
"run_shell_command(gh issue list)",
"run_shell_command(gh issue view)"
],
"telemetry": {
"enabled": true,
"target": "gcp"
},
"sandbox": false
}
prompt: |
You are an issue triage assistant. Analyze issues and apply appropriate labels ONE AT A TIME.
Repository: ${{ github.repository }}
You are an issue triage assistant. Analyze the current GitHub issues apply the most appropriate existing labels.
Steps:
1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to see available labels
1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels.
2. Check environment variable for issues to triage: $ISSUES_TO_TRIAGE (JSON array of issues)
3. Parse the JSON array from step 2 and for EACH INDIVIDUAL issue, apply appropriate labels using separate commands:
- `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --add-label "label1"`
- `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --add-label "label2"`
- Continue for each label separately
IMPORTANT: Label each issue individually, one command per issue, one label at a time if needed.
Guidelines:
- Only use existing repository labels from step 1
- Do not add comments to issues
- Triage each issue independently based on title and body content
- Focus on applying: kind/* (bug/enhancement/documentation), area/* (core/cli/testing/windows), and priority/* labels
- If an issue has insufficient information, consider applying "status/need-information"
- After applying appropriate labels to an issue, remove the "status/need-triage" label if present: `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "status/need-triage"`
- Execute one `gh issue edit` command per issue, wait for success before proceeding to the next
Example triage logic:
- Issues with "bug", "error", "broken" → kind/bug
- Issues with "feature", "enhancement", "improve" → kind/enhancement
- Issues about Windows/performance → area/windows, area/performance
- Critical bugs → priority/p0, other bugs → priority/p1, enhancements → priority/p2
3. Review the issue title, body and any comments provided in the environment variables.
4. Ignore any existing priorities or tags on the issue.
5. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*.
6. Get the list of labels already on the issue using `gh issue view ISSUE_NUMBER --repo ${{ github.repository }} --json labels -t '{{range .labels}}{{.name}}{{"\n"}}{{end}}'
7. For area/* and kind/* limit yourself to only the single most applicable label in each case.
8. Give me a single short paragraph about why you are selecting each label in the process. use the format Issue ID: , Title, Label applied:, Label removed, ovearll explanation
9. Parse the JSON array from step 2 and for EACH INDIVIDUAL issue, apply appropriate labels using separate commands:
- `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --add-label "label1"`
- `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --add-label "label2"`
- Continue for each label separately
- IMPORTANT: Label each issue individually, one command per issue, one label at a time if needed.
- Make sure after you apply labels there is only one area/* and one kind/* label per issue.
- To do this look for labels found in step 6 that no longer apply remove them one at a time using
- `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "label-name1"`
- `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "label-name2"`
- IMPORTANT: Remove each label one at a time, one command per issue if needed.
10. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5
- Anything more than 6 versions older than the most recent should add the status/need-retesting label
11. If you see that the issue doesnt look like it has sufficient information recommend the status/need-information label
- After applying appropriate labels to an issue, remove the "status/need-triage" label if present: `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "status/need-triage"`
- Execute one `gh issue edit` command per issue, wait for success before proceeding to the next
Process each issue sequentially and confirm each labeling operation before moving to the next issue.
Guidelines:
- Only use labels that already exist in the repository.
- Do not add comments or modify the issue content.
- Do not remove labels titled help wanted or good first issue.
- Triage only the current issue.
- Apply only one area/ label
- Apply only one kind/ label (Do not apply kind/duplicate or kind/parent-issue)
- Apply all applicable sub-area/* and priority/* labels based on the issue content. It's ok to have multiple of these.
- Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario.
Categorization Guidelines:
P0: Critical / Blocker
- A P0 bug is a catastrophic failure that demands immediate attention. It represents a complete showstopper for a significant portion of users or for the development process itself.
Impact:
- Blocks development or testing for the entire team.
- Major security vulnerability that could compromise user data or system integrity.
- Causes data loss or corruption with no workaround.
- Crashes the application or makes a core feature completely unusable for all or most users in a production environment. Will it cause severe quality degration?
- Is it preventing contributors from contributing to the repository or is it a release blocker?
Qualifier: Is the main function of the software broken?
Example: The gemini auth login command fails with an unrecoverable error, preventing any user from authenticating and using the rest of the CLI.
P1: High
- A P1 bug is a serious issue that significantly degrades the user experience or impacts a core feature. While not a complete blocker, it's a major problem that needs a fast resolution.
- Feature requests are almost never P1.
Impact:
- A core feature is broken or behaving incorrectly for a large number of users or large number of use cases.
- Review the bug details and comments to try figure out if this issue affects a large set of use cases or if it's a narrow set of use cases.
- Severe performance degradation making the application frustratingly slow.
- No straightforward workaround exists, or the workaround is difficult and non-obvious.
Qualifier: Is a key feature unusable or giving very wrong results?
Example: The gemini -p "..." command consistently returns a malformed JSON response or an empty result, making the CLI's primary generation feature unreliable.
P2: Medium
- A P2 bug is a moderately impactful issue. It's a noticeable problem but doesn't prevent the use of the software's main functionality.
Impact:
- Affects a non-critical feature or a smaller, specific subset of users.
- An inconvenient but functional workaround is available and easy to execute.
- Noticeable UI/UX problems that don't break functionality but look unprofessional (e.g., elements are misaligned or overlapping).
Qualifier: Is it an annoying but non-blocking problem?
Example: An error message is unclear or contains a typo, causing user confusion but not halting their workflow.
P3: Low
- A P3 bug is a minor, low-impact issue that is trivial or cosmetic. It has little to no effect on the overall functionality of the application.
Impact:
- Minor cosmetic issues like color inconsistencies, typos in documentation, or slight alignment problems on a non-critical page.
- An edge-case bug that is very difficult to reproduce and affects a tiny fraction of users.
Qualifier: Is it a "nice-to-fix" issue?
Example: Spelling mistakes etc.
Additional Context:
- If users are talking about issues where the model gets downgraded from pro to flash then i want you to categorize that as a performance issue
- This product is designed to use different models eg.. using pro, downgrading to flash etc.
- When users report that they dont expect the model to change those would be categorized as feature requests.
Definition of Areas
area/ux:
- Issues concerning user-facing elements like command usability, interactive features, help docs, and perceived performance.
- I am seeing my screen flicker when using Gemini CLI
- I am seeing the output malformed
- Theme changes aren't taking effect
- My keyboard inputs arent' being recognzied
area/platform:
- Issues related to installation, packaging, OS compatibility (Windows, macOS, Linux), and the underlying CLI framework.
area/background: Issues related to long-running background tasks, daemons, and autonomous or proactive agent features.
area/models:
- i am not getting a response that is reasonable or expected. this can include things like
- I am calling a tool and the tool is not performing as expected.
- i am expecting a tool to be called and it is not getting called ,
- Including experience when using
- built-in tools (e.g., web search, code interpreter, read file, writefile, etc..),
- Function calling issues should be under this area
- i am getting responses from the model that are malformed.
- Issues concerning Gemini quality of response and inference,
- Issues talking about unnecessary token consumption.
- Issues talking about Model getting stuck in a loop be watchful as this could be the root cause for issues that otherwise seem like model performance issues.
- Memory compression
- unexpected responses,
- poor quality of generated code
area/tools:
- These are primarily issues related to Model Context Protocol
- These are issues that mention MCP support
- feature requests asking for support for new tools.
area/core:
- Issues with fundamental components like command parsing, configuration management, session state, and the main API client logic. Introducing multi-modality
area/contribution:
- Issues related to improving the developer contribution experience, such as CI/CD pipelines, build scripts, and test automation infrastructure.
area/authentication:
- Issues related to user identity, login flows, API key handling, credential storage, and access token management, unable to sign in selecting wrong authentication path etc..
area/security-privacy:
- Issues concerning vulnerability patching, dependency security, data sanitization, privacy controls, and preventing unauthorized data access.
area/extensibility:
- Issues related to the plugin system, extension APIs, or making the CLI's functionality available in other applications, github actions, ide support etc..
area/performance:
- Issues focused on model performance
- Issues with running out of capacity,
- 429 errors etc..
- could also pertain to latency,
- other general software performance like, memory usage, CPU consumption, and algorithmic efficiency.
- Switching models from one to the other unexpectedly.

View File

@@ -1,4 +1,4 @@
name: Gemini Scheduled PR Triage 🚀
name: Qwen Scheduled PR Triage 🚀
on:
schedule:
@@ -8,7 +8,7 @@ on:
jobs:
audit-prs:
timeout-minutes: 15
if: ${{ github.repository == 'google-gemini/gemini-cli' }}
if: ${{ github.repository == 'QwenLM/qwen-code' }}
permissions:
contents: read
id-token: write
@@ -21,16 +21,9 @@ jobs:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Generate GitHub App Token
id: generate_token
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
- name: Run PR Triage Script
id: run_triage
env:
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: ./.github/scripts/pr-triage.sh

32
.github/workflows/no-response.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: No Response
# Run as a daily cron at 1:45 AM
on:
schedule:
- cron: '45 1 * * *'
workflow_dispatch: {}
jobs:
no-response:
runs-on: ubuntu-latest
if: ${{ github.repository == 'google-gemini/gemini-cli' }}
permissions:
issues: write
pull-requests: write
concurrency:
group: ${{ github.workflow }}-no-response
cancel-in-progress: true
steps:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: -1
days-before-close: 14
stale-issue-label: 'status/need-information'
close-issue-message: >
This issue was marked as needing more information and has not received a response in 14 days.
Closing it for now. If you still face this problem, feel free to reopen with more details. Thank you!
stale-pr-label: 'status/need-information'
close-pr-message: >
This pull request was marked as needing more information and has had no updates in 14 days.
Closing it for now. You are welcome to reopen with the required info. Thanks for contributing!

View File

@@ -0,0 +1,191 @@
name: 🧐 Qwen Pull Request Review
on:
pull_request:
types: [opened]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to review'
required: true
type: number
jobs:
review-pr:
if: >
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && github.event.action == 'opened') ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '@qwen /review') &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR')) ||
(github.event_name == 'pull_request_review_comment' &&
contains(github.event.comment.body, '@qwen /review') &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR')) ||
(github.event_name == 'pull_request_review' &&
contains(github.event.review.body, '@qwen /review') &&
(github.event.review.author_association == 'OWNER' ||
github.event.review.author_association == 'MEMBER' ||
github.event.review.author_association == 'COLLABORATOR'))
timeout-minutes: 15
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
pull-requests: write
issues: write
steps:
- name: Checkout PR code
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Get PR details (pull_request & workflow_dispatch)
id: get_pr
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PR_NUMBER=${{ github.event.inputs.pr_number }}
else
PR_NUMBER=${{ github.event.pull_request.number }}
fi
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
# Get PR details
PR_DATA=$(gh pr view $PR_NUMBER --json title,body,additions,deletions,changedFiles,baseRefName,headRefName)
echo "pr_data=$PR_DATA" >> "$GITHUB_OUTPUT"
# Get file changes
CHANGED_FILES=$(gh pr diff $PR_NUMBER --name-only)
echo "changed_files<<EOF" >> "$GITHUB_OUTPUT"
echo "$CHANGED_FILES" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Get PR details (issue_comment)
id: get_pr_comment
if: github.event_name == 'issue_comment'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
PR_NUMBER=${{ github.event.issue.number }}
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
# Extract additional instructions from comment
ADDITIONAL_INSTRUCTIONS=$(echo "$COMMENT_BODY" | sed 's/.*@qwen \/review//' | xargs)
echo "additional_instructions=$ADDITIONAL_INSTRUCTIONS" >> "$GITHUB_OUTPUT"
# Get PR details
PR_DATA=$(gh pr view $PR_NUMBER --json title,body,additions,deletions,changedFiles,baseRefName,headRefName)
echo "pr_data=$PR_DATA" >> "$GITHUB_OUTPUT"
# Get file changes
CHANGED_FILES=$(gh pr diff $PR_NUMBER --name-only)
echo "changed_files<<EOF" >> "$GITHUB_OUTPUT"
echo "$CHANGED_FILES" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Run Qwen PR Review
uses: QwenLM/qwen-code-action@5fd6818d04d64e87d255ee4d5f77995e32fbf4c2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.get_pr.outputs.pr_number || steps.get_pr_comment.outputs.pr_number }}
PR_DATA: ${{ steps.get_pr.outputs.pr_data || steps.get_pr_comment.outputs.pr_data }}
CHANGED_FILES: ${{ steps.get_pr.outputs.changed_files || steps.get_pr_comment.outputs.changed_files }}
ADDITIONAL_INSTRUCTIONS: ${{ steps.get_pr.outputs.additional_instructions || steps.get_pr_comment.outputs.additional_instructions }}
REPOSITORY: ${{ github.repository }}
with:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
settings_json: |
{
"coreTools": [
"run_shell_command(echo)",
"run_shell_command(gh pr view)",
"run_shell_command(gh pr diff)",
"run_shell_command(gh pr comment)",
"run_shell_command(cat)",
"run_shell_command(head)",
"run_shell_command(tail)",
"run_shell_command(grep)",
"write_file"
],
"sandbox": false
}
prompt: |
You are an expert code reviewer. You have access to shell commands to gather PR information and perform the review.
IMPORTANT: Use the available shell commands to gather information. Do not ask for information to be provided.
Start by running these commands to gather the required data:
1. Run: echo "$PR_DATA" to get PR details (JSON format)
2. Run: echo "$CHANGED_FILES" to get the list of changed files
3. Run: echo "$PR_NUMBER" to get the PR number
4. Run: echo "$ADDITIONAL_INSTRUCTIONS" to see any specific review instructions from the user
5. Run: gh pr diff $PR_NUMBER to see the full diff
6. For any specific files, use: cat filename, head -50 filename, or tail -50 filename
Additional Review Instructions:
If ADDITIONAL_INSTRUCTIONS contains text, prioritize those specific areas or focus points in your review.
Common instruction examples: "focus on security", "check performance", "review error handling", "check for breaking changes"
Once you have the information, provide a comprehensive code review by:
1. Writing your review to a file: write_file("review.md", "<your detailed review feedback here>")
2. Posting the review: gh pr comment $PR_NUMBER --body-file review.md --repo $REPOSITORY
Review Areas:
- **Security**: Authentication, authorization, input validation, data sanitization
- **Performance**: Algorithms, database queries, caching, resource usage
- **Reliability**: Error handling, logging, testing coverage, edge cases
- **Maintainability**: Code structure, documentation, naming conventions
- **Functionality**: Logic correctness, requirements fulfillment
Output Format:
Structure your review using this exact format with markdown:
## 📋 Review Summary
Provide a brief 2-3 sentence overview of the PR and overall assessment.
## 🔍 General Feedback
- List general observations about code quality
- Mention overall patterns or architectural decisions
- Highlight positive aspects of the implementation
- Note any recurring themes across files
## 🎯 Specific Feedback
Only include sections below that have actual issues. If there are no issues in a priority category, omit that entire section.
### 🔴 Critical
(Only include this section if there are critical issues)
Issues that must be addressed before merging (security vulnerabilities, breaking changes, major bugs):
- **File: `filename:line`** - Description of critical issue with specific recommendation
### 🟡 High
(Only include this section if there are high priority issues)
Important issues that should be addressed (performance problems, design flaws, significant bugs):
- **File: `filename:line`** - Description of high priority issue with suggested fix
### 🟢 Medium
(Only include this section if there are medium priority issues)
Improvements that would enhance code quality (style issues, minor optimizations, better practices):
- **File: `filename:line`** - Description of medium priority improvement
### 🔵 Low
(Only include this section if there are suggestions)
Nice-to-have improvements and suggestions (documentation, naming, minor refactoring):
- **File: `filename:line`** - Description of suggestion or enhancement
**Note**: If no specific issues are found in any category, simply state "No specific issues identified in this review."
## ✅ Highlights
(Only include this section if there are positive aspects to highlight)
- Mention specific good practices or implementations
- Acknowledge well-written code sections
- Note improvements from previous versions

38
.github/workflows/stale.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: Mark stale issues and pull requests
# Run as a daily cron at 1:30 AM
on:
schedule:
- cron: '30 1 * * *'
workflow_dispatch: {}
jobs:
stale:
runs-on: ubuntu-latest
if: ${{ github.repository == 'google-gemini/gemini-cli' }}
permissions:
issues: write
pull-requests: write
concurrency:
group: ${{ github.workflow }}-stale
cancel-in-progress: true
steps:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: >
This issue has been automatically marked as stale due to 60 days of inactivity.
It will be closed in 14 days if no further activity occurs.
stale-pr-message: >
This pull request has been automatically marked as stale due to 60 days of inactivity.
It will be closed in 14 days if no further activity occurs.
close-issue-message: >
This issue has been closed due to 14 additional days of inactivity after being marked as stale.
If you believe this is still relevant, feel free to comment or reopen the issue. Thank you!
close-pr-message: >
This pull request has been closed due to 14 additional days of inactivity after being marked as stale.
If this is still relevant, you are welcome to reopen or leave a comment. Thanks for contributing!
days-before-stale: 60
days-before-close: 14
exempt-issue-labels: pinned,security
exempt-pr-labels: pinned,security

2
.vscode/launch.json vendored
View File

@@ -50,7 +50,7 @@
"type": "node",
// fix source mapping when debugging in sandbox using global installation
// note this does not interfere when remoteRoot is also ${workspaceFolder}/packages
"remoteRoot": "/usr/local/share/npm-global/lib/node_modules/@gemini-cli",
"remoteRoot": "/usr/local/share/npm-global/lib/node_modules/@qwen-code",
"localRoot": "${workspaceFolder}/packages"
},
{

View File

@@ -242,6 +242,8 @@ To hit a breakpoint inside the sandbox container run:
DEBUG=1 gemini
```
**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect gemini-cli due to automatic exclusion. Use `.gemini/.env` files for gemini-cli specific debug settings.
### React DevTools
To debug the CLI's React-based UI, you can use React DevTools. Ink, the library used for the CLI's interface, is compatible with React DevTools version 4.x.

View File

@@ -1,4 +1,4 @@
# Gemini CLI Roadmap
# Qwen CLI Roadmap
The [Official Gemini CLI Roadmap](https://github.com/orgs/google-gemini/projects/11/)

8
SECURITY.md Normal file
View File

@@ -0,0 +1,8 @@
# Reporting Security Issues
To report a security issue, please use [https://g.co/vulnz](https://g.co/vulnz).
We use g.co/vulnz for our intake, and do coordination and disclosure here on
GitHub (including using GitHub Security Advisory). The Google Security Team will
respond within 5 working days of your report on g.co/vulnz.
[GitHub Security Advisory]: https://github.com/google-gemini/gemini-cli/security/advisories

View File

@@ -92,6 +92,8 @@ The Gemini CLI requires you to authenticate with Google's AI services. On initia
You can create a **`.gemini/.env`** file in your project directory or in your home directory. Creating a plain **`.env`** file also works, but `.gemini/.env` is recommended to keep Gemini variables isolated from other tools.
**Important:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from project `.env` files to prevent interference with gemini-cli behavior. Use `.gemini/.env` files for gemini-cli specific variables.
Gemini CLI automatically loads environment variables from the **first** `.env` file it finds, using the following search order:
1. Starting in the **current directory** and moving upward toward `/`, for each directory it checks:

View File

@@ -17,6 +17,11 @@ Slash commands provide meta-level control over the CLI itself.
- **`save`**
- **Description:** Saves the current conversation history. You must add a `<tag>` for identifying the conversation state.
- **Usage:** `/chat save <tag>`
- **Details on Checkpoint Location:** The default locations for saved chat checkpoints are:
- Linux/macOS: `~/.config/google-generative-ai/checkpoints/`
- Windows: `C:\Users\<YourUsername>\AppData\Roaming\google-generative-ai\checkpoints\`
- When you run `/chat list`, the CLI only scans these specific directories to find available checkpoints.
- **Note:** These checkpoints are for manually saving and resuming conversation states. For automatic checkpoints created before file modifications, see the [Checkpointing documentation](../checkpointing.md).
- **`resume`**
- **Description:** Resumes a conversation from a previous save.
- **Usage:** `/chat resume <tag>`
@@ -33,6 +38,17 @@ Slash commands provide meta-level control over the CLI itself.
- **`/copy`**
- **Description:** Copies the last output produced by Qwen Code to your clipboard, for easy sharing or reuse.
- **`/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 `/direcotry add` and `--include-directories`.
- **Usage:** `/directory show`
- **`/editor`**
- **Description:** Open a dialog for selecting supported editors.
@@ -106,6 +122,9 @@ Slash commands provide meta-level control over the CLI itself.
- **Persistent setting:** Vim mode preference is saved to `~/.gemini/settings.json` and restored between sessions
- **Status indicator:** When enabled, shows `[NORMAL]` or `[INSERT]` in the footer
- **`/init`**
- **Description:** To help users easily create a `GEMINI.md` file, this command analyzes the current directory and generates a tailored context file, making it simpler for them to provide project-specific instructions to the Gemini agent.
### Custom Commands
For a quick start, see the [example](#example-a-pure-function-refactoring-command) below.

View File

@@ -240,6 +240,14 @@ In addition to a project settings file, a project's `.gemini` directory can cont
}
```
- **`excludedProjectEnvVars`** (array of strings):
- **Description:** Specifies environment variables that should be excluded from being loaded from project `.env` files. This prevents project-specific environment variables (like `DEBUG=true`) from interfering with gemini-cli behavior. Variables from `.gemini/.env` files are never excluded.
- **Default:** `["DEBUG", "DEBUG_MODE"]`
- **Example:**
```json
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"]
```
### Example `settings.json`:
```json
@@ -271,7 +279,8 @@ In addition to a project settings file, a project's `.gemini` directory can cont
"run_shell_command": {
"tokenBudget": 100
}
}
},
"excludedProjectEnvVars": ["DEBUG", "DEBUG_MODE", "NODE_ENV"]
}
```
@@ -293,6 +302,8 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
2. If not found, it searches upwards in parent directories until it finds an `.env` file or reaches the project root (identified by a `.git` folder) or the home directory.
3. If still not found, it looks for `~/.env` (in the user's home directory).
**Environment Variable Exclusion:** Some environment variables (like `DEBUG` and `DEBUG_MODE`) are automatically excluded from being loaded from project `.env` files to prevent interference with gemini-cli behavior. Variables from `.gemini/.env` files are never excluded. You can customize this behavior using the `excludedProjectEnvVars` setting in your `settings.json` file.
- **`GEMINI_API_KEY`** (Required):
- Your API key for the Gemini API.
- **Crucial for operation.** The CLI will not function without it.
@@ -332,6 +343,7 @@ The CLI automatically loads environment variables from an `.env` file. The loadi
- `<profile_name>`: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-<profile_name>.sb` in your project's `.qwen/` directory (e.g., `my-project/.qwen/sandbox-macos-custom.sb`).
- **`DEBUG` or `DEBUG_MODE`** (often used by underlying libraries or the CLI itself):
- Set to `true` or `1` to enable verbose debug logging, which can be helpful for troubleshooting.
- **Note:** These variables are automatically excluded from project `.env` files by default to prevent interference with gemini-cli behavior. Use `.gemini/.env` files if you need to set these for gemini-cli specifically.
- **`NO_COLOR`**:
- Set to any value to disable all color output in the CLI.
- **`CLI_TITLE`**:
@@ -387,6 +399,11 @@ Arguments passed directly when running the CLI can override other configurations
- **`--proxy`**:
- Sets the proxy for the CLI.
- Example: `--proxy http://localhost:7890`.
- **`--include-directories <dir1,dir2,...>`**:
- Includes additional directories in the workspace for multi-directory support.
- Can be specified multiple times or as comma-separated values.
- 5 directories can be added at maximum.
- Example: `--include-directories /path/to/project1,/path/to/project2` or `--include-directories /path/to/project1 --include-directories /path/to/project2`
- **`--version`**:
- Displays the version of the CLI.
- **`--openai-logging`**:
@@ -444,6 +461,7 @@ This example demonstrates how you can provide general project context, specific
- Location: The CLI also scans for the configured context file in subdirectories _below_ the current working directory (respecting common ignore patterns like `node_modules`, `.git`, etc.). The breadth of this search is limited to 200 directories by default, but can be configured with a `memoryDiscoveryMaxDirs` field in your `settings.json` file.
- Scope: Allows for highly specific instructions relevant to a particular component, module, or subsection of your project.
- **Concatenation & UI Indication:** The contents of all found context files are concatenated (with separators indicating their origin and path) and provided as part of the system prompt to the Gemini model. The CLI footer displays the count of loaded context files, giving you a quick visual cue about the active instructional context.
- **Importing Content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, see the [Memory Import Processor documentation](../core/memport.md).
- **Commands for Memory Management:**
- Use `/memory refresh` to force a re-scan and reload of all context files from all configured locations. This updates the AI's instructional context.
- Use `/memory show` to display the combined instructional context currently loaded, allowing you to verify the hierarchy and content being used by the AI.

View File

@@ -58,7 +58,11 @@ Add a `customThemes` block to your user, project, or system `settings.json` file
"AccentYellow": "#E5C07B",
"AccentRed": "#E06C75",
"Comment": "#5C6370",
"Gray": "#ABB2BF"
"Gray": "#ABB2BF",
"DiffAdded": "#A6E3A1",
"DiffRemoved": "#F38BA8",
"DiffModified": "#89B4FA",
"GradientColors": ["#4796E4", "#847ACE", "#C3677F"]
}
}
}
@@ -77,6 +81,9 @@ Add a `customThemes` block to your user, project, or system `settings.json` file
- `AccentRed`
- `Comment`
- `Gray`
- `DiffAdded` (optional, for added lines in diffs)
- `DiffRemoved` (optional, for removed lines in diffs)
- `DiffModified` (optional, for modified lines in diffs)
**Required Properties:**

View File

@@ -1,18 +1,14 @@
# Memory Import Processor
The Memory Import Processor is a feature that allows you to modularize your GEMINI.md files by importing content from other markdown files using the `@file.md` syntax.
The Memory Import Processor is a feature that allows you to modularize your GEMINI.md files by importing content from other files using the `@file.md` syntax.
## Overview
This feature enables you to break down large GEMINI.md files into smaller, more manageable components that can be reused across different contexts. The import processor supports both relative and absolute paths, with built-in safety features to prevent circular imports and ensure file access security.
## Important Limitations
**This feature only supports `.md` (markdown) files.** Attempting to import files with other extensions (like `.txt`, `.json`, etc.) will result in a warning and the import will fail.
## Syntax
Use the `@` symbol followed by the path to the markdown file you want to import:
Use the `@` symbol followed by the path to the file you want to import:
```markdown
# Main GEMINI.md file
@@ -96,24 +92,10 @@ The `validateImportPath` function ensures that imports are only allowed from spe
### Maximum Import Depth
To prevent infinite recursion, there's a configurable maximum import depth (default: 10 levels).
To prevent infinite recursion, there's a configurable maximum import depth (default: 5 levels).
## Error Handling
### Non-MD File Attempts
If you try to import a non-markdown file, you'll see a warning:
```markdown
@./instructions.txt <!-- This will show a warning and fail -->
```
Console output:
```
[WARN] [ImportProcessor] Import processor only supports .md files. Attempting to import non-md file: ./instructions.txt. This will fail.
```
### Missing Files
If a referenced file doesn't exist, the import will fail gracefully with an error comment in the output.
@@ -122,6 +104,36 @@ If a referenced file doesn't exist, the import will fail gracefully with an erro
Permission issues or other file system errors are handled gracefully with appropriate error messages.
## Code Region Detection
The import processor uses the `marked` library to detect code blocks and inline code spans, ensuring that `@` imports inside these regions are properly ignored. This provides robust handling of nested code blocks and complex Markdown structures.
## Import Tree Structure
The processor returns an import tree that shows the hierarchy of imported files, similar to Claude's `/memory` feature. This helps users debug problems with their GEMINI.md files by showing which files were read and their import relationships.
Example tree structure:
```
Memory Files
L project: GEMINI.md
L a.md
L b.md
L c.md
L d.md
L e.md
L f.md
L included.md
```
The tree preserves the order that files were imported and shows the complete import chain for debugging purposes.
## Comparison to Claude Code's `/memory` (`claude.md`) Approach
Claude Code's `/memory` feature (as seen in `claude.md`) produces a flat, linear document by concatenating all included files, always marking file boundaries with clear comments and path names. It does not explicitly present the import hierarchy, but the LLM receives all file contents and paths, which is sufficient for reconstructing the hierarchy if needed.
Note: The import tree is mainly for clarity during development and has limited relevance to LLM consumption.
## API Reference
### `processImports(content, basePath, debugMode?, importState?)`
@@ -135,7 +147,25 @@ Processes import statements in GEMINI.md content.
- `debugMode` (boolean, optional): Whether to enable debug logging (default: false)
- `importState` (ImportState, optional): State tracking for circular import prevention
**Returns:** Promise<string> - Processed content with imports resolved
**Returns:** Promise<ProcessImportsResult> - Object containing processed content and import tree
### `ProcessImportsResult`
```typescript
interface ProcessImportsResult {
content: string; // The processed content with imports resolved
importTree: MemoryFile; // Tree structure showing the import hierarchy
}
```
### `MemoryFile`
```typescript
interface MemoryFile {
path: string; // The file path
imports?: MemoryFile[]; // Direct imports, in the order they were imported
}
```
### `validateImportPath(importPath, basePath, allowedDirectories)`
@@ -149,6 +179,16 @@ Validates import paths to ensure they are safe and within allowed directories.
**Returns:** boolean - Whether the import path is valid
### `findProjectRoot(startDir)`
Finds the project root by searching for a `.git` directory upwards from the given start directory. Implemented as an **async** function using non-blocking file system APIs to avoid blocking the Node.js event loop.
**Parameters:**
- `startDir` (string): The directory to start searching from
**Returns:** Promise<string> - The project root directory (or the start directory if no `.git` is found)
## Best Practices
1. **Use descriptive file names** for imported components
@@ -161,7 +201,7 @@ Validates import paths to ensure they are safe and within allowed directories.
### Common Issues
1. **Import not working**: Check that the file exists and has a `.md` extension
1. **Import not working**: Check that the file exists and the path is correct
2. **Circular import warnings**: Review your import structure for circular references
3. **Permission errors**: Ensure the files are readable and within allowed directories
4. **Path resolution issues**: Use absolute paths if relative paths aren't resolving correctly

View File

@@ -33,10 +33,44 @@ The `gemini-extension.json` file contains the configuration for the extension. T
}
```
- `name`: The name of the extension. This is used to uniquely identify the extension. This should match the name of your extension directory.
- `name`: The name of the extension. This is used to uniquely identify the extension and for conflict resolution when extension commands have the same name as user or project commands.
- `version`: The version of the extension.
- `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence.
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the workspace. If this property is not used but a `GEMINI.md` file is present in your extension directory, then that file will be loaded.
- `excludeTools`: An array of tool names to exclude from the model. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command.
When Gemini CLI starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence.
## Extension Commands
Extensions can provide [custom commands](./cli/commands.md#custom-commands) by placing TOML files in a `commands/` subdirectory within the extension directory. These commands follow the same format as user and project custom commands and use standard naming conventions.
### Example
An extension named `gcp` with the following structure:
```
.gemini/extensions/gcp/
├── gemini-extension.json
└── commands/
├── deploy.toml
└── gcs/
└── sync.toml
```
Would provide these commands:
- `/deploy` - Shows as `[gcp] Custom command from deploy.toml` in help
- `/gcs:sync` - Shows as `[gcp] Custom command from sync.toml` in help
### Conflict Resolution
Extension commands have the lowest precedence. When a conflict occurs with user or project commands:
1. **No conflict**: Extension command uses its natural name (e.g., `/deploy`)
2. **With conflict**: Extension command is renamed with the extension prefix (e.g., `/gcp.deploy`)
For example, if both a user and the `gcp` extension define a `deploy` command:
- `/deploy` - Executes the user's deploy command
- `/gcp.deploy` - Executes the extension's deploy command (marked with `[gcp]` tag)

59
docs/gemini-ignore.md Normal file
View File

@@ -0,0 +1,59 @@
# Ignoring Files
This document provides an overview of the Gemini Ignore (`.geminiignore`) feature of the Gemini CLI.
The Gemini CLI includes the ability to automatically ignore files, similar to `.gitignore` (used by Git) and `.aiexclude` (used by Gemini Code Assist). Adding paths to your `.geminiignore` file will exclude them from tools that support this feature, although they will still be visible to other services (such as Git).
## How it works
When you add a path to your `.geminiignore` file, tools that respect this file will exclude matching files and directories from their operations. For example, when you use the [`read_many_files`](./tools/multi-file.md) command, any paths in your `.geminiignore` file will be automatically excluded.
For the most part, `.geminiignore` follows the conventions of `.gitignore` files:
- Blank lines and lines starting with `#` are ignored.
- Standard glob patterns are supported (such as `*`, `?`, and `[]`).
- Putting a `/` at the end will only match directories.
- Putting a `/` at the beginning anchors the path relative to the `.geminiignore` file.
- `!` negates a pattern.
You can update your `.geminiignore` file at any time. To apply the changes, you must restart your Gemini CLI session.
## How to use `.geminiignore`
To enable `.geminiignore`:
1. Create a file named `.geminiignore` in the root of your project directory.
To add a file or directory to `.geminiignore`:
1. Open your `.geminiignore` file.
2. Add the path or file you want to ignore, for example: `/archive/` or `apikeys.txt`.
### `.geminiignore` examples
You can use `.geminiignore` to ignore directories and files:
```
# Exclude your /packages/ directory and all subdirectories
/packages/
# Exclude your apikeys.txt file
apikeys.txt
```
You can use wildcards in your `.geminiignore` file with `*`:
```
# Exclude all .md files
*.md
```
Finally, you can exclude files and directories from exclusion with `!`:
```
# Exclude all .md files except README.md
*.md
!README.md
```
To remove paths from your `.geminiignore` file, delete the relevant lines.

View File

@@ -0,0 +1,84 @@
# Automation and Triage Processes
This document provides a detailed overview of the automated processes we use to manage and triage issues and pull requests. Our goal is to provide prompt feedback and ensure that contributions are reviewed and integrated efficiently. Understanding this automation will help you as a contributor know what to expect and how to best interact with our repository bots.
## Guiding Principle: Issues and Pull Requests
First and foremost, almost every Pull Request (PR) should be linked to a corresponding Issue. The issue describes the "what" and the "why" (the bug or feature), while the PR is the "how" (the implementation). This separation helps us track work, prioritize features, and maintain clear historical context. Our automation is built around this principle.
---
## Detailed Automation Workflows
Here is a breakdown of the specific automation workflows that run in our repository.
### 1. When you open an Issue: `Automated Issue Triage`
This is the first bot you will interact with when you create an issue. Its job is to perform an initial analysis and apply the correct labels.
- **Workflow File**: `.github/workflows/gemini-automated-issue-triage.yml`
- **When it runs**: Immediately after an issue is created or reopened.
- **What it does**:
- It uses a Gemini model to analyze the issue's title and body against a detailed set of guidelines.
- **Applies one `area/*` label**: Categorizes the issue into a functional area of the project (e.g., `area/ux`, `area/models`, `area/platform`).
- **Applies one `kind/*` label**: Identifies the type of issue (e.g., `kind/bug`, `kind/enhancement`, `kind/question`).
- **Applies one `priority/*` label**: Assigns a priority from P0 (critical) to P3 (low) based on the described impact.
- **May apply `status/need-information`**: If the issue lacks critical details (like logs or reproduction steps), it will be flagged for more information.
- **May apply `status/need-retesting`**: If the issue references a CLI version that is more than six versions old, it will be flagged for retesting on a current version.
- **What you should do**:
- Fill out the issue template as completely as possible. The more detail you provide, the more accurate the triage will be.
- If the `status/need-information` label is added, please provide the requested details in a comment.
### 2. When you open a Pull Request: `Continuous Integration (CI)`
This workflow ensures that all changes meet our quality standards before they can be merged.
- **Workflow File**: `.github/workflows/ci.yml`
- **When it runs**: On every push to a pull request.
- **What it does**:
- **Lint**: Checks that your code adheres to our project's formatting and style rules.
- **Test**: Runs our full suite of automated tests across macOS, Windows, and Linux, and on multiple Node.js versions. This is the most time-consuming part of the CI process.
- **Post Coverage Comment**: After all tests have successfully passed, a bot will post a comment on your PR. This comment provides a summary of how well your changes are covered by tests.
- **What you should do**:
- Ensure all CI checks pass. A green checkmark ✅ will appear next to your commit when everything is successful.
- If a check fails (a red "X" ❌), click the "Details" link next to the failed check to view the logs, identify the problem, and push a fix.
### 3. Ongoing Triage for Pull Requests: `PR Auditing and Label Sync`
This workflow runs periodically to ensure all open PRs are correctly linked to issues and have consistent labels.
- **Workflow File**: `.github/workflows/gemini-scheduled-pr-triage.yml`
- **When it runs**: Every 15 minutes on all open pull requests.
- **What it does**:
- **Checks for a linked issue**: The bot scans your PR description for a keyword that links it to an issue (e.g., `Fixes #123`, `Closes #456`).
- **Adds `status/need-issue`**: If no linked issue is found, the bot will add the `status/need-issue` label to your PR. This is a clear signal that an issue needs to be created and linked.
- **Synchronizes labels**: If an issue _is_ linked, the bot ensures the PR's labels perfectly match the issue's labels. It will add any missing labels and remove any that don't belong, and it will remove the `status/need-issue` label if it was present.
- **What you should do**:
- **Always link your PR to an issue.** This is the most important step. Add a line like `Resolves #<issue-number>` to your PR description.
- This will ensure your PR is correctly categorized and moves through the review process smoothly.
### 4. Ongoing Triage for Issues: `Scheduled Issue Triage`
This is a fallback workflow to ensure that no issue gets missed by the triage process.
- **Workflow File**: `.github/workflows/gemini-scheduled-issue-triage.yml`
- **When it runs**: Every hour on all open issues.
- **What it does**:
- It actively seeks out issues that either have no labels at all or still have the `status/need-triage` label.
- It then triggers the same powerful Gemini-based analysis as the initial triage bot to apply the correct labels.
- **What you should do**:
- You typically don't need to do anything. This workflow is a safety net to ensure every issue is eventually categorized, even if the initial triage fails.
### 5. Release Automation
This workflow handles the process of packaging and publishing new versions of the Gemini CLI.
- **Workflow File**: `.github/workflows/release.yml`
- **When it runs**: On a daily schedule for "nightly" releases, and manually for official patch/minor releases.
- **What it does**:
- Automatically builds the project, bumps the version numbers, and publishes the packages to npm.
- Creates a corresponding release on GitHub with generated release notes.
- **What you should do**:
- As a contributor, you don't need to do anything for this process. You can be confident that once your PR is merged into the `main` branch, your changes will be included in the very next nightly release.
We hope this detailed overview is helpful. If you have any questions about our automation or processes, please don't hesitate to ask!

View File

@@ -0,0 +1,62 @@
# Gemini CLI Keyboard Shortcuts
This document lists the available keyboard shortcuts in the Gemini CLI.
## General
| Shortcut | Description |
| -------- | --------------------------------------------------------------------------------------------------------------------- |
| `Esc` | Close dialogs and suggestions. |
| `Ctrl+C` | Exit the application. Press twice to confirm. |
| `Ctrl+D` | Exit the application if the input is empty. Press twice to confirm. |
| `Ctrl+L` | Clear the screen. |
| `Ctrl+O` | Toggle the display of the debug console. |
| `Ctrl+S` | Allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. |
| `Ctrl+T` | Toggle the display of tool descriptions. |
| `Ctrl+Y` | Toggle auto-approval (YOLO mode) for all tool calls. |
## Input Prompt
| Shortcut | Description |
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `!` | Toggle shell mode when the input is empty. |
| `\` (at end of line) + `Enter` | Insert a newline. |
| `Down Arrow` | Navigate down through the input history. |
| `Enter` | Submit the current prompt. |
| `Meta+Delete` / `Ctrl+Delete` | Delete the word to the right of the cursor. |
| `Tab` | Autocomplete the current suggestion if one exists. |
| `Up Arrow` | Navigate up through the input history. |
| `Ctrl+A` / `Home` | Move the cursor to the beginning of the line. |
| `Ctrl+B` / `Left Arrow` | Move the cursor one character to the left. |
| `Ctrl+C` | Clear the input prompt |
| `Ctrl+D` / `Delete` | Delete the character to the right of the cursor. |
| `Ctrl+E` / `End` | Move the cursor to the end of the line. |
| `Ctrl+F` / `Right Arrow` | Move the cursor one character to the right. |
| `Ctrl+H` / `Backspace` | Delete the character to the left of the cursor. |
| `Ctrl+K` | Delete from the cursor to the end of the line. |
| `Ctrl+Left Arrow` / `Meta+Left Arrow` / `Meta+B` | Move the cursor one word to the left. |
| `Ctrl+N` | Navigate down through the input history. |
| `Ctrl+P` | Navigate up through the input history. |
| `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F` | Move the cursor one word to the right. |
| `Ctrl+U` | Delete from the cursor to the beginning of the line. |
| `Ctrl+V` | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. |
| `Ctrl+W` / `Meta+Backspace` / `Ctrl+Backspace` | Delete the word to the left of the cursor. |
| `Ctrl+X` / `Meta+Enter` | Open the current input in an external editor. |
## Suggestions
| Shortcut | Description |
| --------------- | -------------------------------------- |
| `Down Arrow` | Navigate down through the suggestions. |
| `Tab` / `Enter` | Accept the selected suggestion. |
| `Up Arrow` | Navigate up through the suggestions. |
## Radio Button Select
| Shortcut | Description |
| ------------------ | ------------------------------------------------------------------------------------------------------------- |
| `Down Arrow` / `j` | Move selection down. |
| `Enter` | Confirm selection. |
| `Up Arrow` / `k` | Move selection up. |
| `1-9` | Select an item by its number. |
| (multi-digit) | For items with numbers greater than 9, press the digits in quick succession to select the corresponding item. |

View File

@@ -77,6 +77,24 @@ Built-in profiles (set via `SEATBELT_PROFILE` env var):
- `restrictive-open`: Strict restrictions, network allowed
- `restrictive-closed`: Maximum restrictions
### Custom Sandbox Flags
For container-based sandboxing, you can inject custom flags into the `docker` or `podman` command using the `SANDBOX_FLAGS` environment variable. This is useful for advanced configurations, such as disabling security features for specific use cases.
**Example (Podman)**:
To disable SELinux labeling for volume mounts, you can set the following:
```bash
export SANDBOX_FLAGS="--security-opt label=disable"
```
Multiple flags can be provided as a space-separated string:
```bash
export SANDBOX_FLAGS="--flag1 --flag2=value"
```
## Linux UID/GID handling
The sandbox automatically handles user permissions on Linux. Override these permissions with:
@@ -111,6 +129,8 @@ export SANDBOX_SET_UID_GID=false # Disable UID/GID mapping
DEBUG=1 gemini -s -p "debug command"
```
**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect gemini-cli due to automatic exclusion. Use `.gemini/.env` files for gemini-cli specific debug settings.
### Inspect sandbox
```bash

View File

@@ -209,6 +209,11 @@ Logs are timestamped records of specific events. The following events are logged
- **Attributes**:
- `auth_type`
- `gemini_cli.slash_command`: This event occurs when a user executes a slash command.
- **Attributes**:
- `command` (string)
- `subcommand` (string, if applicable)
### Metrics
Metrics are numerical measurements of behavior over time. The following metrics are collected for Gemini CLI:

View File

@@ -570,3 +570,70 @@ The MCP integration tracks several states:
- **Conflict resolution:** Tool name conflicts between servers are resolved through automatic prefixing
This comprehensive integration makes MCP servers a powerful way to extend the Gemini CLI's capabilities while maintaining security, reliability, and ease of use.
## 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.
### Defining Prompts on the Server
Here's a small example of a stdio MCP server that defines prompts:
```ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
const server = new McpServer({
name: 'prompt-server',
version: '1.0.0',
});
server.registerPrompt(
'poem-writer',
{
title: 'Poem Writer',
description: 'Write a nice haiku',
argsSchema: { title: z.string(), mood: z.string().optional() },
},
({ title, mood }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Write a haiku${mood ? ` with the mood ${mood}` : ''} called ${title}. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables `,
},
},
],
}),
);
const transport = new StdioServerTransport();
await server.connect(transport);
```
This can be included in `settings.json` under `mcpServers` with:
```json
"nodeServer": {
"command": "node",
"args": ["filename.ts"],
}
```
### Invoking Prompts
Once a prompt is discovered, you can invoke it using its name as a slash command. The CLI will automatically handle parsing arguments.
```bash
/poem-writer --title="Gemini CLI" --mood="reverent"
```
or, using positional arguments:
```bash
/poem-writer "Gemini CLI" reverent
```
When you run this command, the Gemini CLI executes the `prompts/get` method on the MCP server with the provided arguments. The server is responsible for substituting the arguments into the prompt template and returning the final prompt text. The CLI then sends this prompt to the model for execution. This provides a convenient way to automate and share common workflows.

View File

@@ -11,11 +11,13 @@ Use `read_many_files` to read content from multiple files specified by paths or
`read_many_files` can be used to perform tasks such as getting an overview of a codebase, finding where specific functionality is implemented, reviewing documentation, or gathering context from multiple configuration files.
**Note:** `read_many_files` looks for files following the provided paths or glob patterns. A directory path such as `"/docs"` will return an empty result; the tool requires a pattern such as `"/docs/*"` or `"/docs/*.md"` to identify the relevant files.
### Arguments
`read_many_files` takes the following arguments:
- `paths` (list[string], required): An array of glob patterns or paths relative to the tool's target directory (e.g., `["src/**/*.ts"]`, `["README.md", "docs/", "assets/logo.png"]`).
- `paths` (list[string], required): An array of glob patterns or paths relative to the tool's target directory (e.g., `["src/**/*.ts"]`, `["README.md", "docs/*", "assets/logo.png"]`).
- `exclude` (list[string], optional): Glob patterns for files/directories to exclude (e.g., `["**/*.log", "temp/"]`). These are added to default excludes if `useDefaultExcludes` is true.
- `include` (list[string], optional): Additional glob patterns to include. These are merged with `paths` (e.g., `["*.test.ts"]` to specifically add test files if they were broadly excluded, or `["images/*.jpg"]` to include specific image types).
- `recursive` (boolean, optional): Whether to search recursively. This is primarily controlled by `**` in glob patterns. Defaults to `true`.

View File

@@ -63,6 +63,8 @@ You may opt-out from sending Usage Statistics to Google by following the instruc
Whether your code, including prompts and answers, is used to train Google's models depends on the type of authentication method you use and your account type.
By default (if you have not opted out):
- **Google account with Gemini Code Assist for Individuals**: Yes. When you use your personal Google account, the [Gemini Code Assist Privacy Notice for Individuals](https://developers.google.com/gemini-code-assist/resources/privacy-notice-gemini-code-assist-individuals) applies. Under this notice,
your **prompts, answers, and related code are collected** and may be used to improve Google's products, including for model training.
- **Google account with Gemini Code Assist for Workspace, Standard, or Enterprise**: No. For these accounts, your data is governed by the [Gemini Code Assist Privacy Notices](https://cloud.google.com/gemini/docs/codeassist/security-privacy-compliance#standard_and_enterprise_data_protection_and_privacy) terms, which treat your inputs as confidential. Your **prompts, answers, and related code are not collected** and are not used to train models.
@@ -71,17 +73,21 @@ Whether your code, including prompts and answers, is used to train Google's mode
- **Paid services**: No. When you use the Gemini API key via the Gemini Developer API with a paid service, the [Gemini API Terms of Service - Paid Services](https://ai.google.dev/gemini-api/terms#paid-services) terms apply, which treats your inputs as confidential. Your **prompts, answers, and related code are not collected** and are not used to train models.
- **Gemini API key via the Vertex AI GenAI API**: No. For these accounts, your data is governed by the [Google Cloud Privacy Notice](https://cloud.google.com/terms/cloud-privacy-notice) terms, which treat your inputs as confidential. Your **prompts, answers, and related code are not collected** and are not used to train models.
For more information about opting out, refer to the next question.
### 2. What are Usage Statistics and what does the opt-out control?
The **Usage Statistics** setting is the single control for all optional data collection in the Gemini CLI.
The data it collects depends on your account and authentication type:
- **Google account with Gemini Code Assist for Individuals**: When enabled, this setting allows Google to collect both anonymous telemetry (for example, commands run and performance metrics) and **your prompts and answers** for model improvement.
- **Google account with Gemini Code Assist for Workspace, Standard, or Enterprise**: This setting only controls the collection of anonymous telemetry. Your prompts and answers are never collected, regardless of this setting.
- **Google account with Gemini Code Assist for Individuals**: When enabled, this setting allows Google to collect both anonymous telemetry (for example, commands run and performance metrics) and **your prompts and answers, including code,** for model improvement.
- **Google account with Gemini Code Assist for Workspace, Standard, or Enterprise**: This setting only controls the collection of anonymous telemetry. Your prompts and answers, including code, are never collected, regardless of this setting.
- **Gemini API key via the Gemini Developer API**:
**Unpaid services**: When enabled, this setting allows Google to collect both anonymous telemetry (like commands run and performance metrics) and **your prompts and answers** for model improvement. When disabled we will use your data as described in [How Google Uses Your Data](https://ai.google.dev/gemini-api/terms#data-use-unpaid).
**Unpaid services**: When enabled, this setting allows Google to collect both anonymous telemetry (like commands run and performance metrics) and **your prompts and answers, including code,** for model improvement. When disabled we will use your data as described in [How Google Uses Your Data](https://ai.google.dev/gemini-api/terms#data-use-unpaid).
**Paid services**: This setting only controls the collection of anonymous telemetry. Google logs prompts and responses for a limited period of time, solely for the purpose of detecting violations of the Prohibited Use Policy and any required legal or regulatory disclosures.
- **Gemini API key via the Vertex AI GenAI API:** This setting only controls the collection of anonymous telemetry. Your prompts and answers are never collected, regardless of this setting.
- **Gemini API key via the Vertex AI GenAI API:** This setting only controls the collection of anonymous telemetry. Your prompts and answers, including code, are never collected, regardless of this setting.
Please refer to the Privacy Notice that applies to your authentication method for more information about what data is collected and how this data is used.
You can disable Usage Statistics for any account type by following the instructions in the [Usage Statistics Configuration](./cli/configuration.md#usage-statistics) documentation.

View File

@@ -53,6 +53,11 @@ This guide provides solutions to common issues and debugging tips.
- **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.
- **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**
- **Issue:** Setting `DEBUG=true` in a project's `.env` file doesn't enable debug mode for gemini-cli.
- **Cause:** The `DEBUG` and `DEBUG_MODE` variables are automatically excluded from project `.env` files to prevent interference with gemini-cli behavior.
- **Solution:** Use a `.gemini/.env` file instead, or configure the `excludedProjectEnvVars` setting in your `settings.json` to exclude fewer variables.
## Debugging Tips
- **CLI debugging:**

View File

@@ -34,6 +34,7 @@ export default tseslint.config(
'packages/server/dist/**',
'packages/vscode-ide-companion/dist/**',
'bundle/**',
'package/bundle/**',
],
},
eslint.configs.recommended,
@@ -203,6 +204,21 @@ export default tseslint.config(
'@typescript-eslint/no-require-imports': 'off',
},
},
// extra settings for scripts that we run directly with node
{
files: ['packages/vscode-ide-companion/scripts/**/*.js'],
languageOptions: {
globals: {
...globals.node,
process: 'readonly',
console: 'readonly',
},
},
rules: {
'no-restricted-syntax': 'off',
'@typescript-eslint/no-require-imports': 'off',
},
},
// Prettier config must be last
prettierConfig,
// extra settings for scripts that we run directly with node

View File

@@ -6,25 +6,84 @@
import { strict as assert } from 'assert';
import { test } from 'node:test';
import { TestRig } from './test-helper.js';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
test('reads a file', (t) => {
test('should be able to read a file', async () => {
const rig = new TestRig();
rig.setup(t.name);
await rig.setup('should be able to read a file');
rig.createFile('test.txt', 'hello world');
const output = rig.run(`read the file name test.txt`);
const result = await rig.run(
`read the file test.txt and show me its contents`,
);
assert.ok(output.toLowerCase().includes('hello'));
const foundToolCall = await rig.waitForToolCall('read_file');
// Add debugging information
if (!foundToolCall || !result.includes('hello world')) {
printDebugInfo(rig, result, {
'Found tool call': foundToolCall,
'Contains hello world': result.includes('hello world'),
});
}
assert.ok(foundToolCall, 'Expected to find a read_file tool call');
// Validate model output - will throw if no output, warn if missing expected content
validateModelOutput(result, 'hello world', 'File read test');
});
test('writes a file', (t) => {
test('should be able to write a file', async () => {
const rig = new TestRig();
rig.setup(t.name);
await rig.setup('should be able to write a file');
rig.createFile('test.txt', '');
rig.run(`edit test.txt to have a hello world message`);
const result = await rig.run(`edit test.txt to have a hello world message`);
// Accept multiple valid tools for editing files
const foundToolCall = await rig.waitForAnyToolCall([
'write_file',
'edit',
'replace',
]);
// Add debugging information
if (!foundToolCall) {
printDebugInfo(rig, result);
}
assert.ok(
foundToolCall,
'Expected to find a write_file, edit, or replace tool call',
);
// Validate model output - will throw if no output
validateModelOutput(result, null, 'File write test');
const fileContent = rig.readFile('test.txt');
assert.ok(fileContent.toLowerCase().includes('hello'));
// Add debugging for file content
if (!fileContent.toLowerCase().includes('hello')) {
const writeCalls = rig
.readToolLogs()
.filter((t) => t.toolRequest.name === 'write_file')
.map((t) => t.toolRequest.args);
printDebugInfo(rig, result, {
'File content mismatch': true,
'Expected to contain': 'hello',
'Actual content': fileContent,
'Write tool calls': JSON.stringify(writeCalls),
});
}
assert.ok(
fileContent.toLowerCase().includes('hello'),
'Expected file to contain hello',
);
// Log success info if verbose
if (process.env.VERBOSE === 'true') {
console.log('File written successfully with hello message.');
}
});

View File

@@ -6,14 +6,69 @@
import { test } from 'node:test';
import { strict as assert } from 'assert';
import { TestRig } from './test-helper.js';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
test('should be able to search the web', async (t) => {
test('should be able to search the web', async () => {
const rig = new TestRig();
rig.setup(t.name);
await rig.setup('should be able to search the web');
const prompt = `what planet do we live on`;
const result = await rig.run(prompt);
let result;
try {
result = await rig.run(`what is the weather in London`);
} catch (error) {
// Network errors can occur in CI environments
if (
error.message.includes('network') ||
error.message.includes('timeout')
) {
console.warn('Skipping test due to network error:', error.message);
return; // Skip the test
}
throw error; // Re-throw if not a network error
}
assert.ok(result.toLowerCase().includes('earth'));
const foundToolCall = await rig.waitForToolCall('google_web_search');
// Add debugging information
if (!foundToolCall) {
const allTools = printDebugInfo(rig, result);
// Check if the tool call failed due to network issues
const failedSearchCalls = allTools.filter(
(t) =>
t.toolRequest.name === 'google_web_search' && !t.toolRequest.success,
);
if (failedSearchCalls.length > 0) {
console.warn(
'google_web_search tool was called but failed, possibly due to network issues',
);
console.warn(
'Failed calls:',
failedSearchCalls.map((t) => t.toolRequest.args),
);
return; // Skip the test if network issues
}
}
assert.ok(foundToolCall, 'Expected to find a call to google_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',
);
// If content was missing, log the search queries used
if (!hasExpectedContent) {
const searchCalls = rig
.readToolLogs()
.filter((t) => t.toolRequest.name === 'google_web_search');
if (searchCalls.length > 0) {
console.warn(
'Search queries used:',
searchCalls.map((t) => t.toolRequest.args),
);
}
}
});

View File

@@ -6,19 +6,57 @@
import { test } from 'node:test';
import { strict as assert } from 'assert';
import { TestRig } from './test-helper.js';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
import { existsSync } from 'fs';
import { join } from 'path';
test('should be able to list a directory', async (t) => {
test('should be able to list a directory', async () => {
const rig = new TestRig();
rig.setup(t.name);
await rig.setup('should be able to list a directory');
rig.createFile('file1.txt', 'file 1 content');
rig.mkdir('subdir');
rig.sync();
const prompt = `Can you list the files in the current directory. Display them in the style of 'ls'`;
const result = rig.run(prompt);
// Poll for filesystem changes to propagate in containers
await rig.poll(
() => {
// Check if the files exist in the test directory
const file1Path = join(rig.testDir, 'file1.txt');
const subdirPath = join(rig.testDir, 'subdir');
return existsSync(file1Path) && existsSync(subdirPath);
},
1000, // 1 second max wait
50, // check every 50ms
);
const lines = result.split('\n').filter((line) => line.trim() !== '');
assert.ok(lines.some((line) => line.includes('file1.txt')));
assert.ok(lines.some((line) => line.includes('subdir')));
const prompt = `Can you list the files in the current directory. Display them in the style of 'ls'`;
const result = await rig.run(prompt);
const foundToolCall = await rig.waitForToolCall('list_directory');
// Add debugging information
if (
!foundToolCall ||
!result.includes('file1.txt') ||
!result.includes('subdir')
) {
const allTools = printDebugInfo(rig, result, {
'Found tool call': foundToolCall,
'Contains file1.txt': result.includes('file1.txt'),
'Contains subdir': result.includes('subdir'),
});
console.error(
'List directory calls:',
allTools
.filter((t) => t.toolRequest.name === 'list_directory')
.map((t) => t.toolRequest.args),
);
}
assert.ok(foundToolCall, 'Expected to find a list_directory tool call');
// Validate model output - will throw if no output, warn if missing expected content
validateModelOutput(result, ['file1.txt', 'subdir'], 'List directory test');
});

View File

@@ -6,17 +6,45 @@
import { test } from 'node:test';
import { strict as assert } from 'assert';
import { TestRig } from './test-helper.js';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
test.skip('should be able to read multiple files', async (t) => {
test('should be able to read multiple files', async () => {
const rig = new TestRig();
rig.setup(t.name);
await rig.setup('should be able to read multiple files');
rig.createFile('file1.txt', 'file 1 content');
rig.createFile('file2.txt', 'file 2 content');
const prompt = `Read the files in this directory, list them and print them to the screen`;
const prompt = `Please use read_many_files to read file1.txt and file2.txt and show me what's in them`;
const result = await rig.run(prompt);
assert.ok(result.includes('file 1 content'));
assert.ok(result.includes('file 2 content'));
// Check for either read_many_files or multiple read_file calls
const allTools = rig.readToolLogs();
const readManyFilesCall = await rig.waitForToolCall('read_many_files');
const readFileCalls = allTools.filter(
(t) => t.toolRequest.name === 'read_file',
);
// Accept either read_many_files OR at least 2 read_file calls
const foundValidPattern = readManyFilesCall || readFileCalls.length >= 2;
// Add debugging information
if (!foundValidPattern) {
printDebugInfo(rig, result, {
'read_many_files called': readManyFilesCall,
'read_file calls': readFileCalls.length,
});
}
assert.ok(
foundValidPattern,
'Expected to find either read_many_files or multiple read_file tool calls',
);
// Validate model output - will throw if no output, warn if missing expected content
validateModelOutput(
result,
['file 1 content', 'file 2 content'],
'Read many files test',
);
});

View File

@@ -6,17 +6,61 @@
import { test } from 'node:test';
import { strict as assert } from 'assert';
import { TestRig } from './test-helper.js';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
test('should be able to replace content in a file', async (t) => {
test('should be able to replace content in a file', async () => {
const rig = new TestRig();
rig.setup(t.name);
await rig.setup('should be able to replace content in a file');
const fileName = 'file_to_replace.txt';
rig.createFile(fileName, 'original content');
const originalContent = 'original content';
const expectedContent = 'replaced content';
rig.createFile(fileName, originalContent);
const prompt = `Can you replace 'original' with 'replaced' in the file 'file_to_replace.txt'`;
await rig.run(prompt);
const result = await rig.run(prompt);
const foundToolCall = await rig.waitForToolCall('replace');
// Add debugging information
if (!foundToolCall) {
printDebugInfo(rig, result);
}
assert.ok(foundToolCall, 'Expected to find a replace tool call');
// Validate model output - will throw if no output, warn if missing expected content
validateModelOutput(
result,
['replaced', 'file_to_replace.txt'],
'Replace content test',
);
const newFileContent = rig.readFile(fileName);
assert.strictEqual(newFileContent, 'replaced content');
// Add debugging for file content
if (newFileContent !== expectedContent) {
console.error('File content mismatch - Debug info:');
console.error('Expected:', expectedContent);
console.error('Actual:', newFileContent);
console.error(
'Tool calls:',
rig.readToolLogs().map((t) => ({
name: t.toolRequest.name,
args: t.toolRequest.args,
})),
);
}
assert.strictEqual(
newFileContent,
expectedContent,
'File content should be updated correctly',
);
// Log success info if verbose
if (process.env.VERBOSE === 'true') {
console.log('File replaced successfully. New content:', newFileContent);
}
});

View File

@@ -101,6 +101,7 @@ async function main() {
KEEP_OUTPUT: keepOutput.toString(),
VERBOSE: verbose.toString(),
TEST_FILE_NAME: testFileName,
TELEMETRY_LOG_FILE: join(testFileDir, 'telemetry.log'),
},
});

View File

@@ -6,26 +6,58 @@
import { test } from 'node:test';
import { strict as assert } from 'assert';
import { TestRig } from './test-helper.js';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
test('should be able to run a shell command', async (t) => {
test('should be able to run a shell command', async () => {
const rig = new TestRig();
rig.setup(t.name);
rig.createFile('blah.txt', 'some content');
await rig.setup('should be able to run a shell command');
const prompt = `Can you use ls to list the contexts of the current folder`;
const result = rig.run(prompt);
const prompt = `Please run the command "echo hello-world" and show me the output`;
assert.ok(result.includes('blah.txt'));
const result = await rig.run(prompt);
const foundToolCall = await rig.waitForToolCall('run_shell_command');
// Add debugging information
if (!foundToolCall || !result.includes('hello-world')) {
printDebugInfo(rig, result, {
'Found tool call': foundToolCall,
'Contains hello-world': result.includes('hello-world'),
});
}
assert.ok(foundToolCall, 'Expected to find a run_shell_command tool call');
// Validate model output - will throw if no output, warn if missing expected content
// Model often reports exit code instead of showing output
validateModelOutput(
result,
['hello-world', 'exit code 0'],
'Shell command test',
);
});
test('should be able to run a shell command via stdin', async (t) => {
test('should be able to run a shell command via stdin', async () => {
const rig = new TestRig();
rig.setup(t.name);
rig.createFile('blah.txt', 'some content');
await rig.setup('should be able to run a shell command via stdin');
const prompt = `Can you use ls to list the contexts of the current folder`;
const result = rig.run({ stdin: prompt });
const prompt = `Please run the command "echo test-stdin" and show me what it outputs`;
assert.ok(result.includes('blah.txt'));
const result = await rig.run({ stdin: prompt });
const foundToolCall = await rig.waitForToolCall('run_shell_command');
// Add debugging information
if (!foundToolCall || !result.includes('test-stdin')) {
printDebugInfo(rig, result, {
'Test type': 'Stdin test',
'Found tool call': foundToolCall,
'Contains test-stdin': result.includes('test-stdin'),
});
}
assert.ok(foundToolCall, 'Expected to find a run_shell_command tool call');
// Validate model output - will throw if no output, warn if missing expected content
validateModelOutput(result, 'test-stdin', 'Shell command stdin test');
});

View File

@@ -6,16 +6,36 @@
import { test } from 'node:test';
import { strict as assert } from 'assert';
import { TestRig } from './test-helper.js';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
test('should be able to save to memory', async (t) => {
test('should be able to save to memory', async () => {
const rig = new TestRig();
rig.setup(t.name);
await rig.setup('should be able to save to memory');
const prompt = `remember that my favorite color is blue.
what is my favorite color? tell me that and surround it with $ symbol`;
const result = await rig.run(prompt);
assert.ok(result.toLowerCase().includes('$blue$'));
const foundToolCall = await rig.waitForToolCall('save_memory');
// Add debugging information
if (!foundToolCall || !result.toLowerCase().includes('blue')) {
const allTools = printDebugInfo(rig, result, {
'Found tool call': foundToolCall,
'Contains blue': result.toLowerCase().includes('blue'),
});
console.error(
'Memory tool calls:',
allTools
.filter((t) => t.toolRequest.name === 'save_memory')
.map((t) => t.toolRequest.args),
);
}
assert.ok(foundToolCall, 'Expected to find a save_memory tool call');
// Validate model output - will throw if no output, warn if missing expected content
validateModelOutput(result, 'blue', 'Save memory test');
});

View File

@@ -4,67 +4,208 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { test, describe, before, after } from 'node:test';
/**
* This test verifies MCP (Model Context Protocol) server integration.
* It uses a minimal MCP server implementation that doesn't require
* external dependencies, making it compatible with Docker sandbox mode.
*/
import { test, describe, before } from 'node:test';
import { strict as assert } from 'node:assert';
import { TestRig } from './test-helper.js';
import { spawn } from 'child_process';
import { TestRig, validateModelOutput } from './test-helper.js';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { writeFileSync, unlinkSync } from 'fs';
import { writeFileSync } from 'fs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const serverScriptPath = join(__dirname, './temp-server.js');
const serverScript = `
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
// Create a minimal MCP server that doesn't require external dependencies
// This implements the MCP protocol directly using Node.js built-ins
const serverScript = `#!/usr/bin/env node
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const server = new McpServer({
name: 'addition-server',
version: '1.0.0',
const readline = require('readline');
const fs = require('fs');
// Debug logging to stderr (only when MCP_DEBUG or VERBOSE is set)
const debugEnabled = process.env.MCP_DEBUG === 'true' || process.env.VERBOSE === 'true';
function debug(msg) {
if (debugEnabled) {
fs.writeSync(2, \`[MCP-DEBUG] \${msg}\\n\`);
}
}
debug('MCP server starting...');
// Simple JSON-RPC implementation for MCP
class SimpleJSONRPC {
constructor() {
this.handlers = new Map();
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false
});
this.rl.on('line', (line) => {
debug(\`Received line: \${line}\`);
try {
const message = JSON.parse(line);
debug(\`Parsed message: \${JSON.stringify(message)}\`);
this.handleMessage(message);
} catch (e) {
debug(\`Parse error: \${e.message}\`);
}
});
}
send(message) {
const msgStr = JSON.stringify(message);
debug(\`Sending message: \${msgStr}\`);
process.stdout.write(msgStr + '\\n');
}
async handleMessage(message) {
if (message.method && this.handlers.has(message.method)) {
try {
const result = await this.handlers.get(message.method)(message.params || {});
if (message.id !== undefined) {
this.send({
jsonrpc: '2.0',
id: message.id,
result
});
}
} catch (error) {
if (message.id !== undefined) {
this.send({
jsonrpc: '2.0',
id: message.id,
error: {
code: -32603,
message: error.message
}
});
}
}
} else if (message.id !== undefined) {
this.send({
jsonrpc: '2.0',
id: message.id,
error: {
code: -32601,
message: 'Method not found'
}
});
}
}
on(method, handler) {
this.handlers.set(method, handler);
}
}
// Create MCP server
const rpc = new SimpleJSONRPC();
// Handle initialize
rpc.on('initialize', async (params) => {
debug('Handling initialize request');
return {
protocolVersion: '2024-11-05',
capabilities: {
tools: {}
},
serverInfo: {
name: 'addition-server',
version: '1.0.0'
}
};
});
server.registerTool(
'add',
{
title: 'Addition Tool',
description: 'Add two numbers',
inputSchema: { a: z.number(), b: z.number() },
},
async ({ a, b }) => ({
content: [{ type: 'text', text: String(a + b) }],
}),
);
// Handle tools/list
rpc.on('tools/list', async () => {
debug('Handling tools/list request');
return {
tools: [{
name: 'add',
description: 'Add two numbers',
inputSchema: {
type: 'object',
properties: {
a: { type: 'number', description: 'First number' },
b: { type: 'number', description: 'Second number' }
},
required: ['a', 'b']
}
}]
};
});
const transport = new StdioServerTransport();
await server.connect(transport);
// Handle tools/call
rpc.on('tools/call', async (params) => {
debug(\`Handling tools/call request for tool: \${params.name}\`);
if (params.name === 'add') {
const { a, b } = params.arguments;
return {
content: [{
type: 'text',
text: String(a + b)
}]
};
}
throw new Error('Unknown tool: ' + params.name);
});
// Send initialization notification
rpc.send({
jsonrpc: '2.0',
method: 'initialized'
});
`;
describe('simple-mcp-server', () => {
const rig = new TestRig();
let child;
before(() => {
writeFileSync(serverScriptPath, serverScript);
child = spawn('node', [serverScriptPath], {
stdio: ['pipe', 'pipe', 'pipe'],
before(async () => {
// Setup test directory with MCP server configuration
await rig.setup('simple-mcp-server', {
settings: {
mcpServers: {
'addition-server': {
command: 'node',
args: ['mcp-server.cjs'],
},
},
},
});
child.stderr.on('data', (data) => {
console.error(`stderr: ${data}`);
});
// Wait for the server to be ready
return new Promise((resolve) => setTimeout(resolve, 2000));
// Create server script in the test directory
const testServerPath = join(rig.testDir, 'mcp-server.cjs');
writeFileSync(testServerPath, serverScript);
// Make the script executable (though running with 'node' should work anyway)
if (process.platform !== 'win32') {
const { chmodSync } = await import('fs');
chmodSync(testServerPath, 0o755);
}
});
after(() => {
child.kill();
unlinkSync(serverScriptPath);
});
test('should add two numbers', async () => {
// Test directory is already set up in before hook
// Just run the command - MCP server config is in settings.json
const output = await rig.run('add 5 and 10');
test('should add two numbers', () => {
rig.setup('should add two numbers');
const output = rig.run('add 5 and 10');
assert.ok(output.includes('15'));
const foundToolCall = await rig.waitForToolCall('add');
assert.ok(foundToolCall, 'Expected to find an add tool call');
// Validate model output - will throw if no output, fail if missing expected content
validateModelOutput(output, '15', 'MCP server test');
assert.ok(output.includes('15'), 'Expected output to contain the sum (15)');
});
});

View File

@@ -4,11 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'child_process';
import { execSync, spawn } from 'child_process';
import { parse } from 'shell-quote';
import { mkdirSync, writeFileSync, readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { env } from 'process';
import { fileExists } from '../scripts/telemetry_utils.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -19,17 +21,129 @@ function sanitizeTestName(name) {
.replace(/-+/g, '-');
}
// Helper to create detailed error messages
export function createToolCallErrorMessage(expectedTools, foundTools, result) {
const expectedStr = Array.isArray(expectedTools)
? expectedTools.join(' or ')
: expectedTools;
return (
`Expected to find ${expectedStr} tool call(s). ` +
`Found: ${foundTools.length > 0 ? foundTools.join(', ') : 'none'}. ` +
`Output preview: ${result ? result.substring(0, 200) + '...' : 'no output'}`
);
}
// Helper to print debug information when tests fail
export function printDebugInfo(rig, result, context = {}) {
console.error('Test failed - Debug info:');
console.error('Result length:', result.length);
console.error('Result (first 500 chars):', result.substring(0, 500));
console.error(
'Result (last 500 chars):',
result.substring(result.length - 500),
);
// Print any additional context provided
Object.entries(context).forEach(([key, value]) => {
console.error(`${key}:`, value);
});
// Check what tools were actually called
const allTools = rig.readToolLogs();
console.error(
'All tool calls found:',
allTools.map((t) => t.toolRequest.name),
);
return allTools;
}
// Helper to validate model output and warn about unexpected content
export function validateModelOutput(
result,
expectedContent = null,
testName = '',
) {
// First, check if there's any output at all (this should fail the test if missing)
if (!result || result.trim().length === 0) {
throw new Error('Expected LLM to return some output');
}
// If expectedContent is provided, check for it and warn if missing
if (expectedContent) {
const contents = Array.isArray(expectedContent)
? expectedContent
: [expectedContent];
const missingContent = contents.filter((content) => {
if (typeof content === 'string') {
return !result.toLowerCase().includes(content.toLowerCase());
} else if (content instanceof RegExp) {
return !content.test(result);
}
return false;
});
if (missingContent.length > 0) {
console.warn(
`Warning: LLM did not include expected content in response: ${missingContent.join(', ')}.`,
'This is not ideal but not a test failure.',
);
console.warn(
'The tool was called successfully, which is the main requirement.',
);
return false;
} else if (process.env.VERBOSE === 'true') {
console.log(`${testName}: Model output validated successfully.`);
}
return true;
}
return true;
}
export class TestRig {
constructor() {
this.bundlePath = join(__dirname, '..', 'bundle/gemini.js');
this.testDir = null;
}
setup(testName) {
// Get timeout based on environment
getDefaultTimeout() {
if (env.CI) return 60000; // 1 minute in CI
if (env.GEMINI_SANDBOX) return 30000; // 30s in containers
return 15000; // 15s locally
}
setup(testName, options = {}) {
this.testName = testName;
const sanitizedName = sanitizeTestName(testName);
this.testDir = join(env.INTEGRATION_TEST_FILE_DIR, sanitizedName);
mkdirSync(this.testDir, { recursive: true });
// Create a settings file to point the CLI to the local collector
const geminiDir = join(this.testDir, '.qwen');
mkdirSync(geminiDir, { recursive: true });
// In sandbox mode, use an absolute path for telemetry inside the container
// The container mounts the test directory at the same path as the host
const telemetryPath =
env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false'
? join(this.testDir, 'telemetry.log') // Absolute path in test directory
: env.TELEMETRY_LOG_FILE; // Absolute path for non-sandbox
const settings = {
telemetry: {
enabled: true,
target: 'local',
otlpEndpoint: '',
outfile: telemetryPath,
},
sandbox: env.GEMINI_SANDBOX !== 'false' ? env.GEMINI_SANDBOX : false,
...options.settings, // Allow tests to override/add settings
};
writeFileSync(
join(geminiDir, 'settings.json'),
JSON.stringify(settings, null, 2),
);
}
createFile(fileName, content) {
@@ -39,7 +153,7 @@ export class TestRig {
}
mkdir(dir) {
mkdirSync(join(this.testDir, dir));
mkdirSync(join(this.testDir, dir), { recursive: true });
}
sync() {
@@ -70,19 +184,88 @@ export class TestRig {
command += ` ${args.join(' ')}`;
const output = execSync(command, execOptions);
const commandArgs = parse(command);
const node = commandArgs.shift();
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
const testId = `${env.TEST_FILE_NAME.replace(
'.test.js',
'',
)}:${this.testName.replace(/ /g, '-')}`;
console.log(`--- TEST: ${testId} ---`);
console.log(output);
console.log(`--- END TEST: ${testId} ---`);
const child = spawn(node, commandArgs, {
cwd: this.testDir,
stdio: 'pipe',
});
let stdout = '';
let stderr = '';
// Handle stdin if provided
if (execOptions.input) {
child.stdin.write(execOptions.input);
child.stdin.end();
}
return output;
child.stdout.on('data', (data) => {
stdout += data;
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
process.stdout.write(data);
}
});
child.stderr.on('data', (data) => {
stderr += data;
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
process.stderr.write(data);
}
});
const promise = new Promise((resolve, reject) => {
child.on('close', (code) => {
if (code === 0) {
// Store the raw stdout for Podman telemetry parsing
this._lastRunStdout = stdout;
// Filter out telemetry output when running with Podman
// Podman seems to output telemetry to stdout even when writing to file
let result = stdout;
if (env.GEMINI_SANDBOX === 'podman') {
// Remove telemetry JSON objects from output
// They are multi-line JSON objects that start with { and contain telemetry fields
const lines = result.split('\n');
const filteredLines = [];
let inTelemetryObject = false;
let braceDepth = 0;
for (const line of lines) {
if (!inTelemetryObject && line.trim() === '{') {
// Check if this might be start of telemetry object
inTelemetryObject = true;
braceDepth = 1;
} else if (inTelemetryObject) {
// Count braces to track nesting
for (const char of line) {
if (char === '{') braceDepth++;
else if (char === '}') braceDepth--;
}
// Check if we've closed all braces
if (braceDepth === 0) {
inTelemetryObject = false;
// Skip this line (the closing brace)
continue;
}
} else {
// Not in telemetry object, keep the line
filteredLines.push(line);
}
}
result = filteredLines.join('\n');
}
resolve(result);
} else {
reject(new Error(`Process exited with code ${code}:\n${stderr}`));
}
});
});
return promise;
}
readFile(fileName) {
@@ -98,4 +281,312 @@ export class TestRig {
}
return content;
}
async cleanup() {
// Clean up test directory
if (this.testDir && !env.KEEP_OUTPUT) {
try {
execSync(`rm -rf ${this.testDir}`);
} catch (error) {
// Ignore cleanup errors
if (env.VERBOSE === 'true') {
console.warn('Cleanup warning:', error.message);
}
}
}
}
async waitForTelemetryReady() {
// In sandbox mode, telemetry is written to a relative path in the test directory
const logFilePath =
env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false'
? join(this.testDir, 'telemetry.log')
: env.TELEMETRY_LOG_FILE;
if (!logFilePath) return;
// Wait for telemetry file to exist and have content
await this.poll(
() => {
if (!fileExists(logFilePath)) return false;
try {
const content = readFileSync(logFilePath, 'utf-8');
// Check if file has meaningful content (at least one complete JSON object)
return content.includes('"event.name"');
} catch (_e) {
return false;
}
},
2000, // 2 seconds max - reduced since telemetry should flush on exit now
100, // check every 100ms
);
}
async waitForToolCall(toolName, timeout) {
// Use environment-specific timeout
if (!timeout) {
timeout = this.getDefaultTimeout();
}
// Wait for telemetry to be ready before polling for tool calls
await this.waitForTelemetryReady();
return this.poll(
() => {
const toolLogs = this.readToolLogs();
return toolLogs.some((log) => log.toolRequest.name === toolName);
},
timeout,
100,
);
}
async waitForAnyToolCall(toolNames, timeout) {
// Use environment-specific timeout
if (!timeout) {
timeout = this.getDefaultTimeout();
}
// Wait for telemetry to be ready before polling for tool calls
await this.waitForTelemetryReady();
return this.poll(
() => {
const toolLogs = this.readToolLogs();
return toolNames.some((name) =>
toolLogs.some((log) => log.toolRequest.name === name),
);
},
timeout,
100,
);
}
async poll(predicate, timeout, interval) {
const startTime = Date.now();
let attempts = 0;
while (Date.now() - startTime < timeout) {
attempts++;
const result = predicate();
if (env.VERBOSE === 'true' && attempts % 5 === 0) {
console.log(
`Poll attempt ${attempts}: ${result ? 'success' : 'waiting...'}`,
);
}
if (result) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, interval));
}
if (env.VERBOSE === 'true') {
console.log(`Poll timed out after ${attempts} attempts`);
}
return false;
}
_parseToolLogsFromStdout(stdout) {
const logs = [];
// The console output from Podman is JavaScript object notation, not JSON
// Look for tool call events in the output
// Updated regex to handle tool names with hyphens and underscores
const toolCallPattern =
/body:\s*'Tool call:\s*([\w-]+)\..*?Success:\s*(\w+)\..*?Duration:\s*(\d+)ms\.'/g;
const matches = [...stdout.matchAll(toolCallPattern)];
for (const match of matches) {
const toolName = match[1];
const success = match[2] === 'true';
const duration = parseInt(match[3], 10);
// Try to find function_args nearby
const matchIndex = match.index || 0;
const contextStart = Math.max(0, matchIndex - 500);
const contextEnd = Math.min(stdout.length, matchIndex + 500);
const context = stdout.substring(contextStart, contextEnd);
// Look for function_args in the context
let args = '{}';
const argsMatch = context.match(/function_args:\s*'([^']+)'/);
if (argsMatch) {
args = argsMatch[1];
}
// Also try to find function_name to double-check
// Updated regex to handle tool names with hyphens and underscores
const nameMatch = context.match(/function_name:\s*'([\w-]+)'/);
const actualToolName = nameMatch ? nameMatch[1] : toolName;
logs.push({
timestamp: Date.now(),
toolRequest: {
name: actualToolName,
args: args,
success: success,
duration_ms: duration,
},
});
}
// If no matches found with the simple pattern, try the JSON parsing approach
// in case the format changes
if (logs.length === 0) {
const lines = stdout.split('\n');
let currentObject = '';
let inObject = false;
let braceDepth = 0;
for (const line of lines) {
if (!inObject && line.trim() === '{') {
inObject = true;
braceDepth = 1;
currentObject = line + '\n';
} else if (inObject) {
currentObject += line + '\n';
// Count braces
for (const char of line) {
if (char === '{') braceDepth++;
else if (char === '}') braceDepth--;
}
// If we've closed all braces, try to parse the object
if (braceDepth === 0) {
inObject = false;
try {
const obj = JSON.parse(currentObject);
// Check for tool call in different formats
if (
obj.body &&
obj.body.includes('Tool call:') &&
obj.attributes
) {
const bodyMatch = obj.body.match(/Tool call: (\w+)\./);
if (bodyMatch) {
logs.push({
timestamp: obj.timestamp || Date.now(),
toolRequest: {
name: bodyMatch[1],
args: obj.attributes.function_args || '{}',
success: obj.attributes.success !== false,
duration_ms: obj.attributes.duration_ms || 0,
},
});
}
} else if (
obj.attributes &&
obj.attributes['event.name'] === 'gemini_cli.tool_call'
) {
logs.push({
timestamp: obj.attributes['event.timestamp'],
toolRequest: {
name: obj.attributes.function_name,
args: obj.attributes.function_args,
success: obj.attributes.success,
duration_ms: obj.attributes.duration_ms,
},
});
}
} catch (_e) {
// Not valid JSON
}
currentObject = '';
}
}
}
}
return logs;
}
readToolLogs() {
// For Podman, first check if telemetry file exists and has content
// If not, fall back to parsing from stdout
if (env.GEMINI_SANDBOX === 'podman') {
// Try reading from file first
const logFilePath = join(this.testDir, 'telemetry.log');
if (fileExists(logFilePath)) {
try {
const content = readFileSync(logFilePath, 'utf-8');
if (content && content.includes('"event.name"')) {
// File has content, use normal file parsing
// Continue to the normal file parsing logic below
} else if (this._lastRunStdout) {
// File exists but is empty or doesn't have events, parse from stdout
return this._parseToolLogsFromStdout(this._lastRunStdout);
}
} catch (_e) {
// Error reading file, fall back to stdout
if (this._lastRunStdout) {
return this._parseToolLogsFromStdout(this._lastRunStdout);
}
}
} else if (this._lastRunStdout) {
// No file exists, parse from stdout
return this._parseToolLogsFromStdout(this._lastRunStdout);
}
}
// In sandbox mode, telemetry is written to a relative path in the test directory
const logFilePath =
env.GEMINI_SANDBOX && env.GEMINI_SANDBOX !== 'false'
? join(this.testDir, 'telemetry.log')
: env.TELEMETRY_LOG_FILE;
if (!logFilePath) {
console.warn(`TELEMETRY_LOG_FILE environment variable not set`);
return [];
}
// Check if file exists, if not return empty array (file might not be created yet)
if (!fileExists(logFilePath)) {
return [];
}
const content = readFileSync(logFilePath, 'utf-8');
// Split the content into individual JSON objects
// They are separated by "}\n{" pattern
const jsonObjects = content
.split(/}\s*\n\s*{/)
.map((obj, index, array) => {
// Add back the braces we removed during split
if (index > 0) obj = '{' + obj;
if (index < array.length - 1) obj = obj + '}';
return obj.trim();
})
.filter((obj) => obj);
const logs = [];
for (const jsonStr of jsonObjects) {
try {
const logData = JSON.parse(jsonStr);
// Look for tool call logs
if (
logData.attributes &&
logData.attributes['event.name'] === 'qwen-code.tool_call'
) {
const toolName = logData.attributes.function_name;
logs.push({
toolRequest: {
name: toolName,
args: logData.attributes.function_args,
success: logData.attributes.success,
duration_ms: logData.attributes.duration_ms,
},
});
}
} catch (_e) {
// Skip objects that aren't valid JSON
if (env.VERBOSE === 'true') {
console.error('Failed to parse telemetry object:', _e.message);
}
}
}
return logs;
}
}

View File

@@ -6,16 +6,63 @@
import { test } from 'node:test';
import { strict as assert } from 'assert';
import { TestRig } from './test-helper.js';
import {
TestRig,
createToolCallErrorMessage,
printDebugInfo,
validateModelOutput,
} from './test-helper.js';
test('should be able to write a file', async (t) => {
test('should be able to write a file', async () => {
const rig = new TestRig();
rig.setup(t.name);
await rig.setup('should be able to write a file');
const prompt = `show me an example of using the write tool. put a dad joke in dad.txt`;
await rig.run(prompt);
const result = await rig.run(prompt);
const foundToolCall = await rig.waitForToolCall('write_file');
// Add debugging information
if (!foundToolCall) {
printDebugInfo(rig, result);
}
const allTools = rig.readToolLogs();
assert.ok(
foundToolCall,
createToolCallErrorMessage(
'write_file',
allTools.map((t) => t.toolRequest.name),
result,
),
);
// Validate model output - will throw if no output, warn if missing expected content
validateModelOutput(result, 'dad.txt', 'Write file test');
const newFilePath = 'dad.txt';
const newFileContent = rig.readFile(newFilePath);
assert.notEqual(newFileContent, '');
// Add debugging for file content
if (newFileContent === '') {
console.error('File was created but is empty');
console.error(
'Tool calls:',
rig.readToolLogs().map((t) => ({
name: t.toolRequest.name,
args: t.toolRequest.args,
})),
);
}
assert.notEqual(newFileContent, '', 'Expected file to have content');
// Log success info if verbose
if (process.env.VERBOSE === 'true') {
console.log(
'File created successfully with content:',
newFileContent.substring(0, 100) + '...',
);
}
});

49
package-lock.json generated
View File

@@ -1,22 +1,20 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.3",
"version": "0.0.5-nightly.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.0.3",
"version": "0.0.5-nightly.3",
"workspaces": [
"packages/*"
],
"dependencies": {
"tiktoken": "^1.0.21"
},
"bin": {
"qwen": "bundle/gemini.js"
},
"devDependencies": {
"@types/marked": "^5.0.2",
"@types/micromatch": "^4.0.9",
"@types/mime-types": "^3.0.1",
"@types/minimatch": "^5.1.2",
@@ -2426,6 +2424,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/marked": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz",
"integrity": "sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/micromatch": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.9.tgz",
@@ -7735,6 +7740,31 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/marked": {
"version": "15.0.12",
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -11771,7 +11801,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.0.3",
"version": "0.0.5-nightly.3",
"dependencies": {
"@google/genai": "1.9.0",
"@iarna/toml": "^2.2.5",
@@ -11797,6 +11827,7 @@
"string-width": "^7.1.0",
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
"tiktoken": "^1.0.21",
"update-notifier": "^7.3.1",
"yargs": "^17.7.2",
"zod": "^3.23.8"
@@ -11846,7 +11877,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.0.3",
"version": "0.0.5-nightly.3",
"dependencies": {
"@google/genai": "1.9.0",
"@modelcontextprotocol/sdk": "^1.11.0",
@@ -11866,12 +11897,14 @@
"html-to-text": "^9.0.5",
"https-proxy-agent": "^7.0.6",
"ignore": "^7.0.0",
"marked": "^15.0.12",
"micromatch": "^4.0.8",
"open": "^10.1.2",
"openai": "^5.7.0",
"shell-quote": "^1.8.3",
"simple-git": "^3.28.0",
"strip-ansi": "^7.1.0",
"tiktoken": "^1.0.21",
"undici": "^7.10.0",
"ws": "^8.18.0"
},
@@ -11912,7 +11945,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.0.3",
"version": "0.0.4",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.3",
"version": "0.0.5-nightly.3",
"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.3"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.5-nightly.3"
},
"scripts": {
"start": "node scripts/start.js",
@@ -57,6 +57,7 @@
"LICENSE"
],
"devDependencies": {
"@types/marked": "^5.0.2",
"@types/micromatch": "^4.0.9",
"@types/mime-types": "^3.0.1",
"@types/minimatch": "^5.1.2",
@@ -84,8 +85,5 @@
"typescript-eslint": "^8.30.1",
"vitest": "^3.2.4",
"yargs": "^17.7.2"
},
"dependencies": {
"tiktoken": "^1.0.21"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.3",
"version": "0.0.5-nightly.3",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.3"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.5-nightly.3"
},
"dependencies": {
"@qwen-code/qwen-code-core": "file:../core",
@@ -54,7 +54,8 @@
"strip-json-comments": "^3.1.1",
"update-notifier": "^7.3.1",
"yargs": "^17.7.2",
"zod": "^3.23.8"
"zod": "^3.23.8",
"tiktoken": "^1.0.21"
},
"devDependencies": {
"@babel/runtime": "^7.27.6",

View File

@@ -37,7 +37,7 @@ describe('Configuration Integration Tests', () => {
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(tmpdir(), 'gemini-cli-test-'));
tempDir = fs.mkdtempSync(path.join(tmpdir(), 'qwen-code-test-'));
originalEnv = { ...process.env };
process.env.GEMINI_API_KEY = 'test-api-key';
vi.clearAllMocks();

View File

@@ -35,6 +35,13 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
);
return {
...actualServer,
IdeClient: {
getInstance: vi.fn().mockReturnValue({
getConnectionStatus: vi.fn(),
initialize: vi.fn(),
shutdown: vi.fn(),
}),
},
loadEnvironment: vi.fn(),
loadServerHierarchicalMemory: vi.fn(
(cwd, debug, fileService, extensionPaths, _maxDirs) =>
@@ -499,6 +506,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
'/path/to/ext3/context1.md',
'/path/to/ext3/context2.md',
],
'tree',
{
respectGitIgnore: false,
respectGeminiIgnore: true,
@@ -983,7 +991,69 @@ describe('loadCliConfig extensions', () => {
});
});
describe('loadCliConfig ideMode', () => {
describe('loadCliConfig model selection', () => {
it('selects a model from settings.json if provided', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const config = await loadCliConfig(
{
model: 'qwen3-coder-plus',
},
[],
'test-session',
argv,
);
expect(config.getModel()).toBe('qwen3-coder-plus');
});
it('uses the default gemini model if nothing is set', async () => {
process.argv = ['node', 'script.js']; // No model set.
const argv = await parseArguments();
const config = await loadCliConfig(
{
// No model set.
},
[],
'test-session',
argv,
);
expect(config.getModel()).toBe('qwen3-coder-plus');
});
it('always prefers model from argvs', async () => {
process.argv = ['node', 'script.js', '--model', 'qwen3-coder-plus'];
const argv = await parseArguments();
const config = await loadCliConfig(
{
model: 'qwen3-coder-plus',
},
[],
'test-session',
argv,
);
expect(config.getModel()).toBe('qwen3-coder-plus');
});
it('selects the model from argvs if provided', async () => {
process.argv = ['node', 'script.js', '--model', 'qwen3-coder-plus'];
const argv = await parseArguments();
const config = await loadCliConfig(
{
// No model provided via settings.
},
[],
'test-session',
argv,
);
expect(config.getModel()).toBe('qwen3-coder-plus');
});
});
describe('loadCliConfig ideModeFeature', () => {
const originalArgv = process.argv;
const originalEnv = { ...process.env };
@@ -991,8 +1061,6 @@ describe('loadCliConfig ideMode', () => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
process.env.GEMINI_API_KEY = 'test-api-key';
// Explicitly delete TERM_PROGRAM and SANDBOX before each test
delete process.env.TERM_PROGRAM;
delete process.env.SANDBOX;
delete process.env.GEMINI_CLI_IDE_SERVER_PORT;
});
@@ -1008,81 +1076,16 @@ describe('loadCliConfig ideMode', () => {
const settings: Settings = {};
const argv = await parseArguments();
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(false);
expect(config.getIdeModeFeature()).toBe(false);
});
it('should be false if --ide-mode is true but TERM_PROGRAM is not vscode', async () => {
process.argv = ['node', 'script.js', '--ide-mode'];
const settings: Settings = {};
const argv = await parseArguments();
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(false);
});
it('should be false if settings.ideMode is true but TERM_PROGRAM is not vscode', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = { ideMode: true };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(false);
});
it('should be true when --ide-mode is set and TERM_PROGRAM is vscode', async () => {
process.argv = ['node', 'script.js', '--ide-mode'];
const argv = await parseArguments();
process.env.TERM_PROGRAM = 'vscode';
process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000';
const settings: Settings = {};
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(true);
});
it('should be true when settings.ideMode is true and TERM_PROGRAM is vscode', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
process.env.TERM_PROGRAM = 'vscode';
process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000';
const settings: Settings = { ideMode: true };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(true);
});
it('should prioritize --ide-mode (true) over settings (false) when TERM_PROGRAM is vscode', async () => {
process.argv = ['node', 'script.js', '--ide-mode'];
const argv = await parseArguments();
process.env.TERM_PROGRAM = 'vscode';
process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000';
const settings: Settings = { ideMode: false };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(true);
});
it('should prioritize --no-ide-mode (false) over settings (true) even when TERM_PROGRAM is vscode', async () => {
process.argv = ['node', 'script.js', '--no-ide-mode'];
const argv = await parseArguments();
process.env.TERM_PROGRAM = 'vscode';
const settings: Settings = { ideMode: true };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(false);
});
it('should be false when --ide-mode is true, TERM_PROGRAM is vscode, but SANDBOX is set', async () => {
process.argv = ['node', 'script.js', '--ide-mode'];
const argv = await parseArguments();
process.env.TERM_PROGRAM = 'vscode';
process.env.SANDBOX = 'true';
const settings: Settings = {};
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(false);
});
it('should be false when settings.ideMode is true, TERM_PROGRAM is vscode, but SANDBOX is set', async () => {
it('should be false when settings.ideModeFeature is true, but SANDBOX is set', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
process.env.TERM_PROGRAM = 'vscode';
process.env.SANDBOX = 'true';
const settings: Settings = { ideMode: true };
const settings: Settings = { ideModeFeature: true };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeMode()).toBe(false);
expect(config.getIdeModeFeature()).toBe(false);
});
});

View File

@@ -4,6 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'fs';
import * as path from 'path';
import { homedir } from 'node:os';
import yargs from 'yargs/yargs';
import { hideBin } from 'yargs/helpers';
import process from 'node:process';
@@ -59,11 +62,12 @@ export interface CliArgs {
experimentalAcp: boolean | undefined;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
ideMode: boolean | undefined;
ideModeFeature: boolean | undefined;
openaiLogging: boolean | undefined;
openaiApiKey: string | undefined;
openaiBaseUrl: string | undefined;
proxy: string | undefined;
includeDirectories: string[] | undefined;
}
export async function parseArguments(): Promise<CliArgs> {
@@ -77,7 +81,7 @@ export async function parseArguments(): Promise<CliArgs> {
alias: 'm',
type: 'string',
description: `Model`,
default: process.env.GEMINI_MODEL || DEFAULT_GEMINI_MODEL,
default: process.env.GEMINI_MODEL,
})
.option('prompt', {
alias: 'p',
@@ -193,7 +197,7 @@ export async function parseArguments(): Promise<CliArgs> {
type: 'boolean',
description: 'List all available extensions and exit.',
})
.option('ide-mode', {
.option('ide-mode-feature', {
type: 'boolean',
description: 'Run in IDE mode?',
})
@@ -215,6 +219,15 @@ export async function parseArguments(): Promise<CliArgs> {
description:
'Proxy for gemini client, like schema://user:password@host:port',
})
.option('include-directories', {
type: 'array',
string: true,
description:
'Additional directories to include in the workspace (comma-separated or multiple --include-directories)',
coerce: (dirs: string[]) =>
// Handle comma-separated values
dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())),
})
.version(await getCliVersion()) // This will enable the --version flag based on package.json
.alias('v', 'version')
.help()
@@ -230,7 +243,11 @@ export async function parseArguments(): Promise<CliArgs> {
});
yargsInstance.wrap(yargsInstance.terminalWidth());
return yargsInstance.argv;
const result = yargsInstance.parseSync();
// The import format is now only controlled by settings.memoryImportFormat
// We no longer accept it as a CLI argument
return result as CliArgs;
}
// This function is now a thin wrapper around the server's implementation.
@@ -242,21 +259,31 @@ export async function loadHierarchicalGeminiMemory(
fileService: FileDiscoveryService,
settings: Settings,
extensionContextFilePaths: string[] = [],
memoryImportFormat: 'flat' | 'tree' = 'tree',
fileFilteringOptions?: FileFilteringOptions,
): Promise<{ memoryContent: string; fileCount: number }> {
// FIX: Use real, canonical paths for a reliable comparison to handle symlinks.
const realCwd = fs.realpathSync(path.resolve(currentWorkingDirectory));
const realHome = fs.realpathSync(path.resolve(homedir()));
const isHomeDirectory = realCwd === realHome;
// If it is the home directory, pass an empty string to the core memory
// function to signal that it should skip the workspace search.
const effectiveCwd = isHomeDirectory ? '' : currentWorkingDirectory;
if (debugMode) {
logger.debug(
`CLI: Delegating hierarchical memory load to server for CWD: ${currentWorkingDirectory}`,
`CLI: Delegating hierarchical memory load to server for CWD: ${currentWorkingDirectory} (memoryImportFormat: ${memoryImportFormat})`,
);
}
// Directly call the server function.
// The server function will use its own homedir() for the global path.
// Directly call the server function with the corrected path.
return loadServerHierarchicalMemory(
currentWorkingDirectory,
effectiveCwd,
debugMode,
fileService,
extensionContextFilePaths,
memoryImportFormat,
fileFilteringOptions,
settings.memoryDiscoveryMaxDirs,
);
@@ -272,17 +299,16 @@ export async function loadCliConfig(
argv.debug ||
[process.env.DEBUG, process.env.DEBUG_MODE].some(
(v) => v === 'true' || v === '1',
);
) ||
false;
const memoryImportFormat = settings.memoryImportFormat || 'tree';
const ideMode = settings.ideMode ?? false;
const ideMode =
(argv.ideMode ?? settings.ideMode ?? false) &&
process.env.TERM_PROGRAM === 'vscode' &&
const ideModeFeature =
(argv.ideModeFeature ?? settings.ideModeFeature ?? false) &&
!process.env.SANDBOX;
let ideClient: IdeClient | undefined;
if (ideMode) {
ideClient = new IdeClient();
}
const ideClient = IdeClient.getInstance(ideMode && ideModeFeature);
const allExtensions = annotateActiveExtensions(
extensions,
@@ -331,6 +357,7 @@ export async function loadCliConfig(
fileService,
settings,
extensionContextFilePaths,
memoryImportFormat,
fileFiltering,
);
@@ -391,6 +418,7 @@ export async function loadCliConfig(
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: sandboxConfig,
targetDir: process.cwd(),
includeDirectories: argv.includeDirectories,
debugMode,
question: argv.promptInteractive || argv.prompt || '',
fullContext: argv.allFiles || argv.all_files || false,
@@ -438,7 +466,7 @@ export async function loadCliConfig(
cwd: process.cwd(),
fileDiscoveryService: fileService,
bugCommand: settings.bugCommand,
model: argv.model!,
model: argv.model || settings.model || DEFAULT_GEMINI_MODEL,
extensionContextFilePaths,
maxSessionTurns: settings.maxSessionTurns ?? -1,
sessionTokenLimit: settings.sessionTokenLimit ?? 32000,
@@ -450,6 +478,7 @@ export async function loadCliConfig(
noBrowser: !!process.env.NO_BROWSER,
summarizeToolOutput: settings.summarizeToolOutput,
ideMode,
ideModeFeature,
ideClient,
enableOpenAILogging:
(typeof argv.openaiLogging === 'undefined'

View File

@@ -29,10 +29,10 @@ describe('loadExtensions', () => {
beforeEach(() => {
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
path.join(os.tmpdir(), 'qwen-code-test-workspace-'),
);
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
path.join(os.tmpdir(), 'qwen-code-test-home-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
});
@@ -42,6 +42,81 @@ describe('loadExtensions', () => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
});
it('should include extension path in loaded extension', () => {
const workspaceExtensionsDir = path.join(
tempWorkspaceDir,
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
const extensionDir = path.join(workspaceExtensionsDir, 'test-extension');
fs.mkdirSync(extensionDir, { recursive: true });
const config = {
name: 'test-extension',
version: '1.0.0',
};
fs.writeFileSync(
path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify(config),
);
const extensions = loadExtensions(tempWorkspaceDir);
expect(extensions).toHaveLength(1);
expect(extensions[0].path).toBe(extensionDir);
expect(extensions[0].config.name).toBe('test-extension');
});
it('should include extension path in loaded extension', () => {
const workspaceExtensionsDir = path.join(
tempWorkspaceDir,
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
const extensionDir = path.join(workspaceExtensionsDir, 'test-extension');
fs.mkdirSync(extensionDir, { recursive: true });
const config = {
name: 'test-extension',
version: '1.0.0',
};
fs.writeFileSync(
path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify(config),
);
const extensions = loadExtensions(tempWorkspaceDir);
expect(extensions).toHaveLength(1);
expect(extensions[0].path).toBe(extensionDir);
expect(extensions[0].config.name).toBe('test-extension');
});
it('should include extension path in loaded extension', () => {
const workspaceExtensionsDir = path.join(
tempWorkspaceDir,
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(workspaceExtensionsDir, { recursive: true });
const extensionDir = path.join(workspaceExtensionsDir, 'test-extension');
fs.mkdirSync(extensionDir, { recursive: true });
const config = {
name: 'test-extension',
version: '1.0.0',
};
fs.writeFileSync(
path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify(config),
);
const extensions = loadExtensions(tempWorkspaceDir);
expect(extensions).toHaveLength(1);
expect(extensions[0].path).toBe(extensionDir);
expect(extensions[0].config.name).toBe('test-extension');
});
it('should load context file path when QWEN.md is present', () => {
const workspaceExtensionsDir = path.join(
tempWorkspaceDir,

View File

@@ -13,6 +13,7 @@ export const EXTENSIONS_DIRECTORY_NAME = path.join('.qwen', 'extensions');
export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
export interface Extension {
path: string;
config: ExtensionConfig;
contextFiles: string[];
}
@@ -90,6 +91,7 @@ function loadExtension(extensionDir: string): Extension | null {
.filter((contextFilePath) => fs.existsSync(contextFilePath));
return {
path: extensionDir,
config,
contextFiles,
};
@@ -121,6 +123,7 @@ export function annotateActiveExtensions(
name: extension.config.name,
version: extension.config.version,
isActive: true,
path: extension.path,
}));
}
@@ -136,6 +139,7 @@ export function annotateActiveExtensions(
name: extension.config.name,
version: extension.config.version,
isActive: false,
path: extension.path,
}));
}
@@ -153,6 +157,7 @@ export function annotateActiveExtensions(
name: extension.config.name,
version: extension.config.version,
isActive,
path: extension.path,
});
}

View File

@@ -59,7 +59,21 @@ const MOCK_WORKSPACE_SETTINGS_PATH = pathActual.join(
'settings.json',
);
vi.mock('fs');
vi.mock('fs', async (importOriginal) => {
// Get all the functions from the real 'fs' module
const actualFs = await importOriginal<typeof fs>();
return {
...actualFs, // Keep all the real functions
// Now, just override the ones we need for the test
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
realpathSync: (p: string) => p,
};
});
vi.mock('strip-json-comments', () => ({
default: vi.fn((content) => content),
}));
@@ -320,6 +334,86 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged.contextFileName).toBe('PROJECT_SPECIFIC.md');
});
it('should handle excludedProjectEnvVars correctly when only in user settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'CUSTOM_VAR'],
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.excludedProjectEnvVars).toEqual([
'DEBUG',
'NODE_ENV',
'CUSTOM_VAR',
]);
});
it('should handle excludedProjectEnvVars correctly when only in workspace settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
);
const workspaceSettingsContent = {
excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'],
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.excludedProjectEnvVars).toEqual([
'WORKSPACE_DEBUG',
'WORKSPACE_VAR',
]);
});
it('should merge excludedProjectEnvVars with workspace taking precedence over user', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'USER_VAR'],
};
const workspaceSettingsContent = {
excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'],
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
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.user.settings.excludedProjectEnvVars).toEqual([
'DEBUG',
'NODE_ENV',
'USER_VAR',
]);
expect(settings.workspace.settings.excludedProjectEnvVars).toEqual([
'WORKSPACE_DEBUG',
'WORKSPACE_VAR',
]);
expect(settings.merged.excludedProjectEnvVars).toEqual([
'WORKSPACE_DEBUG',
'WORKSPACE_VAR',
]);
});
it('should default contextFileName to undefined if not in any settings file', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = { theme: 'dark' };
@@ -777,6 +871,48 @@ describe('Settings Loading and Merging', () => {
}
});
it('should correctly merge dnsResolutionOrder with workspace taking precedence', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
dnsResolutionOrder: 'ipv4first',
};
const workspaceSettingsContent = {
dnsResolutionOrder: 'verbatim',
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
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.dnsResolutionOrder).toBe('verbatim');
});
it('should use user dnsResolutionOrder if workspace is not defined', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
dnsResolutionOrder: 'verbatim',
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.dnsResolutionOrder).toBe('verbatim');
});
it('should leave unresolved environment variables as is', () => {
const userSettingsContent = { apiKey: '$UNDEFINED_VAR' };
(mockFsExistsSync as Mock).mockImplementation(
@@ -999,4 +1135,140 @@ describe('Settings Loading and Merging', () => {
expect(loadedSettings.merged.theme).toBe('ocean');
});
});
describe('excludedProjectEnvVars integration', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('should exclude DEBUG and DEBUG_MODE from project .env files by default', () => {
// Create a workspace settings file with excludedProjectEnvVars
const workspaceSettingsContent = {
excludedProjectEnvVars: ['DEBUG', 'DEBUG_MODE'],
};
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
// Mock findEnvFile to return a project .env file
const originalFindEnvFile = (
loadSettings as unknown as { findEnvFile: () => string }
).findEnvFile;
(loadSettings as unknown as { findEnvFile: () => string }).findEnvFile =
() => '/mock/project/.env';
// Mock fs.readFileSync for .env file content
const originalReadFileSync = fs.readFileSync;
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === '/mock/project/.env') {
return 'DEBUG=true\nDEBUG_MODE=1\nGEMINI_API_KEY=test-key';
}
if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
return JSON.stringify(workspaceSettingsContent);
}
return '{}';
},
);
try {
// This will call loadEnvironment internally with the merged settings
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// Verify the settings were loaded correctly
expect(settings.merged.excludedProjectEnvVars).toEqual([
'DEBUG',
'DEBUG_MODE',
]);
// Note: We can't directly test process.env changes here because the mocking
// prevents the actual file system operations, but we can verify the settings
// are correctly merged and passed to loadEnvironment
} finally {
(loadSettings as unknown as { findEnvFile: () => string }).findEnvFile =
originalFindEnvFile;
(fs.readFileSync as Mock).mockImplementation(originalReadFileSync);
}
});
it('should respect custom excludedProjectEnvVars from user settings', () => {
const userSettingsContent = {
excludedProjectEnvVars: ['NODE_ENV', 'DEBUG'],
};
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.user.settings.excludedProjectEnvVars).toEqual([
'NODE_ENV',
'DEBUG',
]);
expect(settings.merged.excludedProjectEnvVars).toEqual([
'NODE_ENV',
'DEBUG',
]);
});
it('should merge excludedProjectEnvVars with workspace taking precedence', () => {
const userSettingsContent = {
excludedProjectEnvVars: ['DEBUG', 'NODE_ENV', 'USER_VAR'],
};
const workspaceSettingsContent = {
excludedProjectEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'],
};
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
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.user.settings.excludedProjectEnvVars).toEqual([
'DEBUG',
'NODE_ENV',
'USER_VAR',
]);
expect(settings.workspace.settings.excludedProjectEnvVars).toEqual([
'WORKSPACE_DEBUG',
'WORKSPACE_VAR',
]);
expect(settings.merged.excludedProjectEnvVars).toEqual([
'WORKSPACE_DEBUG',
'WORKSPACE_VAR',
]);
});
});
});

View File

@@ -24,6 +24,7 @@ import { CustomTheme } from '../ui/themes/theme.js';
export const SETTINGS_DIRECTORY_NAME = '.qwen';
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json');
export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
export function getSystemSettingsPath(): string {
if (process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH) {
@@ -38,6 +39,12 @@ export function getSystemSettingsPath(): string {
}
}
export function getWorkspaceSettingsPath(workspaceDir: string): string {
return path.join(workspaceDir, SETTINGS_DIRECTORY_NAME, 'settings.json');
}
export type DnsResolutionOrder = 'ipv4first' | 'verbatim';
export enum SettingScope {
User = 'User',
Workspace = 'Workspace',
@@ -60,6 +67,7 @@ export interface Settings {
theme?: string;
customThemes?: Record<string, CustomTheme>;
selectedAuthType?: AuthType;
useExternalAuth?: boolean;
sandbox?: boolean | string;
coreTools?: string[];
excludeTools?: string[];
@@ -78,6 +86,8 @@ export interface Settings {
bugCommand?: BugCommandSettings;
checkpointing?: CheckpointingSettings;
autoConfigureMaxOldSpaceSize?: boolean;
/** The model name to use (e.g 'gemini-9.0-pro') */
model?: string;
enableOpenAILogging?: boolean;
// Git-aware file filtering settings
@@ -105,10 +115,23 @@ export interface Settings {
summarizeToolOutput?: Record<string, SummarizeToolOutputSettings>;
vimMode?: boolean;
memoryImportFormat?: 'tree' | 'flat';
// Add other settings here.
// Flag to be removed post-launch.
ideModeFeature?: boolean;
/// IDE mode setting configured via slash command toggle.
ideMode?: boolean;
// Setting for disabling auto-update.
disableAutoUpdate?: boolean;
// Setting for disabling the update nag message.
disableUpdateNag?: boolean;
memoryDiscoveryMaxDirs?: number;
// Environment variables to exclude from project .env files
excludedProjectEnvVars?: string[];
dnsResolutionOrder?: DnsResolutionOrder;
sampling_params?: Record<string, unknown>;
systemPromptMappings?: Array<{
baseUrls: string[];
@@ -295,15 +318,61 @@ export function setUpCloudShellEnvironment(envFilePath: string | null): void {
}
}
export function loadEnvironment(): void {
export function loadEnvironment(settings?: Settings): void {
const envFilePath = findEnvFile(process.cwd());
// Cloud Shell environment variable handling
if (process.env.CLOUD_SHELL === 'true') {
setUpCloudShellEnvironment(envFilePath);
}
// If no settings provided, try to load workspace settings for exclusions
let resolvedSettings = settings;
if (!resolvedSettings) {
const workspaceSettingsPath = getWorkspaceSettingsPath(process.cwd());
try {
if (fs.existsSync(workspaceSettingsPath)) {
const workspaceContent = fs.readFileSync(
workspaceSettingsPath,
'utf-8',
);
const parsedWorkspaceSettings = JSON.parse(
stripJsonComments(workspaceContent),
) as Settings;
resolvedSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
}
} catch (_e) {
// Ignore errors loading workspace settings
}
}
if (envFilePath) {
dotenv.config({ path: envFilePath, quiet: true });
// Manually parse and load environment variables to handle exclusions correctly.
// This avoids modifying environment variables that were already set from the shell.
try {
const envFileContent = fs.readFileSync(envFilePath, 'utf-8');
const parsedEnv = dotenv.parse(envFileContent);
const excludedVars =
resolvedSettings?.excludedProjectEnvVars || DEFAULT_EXCLUDED_ENV_VARS;
const isProjectEnvFile = !envFilePath.includes(GEMINI_DIR);
for (const key in parsedEnv) {
if (Object.hasOwn(parsedEnv, key)) {
// If it's a project .env file, skip loading excluded variables.
if (isProjectEnvFile && excludedVars.includes(key)) {
continue;
}
// Load variable only if it's not already set in the environment.
if (!Object.hasOwn(process.env, key)) {
process.env[key] = parsedEnv[key];
}
}
}
} catch (_e) {
// Errors are ignored to match the behavior of `dotenv.config({ quiet: true })`.
}
}
}
@@ -312,12 +381,29 @@ export function loadEnvironment(): void {
* Project settings override user settings.
*/
export function loadSettings(workspaceDir: string): LoadedSettings {
loadEnvironment();
let systemSettings: Settings = {};
let userSettings: Settings = {};
let workspaceSettings: Settings = {};
const settingsErrors: SettingsError[] = [];
const systemSettingsPath = getSystemSettingsPath();
// FIX: Resolve paths to their canonical representation to handle symlinks
const resolvedWorkspaceDir = path.resolve(workspaceDir);
const resolvedHomeDir = path.resolve(homedir());
let realWorkspaceDir = resolvedWorkspaceDir;
try {
// fs.realpathSync gets the "true" path, resolving any symlinks
realWorkspaceDir = fs.realpathSync(resolvedWorkspaceDir);
} catch (_e) {
// This is okay. The path might not exist yet, and that's a valid state.
}
// We expect homedir to always exist and be resolvable.
const realHomeDir = fs.realpathSync(resolvedHomeDir);
const workspaceSettingsPath = getWorkspaceSettingsPath(workspaceDir);
// Load system settings
try {
if (fs.existsSync(systemSettingsPath)) {
@@ -356,37 +442,35 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
});
}
const workspaceSettingsPath = path.join(
workspaceDir,
SETTINGS_DIRECTORY_NAME,
'settings.json',
);
// Load workspace settings
try {
if (fs.existsSync(workspaceSettingsPath)) {
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
const parsedWorkspaceSettings = JSON.parse(
stripJsonComments(projectContent),
) as Settings;
workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
if (workspaceSettings.theme && workspaceSettings.theme === 'VS') {
workspaceSettings.theme = DefaultLight.name;
} else if (
workspaceSettings.theme &&
workspaceSettings.theme === 'VS2015'
) {
workspaceSettings.theme = DefaultDark.name;
// This comparison is now much more reliable.
if (realWorkspaceDir !== realHomeDir) {
// Load workspace settings
try {
if (fs.existsSync(workspaceSettingsPath)) {
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
const parsedWorkspaceSettings = JSON.parse(
stripJsonComments(projectContent),
) as Settings;
workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
if (workspaceSettings.theme && workspaceSettings.theme === 'VS') {
workspaceSettings.theme = DefaultLight.name;
} else if (
workspaceSettings.theme &&
workspaceSettings.theme === 'VS2015'
) {
workspaceSettings.theme = DefaultDark.name;
}
}
} catch (error: unknown) {
settingsErrors.push({
message: getErrorMessage(error),
path: workspaceSettingsPath,
});
}
} catch (error: unknown) {
settingsErrors.push({
message: getErrorMessage(error),
path: workspaceSettingsPath,
});
}
return new LoadedSettings(
// Create LoadedSettings first
const loadedSettings = new LoadedSettings(
{
path: systemSettingsPath,
settings: systemSettings,
@@ -401,6 +485,11 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
},
settingsErrors,
);
// Load environment with merged settings
loadEnvironment(loadedSettings.merged);
return loadedSettings;
}
export function saveSettings(settingsFile: SettingsFile): void {

View File

@@ -6,7 +6,11 @@
import stripAnsi from 'strip-ansi';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { main, setupUnhandledRejectionHandler } from './gemini.js';
import {
main,
setupUnhandledRejectionHandler,
validateDnsResolutionOrder,
} from './gemini.js';
import {
LoadedSettings,
SettingsFile,
@@ -211,3 +215,38 @@ describe('gemini.tsx main function', () => {
processExitSpy.mockRestore();
});
});
describe('validateDnsResolutionOrder', () => {
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
consoleWarnSpy.mockRestore();
});
it('should return "ipv4first" when the input is "ipv4first"', () => {
expect(validateDnsResolutionOrder('ipv4first')).toBe('ipv4first');
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
it('should return "verbatim" when the input is "verbatim"', () => {
expect(validateDnsResolutionOrder('verbatim')).toBe('verbatim');
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
it('should return the default "ipv4first" when the input is undefined', () => {
expect(validateDnsResolutionOrder(undefined)).toBe('ipv4first');
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
it('should return the default "ipv4first" and log a warning for an invalid string', () => {
expect(validateDnsResolutionOrder('invalid-value')).toBe('ipv4first');
expect(consoleWarnSpy).toHaveBeenCalledOnce();
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Invalid value for dnsResolutionOrder in settings: "invalid-value". Using default "ipv4first".',
);
});
});

View File

@@ -12,9 +12,11 @@ import { readStdin } from './utils/readStdin.js';
import { basename } from 'node:path';
import v8 from 'node:v8';
import os from 'node:os';
import dns from 'node:dns';
import { spawn } from 'node:child_process';
import { start_sandbox } from './utils/sandbox.js';
import {
DnsResolutionOrder,
LoadedSettings,
loadSettings,
SettingScope,
@@ -40,8 +42,27 @@ import {
import { validateAuthMethod } from './config/auth.js';
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
import { checkForUpdates } from './ui/utils/updateCheck.js';
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
import { appEvents, AppEvent } from './utils/events.js';
export function validateDnsResolutionOrder(
order: string | undefined,
): DnsResolutionOrder {
const defaultValue: DnsResolutionOrder = 'ipv4first';
if (order === undefined) {
return defaultValue;
}
if (order === 'ipv4first' || order === 'verbatim') {
return order;
}
// We don't want to throw here, just warn and use the default.
console.warn(
`Invalid value for dnsResolutionOrder in settings: "${order}". Using default "${defaultValue}".`,
);
return defaultValue;
}
function getNodeMemoryArgs(config: Config): string[] {
const totalMemoryMB = os.totalmem() / (1024 * 1024);
const heapStats = v8.getHeapStatistics();
@@ -136,6 +157,10 @@ export async function main() {
argv,
);
dns.setDefaultResultOrder(
validateDnsResolutionOrder(settings.merged.dnsResolutionOrder),
);
if (argv.promptInteractive && !process.stdin.isTTY) {
console.error(
'Error: The --prompt-interactive flag is not supported when piping input from stdin.',
@@ -184,7 +209,10 @@ export async function main() {
: [];
const sandboxConfig = config.getSandbox();
if (sandboxConfig) {
if (settings.merged.selectedAuthType) {
if (
settings.merged.selectedAuthType &&
!settings.merged.useExternalAuth
) {
// Validate authentication here because the sandbox will interfere with the Oauth2 web redirect.
try {
const err = validateAuthMethod(settings.merged.selectedAuthType);
@@ -197,7 +225,7 @@ export async function main() {
process.exit(1);
}
}
await start_sandbox(sandboxConfig, memoryArgs);
await start_sandbox(sandboxConfig, memoryArgs, config);
process.exit(0);
} else {
// Not in a sandbox and not entering one, so relaunch with additional
@@ -246,6 +274,17 @@ export async function main() {
{ exitOnCtrlC: false },
);
checkForUpdates()
.then((info) => {
handleAutoUpdate(info, settings, config.getProjectRoot());
})
.catch((err) => {
// Silently ignore update check errors.
if (config.getDebugMode()) {
console.error('Update check failed:', err);
}
});
registerCleanup(() => instance.unmount());
return;
}
@@ -331,6 +370,7 @@ async function loadNonInteractiveConfig(
return await validateNonInteractiveAuth(
settings.merged.selectedAuthType,
settings.merged.useExternalAuth,
finalConfig,
);
}

View File

@@ -4,196 +4,169 @@
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
Config,
executeToolCall,
ToolRegistry,
ToolErrorType,
shutdownTelemetry,
GeminiEventType,
ServerGeminiStreamEvent,
} from '@qwen-code/qwen-code-core';
import { Part } from '@google/genai';
import { runNonInteractive } from './nonInteractiveCli.js';
import { Config, GeminiClient, ToolRegistry } from '@qwen-code/qwen-code-core';
import { GenerateContentResponse, Part, FunctionCall } from '@google/genai';
import { vi } from 'vitest';
// Mock dependencies
vi.mock('@qwen-code/qwen-code-core', async () => {
const actualCore = await vi.importActual<
typeof import('@qwen-code/qwen-code-core')
>('@qwen-code/qwen-code-core');
// Mock core modules
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actualCore,
GeminiClient: vi.fn(),
ToolRegistry: vi.fn(),
...original,
executeToolCall: vi.fn(),
shutdownTelemetry: vi.fn(),
isTelemetrySdkInitialized: vi.fn().mockReturnValue(true),
};
});
describe('runNonInteractive', () => {
let mockConfig: Config;
let mockGeminiClient: GeminiClient;
let mockToolRegistry: ToolRegistry;
let mockChat: {
sendMessageStream: ReturnType<typeof vi.fn>;
let mockCoreExecuteToolCall: vi.Mock;
let mockShutdownTelemetry: vi.Mock;
let consoleErrorSpy: vi.SpyInstance;
let processExitSpy: vi.SpyInstance;
let processStdoutSpy: vi.SpyInstance;
let mockGeminiClient: {
sendMessageStream: vi.Mock;
};
let mockProcessStdoutWrite: ReturnType<typeof vi.fn>;
let mockProcessExit: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.resetAllMocks();
mockChat = {
sendMessageStream: vi.fn(),
};
mockGeminiClient = {
getChat: vi.fn().mockResolvedValue(mockChat),
} as unknown as GeminiClient;
mockCoreExecuteToolCall = vi.mocked(executeToolCall);
mockShutdownTelemetry = vi.mocked(shutdownTelemetry);
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => {}) as (code?: number) => never);
processStdoutSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
mockToolRegistry = {
getFunctionDeclarations: vi.fn().mockReturnValue([]),
getTool: vi.fn(),
getFunctionDeclarations: vi.fn().mockReturnValue([]),
} as unknown as ToolRegistry;
vi.mocked(GeminiClient).mockImplementation(() => mockGeminiClient);
vi.mocked(ToolRegistry).mockImplementation(() => mockToolRegistry);
mockGeminiClient = {
sendMessageStream: vi.fn(),
};
mockConfig = {
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
initialize: vi.fn().mockResolvedValue(undefined),
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getToolRegistry: vi.fn().mockResolvedValue(mockToolRegistry),
getMaxSessionTurns: vi.fn().mockReturnValue(10),
initialize: vi.fn(),
getIdeMode: vi.fn().mockReturnValue(false),
getFullContext: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
} as unknown as Config;
mockProcessStdoutWrite = vi.fn().mockImplementation(() => true);
process.stdout.write = mockProcessStdoutWrite as any; // Use any to bypass strict signature matching for mock
mockProcessExit = vi
.fn()
.mockImplementation((_code?: number) => undefined as never);
process.exit = mockProcessExit as any; // Use any for process.exit mock
});
afterEach(() => {
vi.restoreAllMocks();
// Restore original process methods if they were globally patched
// This might require storing the original methods before patching them in beforeEach
});
async function* createStreamFromEvents(
events: ServerGeminiStreamEvent[],
): AsyncGenerator<ServerGeminiStreamEvent> {
for (const event of events) {
yield event;
}
}
it('should process input and write text output', async () => {
const inputStream = (async function* () {
yield {
candidates: [{ content: { parts: [{ text: 'Hello' }] } }],
} as GenerateContentResponse;
yield {
candidates: [{ content: { parts: [{ text: ' World' }] } }],
} as GenerateContentResponse;
})();
mockChat.sendMessageStream.mockResolvedValue(inputStream);
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Hello' },
{ type: GeminiEventType.Content, value: ' World' },
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive(mockConfig, 'Test input', 'prompt-id-1');
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
{
message: [{ text: 'Test input' }],
config: {
abortSignal: expect.any(AbortSignal),
tools: [{ functionDeclarations: [] }],
},
},
expect.any(String),
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
[{ text: 'Test input' }],
expect.any(AbortSignal),
'prompt-id-1',
);
expect(mockProcessStdoutWrite).toHaveBeenCalledWith('Hello');
expect(mockProcessStdoutWrite).toHaveBeenCalledWith(' World');
expect(mockProcessStdoutWrite).toHaveBeenCalledWith('\n');
expect(processStdoutSpy).toHaveBeenCalledWith('Hello');
expect(processStdoutSpy).toHaveBeenCalledWith(' World');
expect(processStdoutSpy).toHaveBeenCalledWith('\n');
expect(mockShutdownTelemetry).toHaveBeenCalled();
});
it('should handle a single tool call and respond', async () => {
const functionCall: FunctionCall = {
id: 'fc1',
name: 'testTool',
args: { p: 'v' },
};
const toolResponsePart: Part = {
functionResponse: {
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-1',
name: 'testTool',
id: 'fc1',
response: { result: 'tool success' },
args: { arg1: 'value1' },
isClientInitiated: false,
prompt_id: 'prompt-id-2',
},
};
const toolResponse: Part[] = [{ text: 'Tool response' }];
mockCoreExecuteToolCall.mockResolvedValue({ responseParts: toolResponse });
const { executeToolCall: mockCoreExecuteToolCall } = await import(
'@qwen-code/qwen-code-core'
);
vi.mocked(mockCoreExecuteToolCall).mockResolvedValue({
callId: 'fc1',
responseParts: [toolResponsePart],
resultDisplay: 'Tool success display',
error: undefined,
});
const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent];
const secondCallEvents: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Final answer' },
];
const stream1 = (async function* () {
yield { functionCalls: [functionCall] } as GenerateContentResponse;
})();
const stream2 = (async function* () {
yield {
candidates: [{ content: { parts: [{ text: 'Final answer' }] } }],
} as GenerateContentResponse;
})();
mockChat.sendMessageStream
.mockResolvedValueOnce(stream1)
.mockResolvedValueOnce(stream2);
mockGeminiClient.sendMessageStream
.mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
await runNonInteractive(mockConfig, 'Use a tool', 'prompt-id-2');
expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({ callId: 'fc1', name: 'testTool' }),
expect.objectContaining({ name: 'testTool' }),
mockToolRegistry,
expect.any(AbortSignal),
);
expect(mockChat.sendMessageStream).toHaveBeenLastCalledWith(
expect.objectContaining({
message: [toolResponsePart],
}),
expect.any(String),
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
2,
[{ text: 'Tool response' }],
expect.any(AbortSignal),
'prompt-id-2',
);
expect(mockProcessStdoutWrite).toHaveBeenCalledWith('Final answer');
expect(processStdoutSpy).toHaveBeenCalledWith('Final answer');
expect(processStdoutSpy).toHaveBeenCalledWith('\n');
});
it('should handle error during tool execution', async () => {
const functionCall: FunctionCall = {
id: 'fcError',
name: 'errorTool',
args: {},
};
const errorResponsePart: Part = {
functionResponse: {
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-1',
name: 'errorTool',
id: 'fcError',
response: { error: 'Tool failed' },
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-3',
},
};
const { executeToolCall: mockCoreExecuteToolCall } = await import(
'@qwen-code/qwen-code-core'
);
vi.mocked(mockCoreExecuteToolCall).mockResolvedValue({
callId: 'fcError',
responseParts: [errorResponsePart],
resultDisplay: 'Tool execution failed badly',
error: new Error('Tool failed'),
mockCoreExecuteToolCall.mockResolvedValue({
error: new Error('Tool execution failed badly'),
errorType: ToolErrorType.UNHANDLED_EXCEPTION,
});
const stream1 = (async function* () {
yield { functionCalls: [functionCall] } as GenerateContentResponse;
})();
const stream2 = (async function* () {
yield {
candidates: [
{ content: { parts: [{ text: 'Could not complete request.' }] } },
],
} as GenerateContentResponse;
})();
mockChat.sendMessageStream
.mockResolvedValueOnce(stream1)
.mockResolvedValueOnce(stream2);
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents([toolCallEvent]),
);
await runNonInteractive(mockConfig, 'Trigger tool error', 'prompt-id-3');
@@ -201,75 +174,48 @@ describe('runNonInteractive', () => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error executing tool errorTool: Tool execution failed badly',
);
expect(mockChat.sendMessageStream).toHaveBeenLastCalledWith(
expect.objectContaining({
message: [errorResponsePart],
}),
expect.any(String),
);
expect(mockProcessStdoutWrite).toHaveBeenCalledWith(
'Could not complete request.',
);
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it('should exit with error if sendMessageStream throws initially', async () => {
const apiError = new Error('API connection failed');
mockChat.sendMessageStream.mockRejectedValue(apiError);
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
mockGeminiClient.sendMessageStream.mockImplementation(() => {
throw apiError;
});
await runNonInteractive(mockConfig, 'Initial fail', 'prompt-id-4');
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[API Error: API connection failed]',
);
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it('should not exit if a tool is not found, and should send error back to model', async () => {
const functionCall: FunctionCall = {
id: 'fcNotFound',
name: 'nonexistentTool',
args: {},
};
const errorResponsePart: Part = {
functionResponse: {
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-1',
name: 'nonexistentTool',
id: 'fcNotFound',
response: { error: 'Tool "nonexistentTool" not found in registry.' },
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-5',
},
};
const { executeToolCall: mockCoreExecuteToolCall } = await import(
'@qwen-code/qwen-code-core'
);
vi.mocked(mockCoreExecuteToolCall).mockResolvedValue({
callId: 'fcNotFound',
responseParts: [errorResponsePart],
resultDisplay: 'Tool "nonexistentTool" not found in registry.',
mockCoreExecuteToolCall.mockResolvedValue({
error: new Error('Tool "nonexistentTool" not found in registry.'),
resultDisplay: 'Tool "nonexistentTool" not found in registry.',
});
const finalResponse: ServerGeminiStreamEvent[] = [
{
type: GeminiEventType.Content,
value: "Sorry, I can't find that tool.",
},
];
const stream1 = (async function* () {
yield { functionCalls: [functionCall] } as GenerateContentResponse;
})();
const stream2 = (async function* () {
yield {
candidates: [
{
content: {
parts: [{ text: 'Unfortunately the tool does not exist.' }],
},
},
],
} as GenerateContentResponse;
})();
mockChat.sendMessageStream
.mockResolvedValueOnce(stream1)
.mockResolvedValueOnce(stream2);
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
mockGeminiClient.sendMessageStream
.mockReturnValueOnce(createStreamFromEvents([toolCallEvent]))
.mockReturnValueOnce(createStreamFromEvents(finalResponse));
await runNonInteractive(
mockConfig,
@@ -277,68 +223,22 @@ describe('runNonInteractive', () => {
'prompt-id-5',
);
expect(mockCoreExecuteToolCall).toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error executing tool nonexistentTool: Tool "nonexistentTool" not found in registry.',
);
expect(mockProcessExit).not.toHaveBeenCalled();
expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2);
expect(mockChat.sendMessageStream).toHaveBeenLastCalledWith(
expect.objectContaining({
message: [errorResponsePart],
}),
expect.any(String),
);
expect(mockProcessStdoutWrite).toHaveBeenCalledWith(
'Unfortunately the tool does not exist.',
expect(processExitSpy).not.toHaveBeenCalled();
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
expect(processStdoutSpy).toHaveBeenCalledWith(
"Sorry, I can't find that tool.",
);
});
it('should exit when max session turns are exceeded', async () => {
const functionCall: FunctionCall = {
id: 'fcLoop',
name: 'loopTool',
args: {},
};
const toolResponsePart: Part = {
functionResponse: {
name: 'loopTool',
id: 'fcLoop',
response: { result: 'still looping' },
},
};
// Config with a max turn of 1
vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(1);
const { executeToolCall: mockCoreExecuteToolCall } = await import(
'@qwen-code/qwen-code-core'
);
vi.mocked(mockCoreExecuteToolCall).mockResolvedValue({
callId: 'fcLoop',
responseParts: [toolResponsePart],
resultDisplay: 'Still looping',
error: undefined,
});
const stream = (async function* () {
yield { functionCalls: [functionCall] } as GenerateContentResponse;
})();
mockChat.sendMessageStream.mockResolvedValue(stream);
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await runNonInteractive(mockConfig, 'Trigger loop');
expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(1);
vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0);
await runNonInteractive(mockConfig, 'Trigger loop', 'prompt-id-6');
expect(consoleErrorSpy).toHaveBeenCalledWith(
`
Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.`,
'\n Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
);
expect(mockProcessExit).not.toHaveBeenCalled();
});
});

View File

@@ -11,38 +11,13 @@ import {
ToolRegistry,
shutdownTelemetry,
isTelemetrySdkInitialized,
GeminiEventType,
ToolErrorType,
} from '@qwen-code/qwen-code-core';
import {
Content,
Part,
FunctionCall,
GenerateContentResponse,
} from '@google/genai';
import { Content, Part, FunctionCall } from '@google/genai';
import { parseAndFormatApiError } from './ui/utils/errorParsing.js';
function getResponseText(response: GenerateContentResponse): string | null {
if (response.candidates && response.candidates.length > 0) {
const candidate = response.candidates[0];
if (
candidate.content &&
candidate.content.parts &&
candidate.content.parts.length > 0
) {
// We are running in headless mode so we don't need to return thoughts to STDOUT.
const thoughtPart = candidate.content.parts[0];
if (thoughtPart?.thought) {
return null;
}
return candidate.content.parts
.filter((part) => part.text)
.map((part) => part.text)
.join('');
}
}
return null;
}
export async function runNonInteractive(
config: Config,
input: string,
@@ -60,7 +35,6 @@ export async function runNonInteractive(
const geminiClient = config.getGeminiClient();
const toolRegistry: ToolRegistry = await config.getToolRegistry();
const chat = await geminiClient.getChat();
const abortController = new AbortController();
let currentMessages: Content[] = [{ role: 'user', parts: [{ text: input }] }];
let turnCount = 0;
@@ -68,7 +42,7 @@ export async function runNonInteractive(
while (true) {
turnCount++;
if (
config.getMaxSessionTurns() > 0 &&
config.getMaxSessionTurns() >= 0 &&
turnCount > config.getMaxSessionTurns()
) {
console.error(
@@ -78,30 +52,28 @@ export async function runNonInteractive(
}
const functionCalls: FunctionCall[] = [];
const responseStream = await chat.sendMessageStream(
{
message: currentMessages[0]?.parts || [], // Ensure parts are always provided
config: {
abortSignal: abortController.signal,
tools: [
{ functionDeclarations: toolRegistry.getFunctionDeclarations() },
],
},
},
const responseStream = geminiClient.sendMessageStream(
currentMessages[0]?.parts || [],
abortController.signal,
prompt_id,
);
for await (const resp of responseStream) {
for await (const event of responseStream) {
if (abortController.signal.aborted) {
console.error('Operation cancelled.');
return;
}
const textPart = getResponseText(resp);
if (textPart) {
process.stdout.write(textPart);
}
if (resp.functionCalls) {
functionCalls.push(...resp.functionCalls);
if (event.type === GeminiEventType.Content) {
process.stdout.write(event.value);
} else if (event.type === GeminiEventType.ToolCallRequest) {
const toolCallRequest = event.value;
const fc: FunctionCall = {
name: toolCallRequest.name,
args: toolCallRequest.args,
id: toolCallRequest.callId,
};
functionCalls.push(fc);
}
}
@@ -126,15 +98,11 @@ export async function runNonInteractive(
);
if (toolResponse.error) {
const isToolNotFound = toolResponse.error.message.includes(
'not found in registry',
);
console.error(
`Error executing tool ${fc.name}: ${toolResponse.resultDisplay || toolResponse.error.message}`,
);
if (!isToolNotFound) {
if (toolResponse.errorType === ToolErrorType.UNHANDLED_EXCEPTION)
process.exit(1);
}
}
if (toolResponse.responseParts) {

View File

@@ -16,10 +16,12 @@ import { compressCommand } from '../ui/commands/compressCommand.js';
import { copyCommand } from '../ui/commands/copyCommand.js';
import { corgiCommand } from '../ui/commands/corgiCommand.js';
import { docsCommand } from '../ui/commands/docsCommand.js';
import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { ideCommand } from '../ui/commands/ideCommand.js';
import { initCommand } from '../ui/commands/initCommand.js';
import { mcpCommand } from '../ui/commands/mcpCommand.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { privacyCommand } from '../ui/commands/privacyCommand.js';
@@ -29,6 +31,8 @@ import { statsCommand } from '../ui/commands/statsCommand.js';
import { themeCommand } from '../ui/commands/themeCommand.js';
import { toolsCommand } from '../ui/commands/toolsCommand.js';
import { vimCommand } from '../ui/commands/vimCommand.js';
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
import { isGitHubRepository } from '../utils/gitUtils.js';
/**
* Loads the core, hard-coded slash commands that are an integral part
@@ -55,19 +59,22 @@ export class BuiltinCommandLoader implements ICommandLoader {
copyCommand,
corgiCommand,
docsCommand,
directoryCommand,
editorCommand,
extensionsCommand,
helpCommand,
ideCommand(this.config),
initCommand,
mcpCommand,
memoryCommand,
privacyCommand,
mcpCommand,
quitCommand,
restoreCommand(this.config),
statsCommand,
themeCommand,
toolsCommand,
vimCommand,
...(isGitHubRepository() ? [setupGithubCommand] : []),
];
return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);

View File

@@ -177,4 +177,176 @@ describe('CommandService', () => {
expect(loader2.loadCommands).toHaveBeenCalledTimes(1);
expect(loader2.loadCommands).toHaveBeenCalledWith(signal);
});
it('should rename extension commands when they conflict', async () => {
const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN);
const userCommand = createMockCommand('sync', CommandKind.FILE);
const extensionCommand1 = {
...createMockCommand('deploy', CommandKind.FILE),
extensionName: 'firebase',
description: '[firebase] Deploy to Firebase',
};
const extensionCommand2 = {
...createMockCommand('sync', CommandKind.FILE),
extensionName: 'git-helper',
description: '[git-helper] Sync with remote',
};
const mockLoader1 = new MockCommandLoader([builtinCommand]);
const mockLoader2 = new MockCommandLoader([
userCommand,
extensionCommand1,
extensionCommand2,
]);
const service = await CommandService.create(
[mockLoader1, mockLoader2],
new AbortController().signal,
);
const commands = service.getCommands();
expect(commands).toHaveLength(4);
// Built-in command keeps original name
const deployBuiltin = commands.find(
(cmd) => cmd.name === 'deploy' && !cmd.extensionName,
);
expect(deployBuiltin).toBeDefined();
expect(deployBuiltin?.kind).toBe(CommandKind.BUILT_IN);
// Extension command conflicting with built-in gets renamed
const deployExtension = commands.find(
(cmd) => cmd.name === 'firebase.deploy',
);
expect(deployExtension).toBeDefined();
expect(deployExtension?.extensionName).toBe('firebase');
// User command keeps original name
const syncUser = commands.find(
(cmd) => cmd.name === 'sync' && !cmd.extensionName,
);
expect(syncUser).toBeDefined();
expect(syncUser?.kind).toBe(CommandKind.FILE);
// Extension command conflicting with user command gets renamed
const syncExtension = commands.find(
(cmd) => cmd.name === 'git-helper.sync',
);
expect(syncExtension).toBeDefined();
expect(syncExtension?.extensionName).toBe('git-helper');
});
it('should handle user/project command override correctly', async () => {
const builtinCommand = createMockCommand('help', CommandKind.BUILT_IN);
const userCommand = createMockCommand('help', CommandKind.FILE);
const projectCommand = createMockCommand('deploy', CommandKind.FILE);
const userDeployCommand = createMockCommand('deploy', CommandKind.FILE);
const mockLoader1 = new MockCommandLoader([builtinCommand]);
const mockLoader2 = new MockCommandLoader([
userCommand,
userDeployCommand,
projectCommand,
]);
const service = await CommandService.create(
[mockLoader1, mockLoader2],
new AbortController().signal,
);
const commands = service.getCommands();
expect(commands).toHaveLength(2);
// User command overrides built-in
const helpCommand = commands.find((cmd) => cmd.name === 'help');
expect(helpCommand).toBeDefined();
expect(helpCommand?.kind).toBe(CommandKind.FILE);
// Project command overrides user command (last wins)
const deployCommand = commands.find((cmd) => cmd.name === 'deploy');
expect(deployCommand).toBeDefined();
expect(deployCommand?.kind).toBe(CommandKind.FILE);
});
it('should handle secondary conflicts when renaming extension commands', async () => {
// User has both /deploy and /gcp.deploy commands
const userCommand1 = createMockCommand('deploy', CommandKind.FILE);
const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE);
// Extension also has a deploy command that will conflict with user's /deploy
const extensionCommand = {
...createMockCommand('deploy', CommandKind.FILE),
extensionName: 'gcp',
description: '[gcp] Deploy to Google Cloud',
};
const mockLoader = new MockCommandLoader([
userCommand1,
userCommand2,
extensionCommand,
]);
const service = await CommandService.create(
[mockLoader],
new AbortController().signal,
);
const commands = service.getCommands();
expect(commands).toHaveLength(3);
// Original user command keeps its name
const deployUser = commands.find(
(cmd) => cmd.name === 'deploy' && !cmd.extensionName,
);
expect(deployUser).toBeDefined();
// User's dot notation command keeps its name
const gcpDeployUser = commands.find(
(cmd) => cmd.name === 'gcp.deploy' && !cmd.extensionName,
);
expect(gcpDeployUser).toBeDefined();
// Extension command gets renamed with suffix due to secondary conflict
const deployExtension = commands.find(
(cmd) => cmd.name === 'gcp.deploy1' && cmd.extensionName === 'gcp',
);
expect(deployExtension).toBeDefined();
expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud');
});
it('should handle multiple secondary conflicts with incrementing suffixes', async () => {
// User has /deploy, /gcp.deploy, and /gcp.deploy1
const userCommand1 = createMockCommand('deploy', CommandKind.FILE);
const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE);
const userCommand3 = createMockCommand('gcp.deploy1', CommandKind.FILE);
// Extension has a deploy command
const extensionCommand = {
...createMockCommand('deploy', CommandKind.FILE),
extensionName: 'gcp',
description: '[gcp] Deploy to Google Cloud',
};
const mockLoader = new MockCommandLoader([
userCommand1,
userCommand2,
userCommand3,
extensionCommand,
]);
const service = await CommandService.create(
[mockLoader],
new AbortController().signal,
);
const commands = service.getCommands();
expect(commands).toHaveLength(4);
// Extension command gets renamed with suffix 2 due to multiple conflicts
const deployExtension = commands.find(
(cmd) => cmd.name === 'gcp.deploy2' && cmd.extensionName === 'gcp',
);
expect(deployExtension).toBeDefined();
expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud');
});
});

View File

@@ -30,13 +30,17 @@ export class CommandService {
*
* This factory method orchestrates the entire command loading process. It
* runs all provided loaders in parallel, aggregates their results, handles
* name conflicts by letting the last-loaded command win, and then returns a
* name conflicts for extension commands by renaming them, and then returns a
* fully constructed `CommandService` instance.
*
* Conflict resolution:
* - Extension commands that conflict with existing commands are renamed to
* `extensionName.commandName`
* - Non-extension commands (built-in, user, project) override earlier commands
* with the same name based on loader order
*
* @param loaders An array of objects that conform to the `ICommandLoader`
* interface. The order of loaders is significant: if multiple loaders
* provide a command with the same name, the command from the loader that
* appears later in the array will take precedence.
* interface. Built-in commands should come first, followed by FileCommandLoader.
* @param signal An AbortSignal to cancel the loading process.
* @returns A promise that resolves to a new, fully initialized `CommandService` instance.
*/
@@ -57,12 +61,28 @@ export class CommandService {
}
}
// De-duplicate commands using a Map. The last one found with a given name wins.
// This creates a natural override system based on the order of the loaders
// passed to the constructor.
const commandMap = new Map<string, SlashCommand>();
for (const cmd of allCommands) {
commandMap.set(cmd.name, cmd);
let finalName = cmd.name;
// Extension commands get renamed if they conflict with existing commands
if (cmd.extensionName && commandMap.has(cmd.name)) {
let renamedName = `${cmd.extensionName}.${cmd.name}`;
let suffix = 1;
// Keep trying until we find a name that doesn't conflict
while (commandMap.has(renamedName)) {
renamedName = `${cmd.extensionName}.${cmd.name}${suffix}`;
suffix++;
}
finalName = renamedName;
}
commandMap.set(finalName, {
...cmd,
name: finalName,
});
}
const finalCommands = Object.freeze(Array.from(commandMap.values()));

View File

@@ -4,13 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { FileCommandLoader } from './FileCommandLoader.js';
import * as path from 'node:path';
import {
Config,
getProjectCommandsDir,
getUserCommandsDir,
} from '@qwen-code/qwen-code-core';
import mock from 'mock-fs';
import { FileCommandLoader } from './FileCommandLoader.js';
import { assert, vi } from 'vitest';
import { createMockCommandContext } from '../test-utils/mockCommandContext.js';
import {
@@ -85,7 +86,7 @@ describe('FileCommandLoader', () => {
},
});
const loader = new FileCommandLoader(null as unknown as Config);
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
@@ -176,7 +177,7 @@ describe('FileCommandLoader', () => {
},
});
const loader = new FileCommandLoader(null as unknown as Config);
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(2);
@@ -194,9 +195,11 @@ describe('FileCommandLoader', () => {
},
},
});
const loader = new FileCommandLoader({
getProjectRoot: () => '/path/to/project',
} as Config);
const mockConfig = {
getProjectRoot: vi.fn(() => '/path/to/project'),
getExtensions: vi.fn(() => []),
} as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
expect(commands[0]!.name).toBe('gcp:pipelines:run');
@@ -212,7 +215,7 @@ describe('FileCommandLoader', () => {
},
});
const loader = new FileCommandLoader(null as unknown as Config);
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
@@ -221,7 +224,7 @@ describe('FileCommandLoader', () => {
expect(command.name).toBe('git:commit');
});
it('overrides user commands with project commands', async () => {
it('returns both user and project commands in order', async () => {
const userCommandsDir = getUserCommandsDir();
const projectCommandsDir = getProjectCommandsDir(process.cwd());
mock({
@@ -233,16 +236,15 @@ describe('FileCommandLoader', () => {
},
});
const loader = new FileCommandLoader({
getProjectRoot: () => process.cwd(),
} as Config);
const mockConfig = {
getProjectRoot: vi.fn(() => process.cwd()),
getExtensions: vi.fn(() => []),
} as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
const command = commands[0];
expect(command).toBeDefined();
const result = await command.action?.(
expect(commands).toHaveLength(2);
const userResult = await commands[0].action?.(
createMockCommandContext({
invocation: {
raw: '/test',
@@ -252,10 +254,25 @@ describe('FileCommandLoader', () => {
}),
'',
);
if (result?.type === 'submit_prompt') {
expect(result.content).toBe('Project prompt');
if (userResult?.type === 'submit_prompt') {
expect(userResult.content).toBe('User prompt');
} else {
assert.fail('Incorrect action type');
assert.fail('Incorrect action type for user command');
}
const projectResult = await commands[1].action?.(
createMockCommandContext({
invocation: {
raw: '/test',
name: 'test',
args: '',
},
}),
'',
);
if (projectResult?.type === 'submit_prompt') {
expect(projectResult.content).toBe('Project prompt');
} else {
assert.fail('Incorrect action type for project command');
}
});
@@ -268,7 +285,7 @@ describe('FileCommandLoader', () => {
},
});
const loader = new FileCommandLoader(null as unknown as Config);
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
@@ -284,7 +301,7 @@ describe('FileCommandLoader', () => {
},
});
const loader = new FileCommandLoader(null as unknown as Config);
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
@@ -299,7 +316,7 @@ describe('FileCommandLoader', () => {
},
});
const loader = new FileCommandLoader(null as unknown as Config);
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
const command = commands[0];
expect(command).toBeDefined();
@@ -308,7 +325,7 @@ describe('FileCommandLoader', () => {
it('handles file system errors gracefully', async () => {
mock({}); // Mock an empty file system
const loader = new FileCommandLoader(null as unknown as Config);
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(0);
});
@@ -321,7 +338,7 @@ describe('FileCommandLoader', () => {
},
});
const loader = new FileCommandLoader(null as unknown as Config);
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
const command = commands[0];
expect(command).toBeDefined();
@@ -336,7 +353,7 @@ describe('FileCommandLoader', () => {
},
});
const loader = new FileCommandLoader(null as unknown as Config);
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
const command = commands[0];
expect(command).toBeDefined();
@@ -351,7 +368,7 @@ describe('FileCommandLoader', () => {
},
});
const loader = new FileCommandLoader(null as unknown as Config);
const loader = new FileCommandLoader(null);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
@@ -362,6 +379,298 @@ describe('FileCommandLoader', () => {
expect(command.name).toBe('legacy_command');
});
describe('Extension Command Loading', () => {
it('loads commands from active extensions', async () => {
const userCommandsDir = getUserCommandsDir();
const projectCommandsDir = getProjectCommandsDir(process.cwd());
const extensionDir = path.join(
process.cwd(),
'.gemini/extensions/test-ext',
);
mock({
[userCommandsDir]: {
'user.toml': 'prompt = "User command"',
},
[projectCommandsDir]: {
'project.toml': 'prompt = "Project command"',
},
[extensionDir]: {
'gemini-extension.json': JSON.stringify({
name: 'test-ext',
version: '1.0.0',
}),
commands: {
'ext.toml': 'prompt = "Extension command"',
},
},
});
const mockConfig = {
getProjectRoot: vi.fn(() => process.cwd()),
getExtensions: vi.fn(() => [
{
name: 'test-ext',
version: '1.0.0',
isActive: true,
path: extensionDir,
},
]),
} as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(3);
const commandNames = commands.map((cmd) => cmd.name);
expect(commandNames).toEqual(['user', 'project', 'ext']);
const extCommand = commands.find((cmd) => cmd.name === 'ext');
expect(extCommand?.extensionName).toBe('test-ext');
expect(extCommand?.description).toMatch(/^\[test-ext\]/);
});
it('extension commands have extensionName metadata for conflict resolution', async () => {
const userCommandsDir = getUserCommandsDir();
const projectCommandsDir = getProjectCommandsDir(process.cwd());
const extensionDir = path.join(
process.cwd(),
'.gemini/extensions/test-ext',
);
mock({
[extensionDir]: {
'gemini-extension.json': JSON.stringify({
name: 'test-ext',
version: '1.0.0',
}),
commands: {
'deploy.toml': 'prompt = "Extension deploy command"',
},
},
[userCommandsDir]: {
'deploy.toml': 'prompt = "User deploy command"',
},
[projectCommandsDir]: {
'deploy.toml': 'prompt = "Project deploy command"',
},
});
const mockConfig = {
getProjectRoot: vi.fn(() => process.cwd()),
getExtensions: vi.fn(() => [
{
name: 'test-ext',
version: '1.0.0',
isActive: true,
path: extensionDir,
},
]),
} as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
// Return all commands, even duplicates
expect(commands).toHaveLength(3);
expect(commands[0].name).toBe('deploy');
expect(commands[0].extensionName).toBeUndefined();
const result0 = await commands[0].action?.(
createMockCommandContext({
invocation: {
raw: '/deploy',
name: 'deploy',
args: '',
},
}),
'',
);
expect(result0?.type).toBe('submit_prompt');
if (result0?.type === 'submit_prompt') {
expect(result0.content).toBe('User deploy command');
}
expect(commands[1].name).toBe('deploy');
expect(commands[1].extensionName).toBeUndefined();
const result1 = await commands[1].action?.(
createMockCommandContext({
invocation: {
raw: '/deploy',
name: 'deploy',
args: '',
},
}),
'',
);
expect(result1?.type).toBe('submit_prompt');
if (result1?.type === 'submit_prompt') {
expect(result1.content).toBe('Project deploy command');
}
expect(commands[2].name).toBe('deploy');
expect(commands[2].extensionName).toBe('test-ext');
expect(commands[2].description).toMatch(/^\[test-ext\]/);
const result2 = await commands[2].action?.(
createMockCommandContext({
invocation: {
raw: '/deploy',
name: 'deploy',
args: '',
},
}),
'',
);
expect(result2?.type).toBe('submit_prompt');
if (result2?.type === 'submit_prompt') {
expect(result2.content).toBe('Extension deploy command');
}
});
it('only loads commands from active extensions', async () => {
const extensionDir1 = path.join(
process.cwd(),
'.gemini/extensions/active-ext',
);
const extensionDir2 = path.join(
process.cwd(),
'.gemini/extensions/inactive-ext',
);
mock({
[extensionDir1]: {
'gemini-extension.json': JSON.stringify({
name: 'active-ext',
version: '1.0.0',
}),
commands: {
'active.toml': 'prompt = "Active extension command"',
},
},
[extensionDir2]: {
'gemini-extension.json': JSON.stringify({
name: 'inactive-ext',
version: '1.0.0',
}),
commands: {
'inactive.toml': 'prompt = "Inactive extension command"',
},
},
});
const mockConfig = {
getProjectRoot: vi.fn(() => process.cwd()),
getExtensions: vi.fn(() => [
{
name: 'active-ext',
version: '1.0.0',
isActive: true,
path: extensionDir1,
},
{
name: 'inactive-ext',
version: '1.0.0',
isActive: false,
path: extensionDir2,
},
]),
} as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(1);
expect(commands[0].name).toBe('active');
expect(commands[0].extensionName).toBe('active-ext');
expect(commands[0].description).toMatch(/^\[active-ext\]/);
});
it('handles missing extension commands directory gracefully', async () => {
const extensionDir = path.join(
process.cwd(),
'.gemini/extensions/no-commands',
);
mock({
[extensionDir]: {
'gemini-extension.json': JSON.stringify({
name: 'no-commands',
version: '1.0.0',
}),
// No commands directory
},
});
const mockConfig = {
getProjectRoot: vi.fn(() => process.cwd()),
getExtensions: vi.fn(() => [
{
name: 'no-commands',
version: '1.0.0',
isActive: true,
path: extensionDir,
},
]),
} as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(0);
});
it('handles nested command structure in extensions', async () => {
const extensionDir = path.join(process.cwd(), '.gemini/extensions/a');
mock({
[extensionDir]: {
'gemini-extension.json': JSON.stringify({
name: 'a',
version: '1.0.0',
}),
commands: {
b: {
'c.toml': 'prompt = "Nested command from extension a"',
d: {
'e.toml': 'prompt = "Deeply nested command"',
},
},
'simple.toml': 'prompt = "Simple command"',
},
},
});
const mockConfig = {
getProjectRoot: vi.fn(() => process.cwd()),
getExtensions: vi.fn(() => [
{ name: 'a', version: '1.0.0', isActive: true, path: extensionDir },
]),
} as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
expect(commands).toHaveLength(3);
const commandNames = commands.map((cmd) => cmd.name).sort();
expect(commandNames).toEqual(['b:c', 'b:d:e', 'simple']);
const nestedCmd = commands.find((cmd) => cmd.name === 'b:c');
expect(nestedCmd?.extensionName).toBe('a');
expect(nestedCmd?.description).toMatch(/^\[a\]/);
expect(nestedCmd).toBeDefined();
const result = await nestedCmd!.action?.(
createMockCommandContext({
invocation: {
raw: '/b:c',
name: 'b:c',
args: '',
},
}),
'',
);
if (result?.type === 'submit_prompt') {
expect(result.content).toBe('Nested command from extension a');
} else {
assert.fail('Incorrect action type');
}
});
});
describe('Shorthand Argument Processor Integration', () => {
it('correctly processes a command with {{args}}', async () => {
const userCommandsDir = getUserCommandsDir();

View File

@@ -35,6 +35,11 @@ import {
ShellProcessor,
} from './prompt-processors/shellProcessor.js';
interface CommandDirectory {
path: string;
extensionName?: string;
}
/**
* Defines the Zod schema for a command definition file. This serves as the
* single source of truth for both validation and type inference.
@@ -65,13 +70,18 @@ export class FileCommandLoader implements ICommandLoader {
}
/**
* Loads all commands, applying the precedence rule where project-level
* commands override user-level commands with the same name.
* Loads all commands from user, project, and extension directories.
* Returns commands in order: user → project → extensions (alphabetically).
*
* Order is important for conflict resolution in CommandService:
* - User/project commands (without extensionName) use "last wins" strategy
* - Extension commands (with extensionName) get renamed if conflicts exist
*
* @param signal An AbortSignal to cancel the loading process.
* @returns A promise that resolves to an array of loaded SlashCommands.
* @returns A promise that resolves to an array of all loaded SlashCommands.
*/
async loadCommands(signal: AbortSignal): Promise<SlashCommand[]> {
const commandMap = new Map<string, SlashCommand>();
const allCommands: SlashCommand[] = [];
const globOptions = {
nodir: true,
dot: true,
@@ -79,54 +89,85 @@ export class FileCommandLoader implements ICommandLoader {
follow: true,
};
try {
// User Commands
const userDir = getUserCommandsDir();
const userFiles = await glob('**/*.toml', {
...globOptions,
cwd: userDir,
});
const userCommandPromises = userFiles.map((file) =>
this.parseAndAdaptFile(path.join(userDir, file), userDir),
);
const userCommands = (await Promise.all(userCommandPromises)).filter(
(cmd): cmd is SlashCommand => cmd !== null,
);
for (const cmd of userCommands) {
commandMap.set(cmd.name, cmd);
}
// Load commands from each directory
const commandDirs = this.getCommandDirectories();
for (const dirInfo of commandDirs) {
try {
const files = await glob('**/*.toml', {
...globOptions,
cwd: dirInfo.path,
});
// Project Commands (these intentionally override user commands)
const projectDir = getProjectCommandsDir(this.projectRoot);
const projectFiles = await glob('**/*.toml', {
...globOptions,
cwd: projectDir,
});
const projectCommandPromises = projectFiles.map((file) =>
this.parseAndAdaptFile(path.join(projectDir, file), projectDir),
);
const projectCommands = (
await Promise.all(projectCommandPromises)
).filter((cmd): cmd is SlashCommand => cmd !== null);
for (const cmd of projectCommands) {
commandMap.set(cmd.name, cmd);
const commandPromises = files.map((file) =>
this.parseAndAdaptFile(
path.join(dirInfo.path, file),
dirInfo.path,
dirInfo.extensionName,
),
);
const commands = (await Promise.all(commandPromises)).filter(
(cmd): cmd is SlashCommand => cmd !== null,
);
// Add all commands without deduplication
allCommands.push(...commands);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error(
`[FileCommandLoader] Error loading commands from ${dirInfo.path}:`,
error,
);
}
}
} catch (error) {
console.error(`[FileCommandLoader] Error during file search:`, error);
}
return Array.from(commandMap.values());
return allCommands;
}
/**
* Get all command directories in order for loading.
* User commands → Project commands → Extension commands
* This order ensures extension commands can detect all conflicts.
*/
private getCommandDirectories(): CommandDirectory[] {
const dirs: CommandDirectory[] = [];
// 1. User commands
dirs.push({ path: getUserCommandsDir() });
// 2. Project commands (override user commands)
dirs.push({ path: getProjectCommandsDir(this.projectRoot) });
// 3. Extension commands (processed last to detect all conflicts)
if (this.config) {
const activeExtensions = this.config
.getExtensions()
.filter((ext) => ext.isActive)
.sort((a, b) => a.name.localeCompare(b.name)); // Sort alphabetically for deterministic loading
const extensionCommandDirs = activeExtensions.map((ext) => ({
path: path.join(ext.path, 'commands'),
extensionName: ext.name,
}));
dirs.push(...extensionCommandDirs);
}
return dirs;
}
/**
* Parses a single .toml file and transforms it into a SlashCommand object.
* @param filePath The absolute path to the .toml file.
* @param baseDir The root command directory for name calculation.
* @param extensionName Optional extension name to prefix commands with.
* @returns A promise resolving to a SlashCommand, or null if the file is invalid.
*/
private async parseAndAdaptFile(
filePath: string,
baseDir: string,
extensionName?: string,
): Promise<SlashCommand | null> {
let fileContent: string;
try {
@@ -167,7 +208,7 @@ export class FileCommandLoader implements ICommandLoader {
0,
relativePathWithExt.length - 5, // length of '.toml'
);
const commandName = relativePath
const baseCommandName = relativePath
.split(path.sep)
// Sanitize each path segment to prevent ambiguity. Since ':' is our
// namespace separator, we replace any literal colons in filenames
@@ -175,11 +216,18 @@ export class FileCommandLoader implements ICommandLoader {
.map((segment) => segment.replaceAll(':', '_'))
.join(':');
// Add extension name tag for extension commands
const defaultDescription = `Custom command from ${path.basename(filePath)}`;
let description = validDef.description || defaultDescription;
if (extensionName) {
description = `[${extensionName}] ${description}`;
}
const processors: IPromptProcessor[] = [];
// Add the Shell Processor if needed.
if (validDef.prompt.includes(SHELL_INJECTION_TRIGGER)) {
processors.push(new ShellProcessor(commandName));
processors.push(new ShellProcessor(baseCommandName));
}
// The presence of '{{args}}' is the switch that determines the behavior.
@@ -190,18 +238,17 @@ export class FileCommandLoader implements ICommandLoader {
}
return {
name: commandName,
description:
validDef.description ||
`Custom command from ${path.basename(filePath)}`,
name: baseCommandName,
description,
kind: CommandKind.FILE,
extensionName,
action: async (
context: CommandContext,
_args: string,
): Promise<SlashCommandActionReturn> => {
if (!context.invocation) {
console.error(
`[FileCommandLoader] Critical error: Command '${commandName}' was executed without invocation context.`,
`[FileCommandLoader] Critical error: Command '${baseCommandName}' was executed without invocation context.`,
);
return {
type: 'submit_prompt',

View File

@@ -0,0 +1,63 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/// <reference types="vitest/globals" />
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { expect } from 'vitest';
import type { TextBuffer } from '../ui/components/shared/text-buffer.js';
// RegExp to detect invalid characters: backspace, and ANSI escape codes
// eslint-disable-next-line no-control-regex
const invalidCharsRegex = /[\b\x1b]/;
function toHaveOnlyValidCharacters(this: vi.Assertion, buffer: TextBuffer) {
const { isNot } = this;
let pass = true;
const invalidLines: Array<{ line: number; content: string }> = [];
for (let i = 0; i < buffer.lines.length; i++) {
const line = buffer.lines[i];
if (line.includes('\n')) {
pass = false;
invalidLines.push({ line: i, content: line });
break; // Fail fast on newlines
}
if (invalidCharsRegex.test(line)) {
pass = false;
invalidLines.push({ line: i, content: line });
}
}
return {
pass,
message: () =>
`Expected buffer ${isNot ? 'not ' : ''}to have only valid characters, but found invalid characters in lines:\n${invalidLines
.map((l) => ` [${l.line}]: "${l.content}"`) /* This line was changed */
.join('\n')}`,
actual: buffer.lines,
expected: 'Lines with no line breaks, backspaces, or escape codes.',
};
}
expect.extend({
toHaveOnlyValidCharacters,
});
// Extend Vitest's `expect` interface with the custom matcher's type definition.
declare module 'vitest' {
interface Assertion<T> {
toHaveOnlyValidCharacters(): T;
}
interface AsymmetricMatchersContaining {
toHaveOnlyValidCharacters(): void;
}
}

View File

@@ -53,8 +53,10 @@ export const createMockCommandContext = (
setPendingItem: vi.fn(),
loadHistory: vi.fn(),
toggleCorgiMode: vi.fn(),
toggleVimEnabled: vi.fn(),
},
session: {
sessionShellAllowlist: new Set<string>(),
stats: {
sessionStartTime: new Date(),
lastPromptTokenCount: 0,

View File

@@ -23,6 +23,10 @@ import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
import { StreamingState, ConsoleMessageItem } from './types.js';
import { Tips } from './components/Tips.js';
import { checkForUpdates, UpdateObject } from './utils/updateCheck.js';
import { EventEmitter } from 'events';
import { updateEventEmitter } from '../utils/updateEventEmitter.js';
import * as auth from '../config/auth.js';
// Define a more complete mock server config based on actual Config
interface MockServerConfig {
@@ -148,13 +152,17 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
setFlashFallbackHandler: vi.fn(),
getSessionId: vi.fn(() => 'test-session-id'),
getUserTier: vi.fn().mockResolvedValue(undefined),
getIdeModeFeature: vi.fn(() => false),
getIdeMode: vi.fn(() => false),
getWorkspaceContext: vi.fn(() => ({
getDirectories: vi.fn(() => []),
})),
};
});
const ideContextMock = {
getOpenFilesContext: vi.fn(),
subscribeToOpenFiles: vi.fn(() => vi.fn()), // subscribe returns an unsubscribe function
getIdeContext: vi.fn(),
subscribeToIdeContext: vi.fn(() => vi.fn()), // subscribe returns an unsubscribe function
};
return {
@@ -163,6 +171,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
MCPServerConfig: actualCore.MCPServerConfig,
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
ideContext: ideContextMock,
isGitRepository: vi.fn(),
};
});
@@ -220,6 +229,21 @@ vi.mock('./components/Header.js', () => ({
Header: vi.fn(() => null),
}));
vi.mock('./utils/updateCheck.js', () => ({
checkForUpdates: vi.fn(),
}));
vi.mock('./config/auth.js', () => ({
validateAuthMethod: vi.fn(),
}));
const mockedCheckForUpdates = vi.mocked(checkForUpdates);
const { isGitRepository: mockedIsGitRepository } = vi.mocked(
await import('@qwen-code/qwen-code-core'),
);
vi.mock('node:child_process');
describe('App UI', () => {
let mockConfig: MockServerConfig;
let mockSettings: LoadedSettings;
@@ -277,7 +301,14 @@ describe('App UI', () => {
// Ensure a theme is set so the theme dialog does not appear.
mockSettings = createMockSettings({ workspace: { theme: 'Default' } });
vi.mocked(ideContext.getOpenFilesContext).mockReturnValue(undefined);
// Ensure getWorkspaceContext is available if not added by the constructor
if (!mockConfig.getWorkspaceContext) {
mockConfig.getWorkspaceContext = vi.fn(() => ({
getDirectories: vi.fn(() => ['/test/dir']),
}));
}
vi.mocked(ideContext.getIdeContext).mockReturnValue(undefined);
});
afterEach(() => {
@@ -288,11 +319,181 @@ describe('App UI', () => {
vi.clearAllMocks(); // Clear mocks after each test
});
describe('handleAutoUpdate', () => {
let spawnEmitter: EventEmitter;
beforeEach(async () => {
const { spawn } = await import('node:child_process');
spawnEmitter = new EventEmitter();
spawnEmitter.stdout = new EventEmitter();
spawnEmitter.stderr = new EventEmitter();
(spawn as vi.Mock).mockReturnValue(spawnEmitter);
});
afterEach(() => {
delete process.env.GEMINI_CLI_DISABLE_AUTOUPDATER;
});
it('should not start the update process when running from git', async () => {
mockedIsGitRepository.mockResolvedValue(true);
const info: UpdateObject = {
update: {
name: '@qwen-code/qwen-code',
latest: '1.1.0',
current: '1.0.0',
},
message: 'Qwen Code update available!',
};
mockedCheckForUpdates.mockResolvedValue(info);
const { spawn } = await import('node:child_process');
const { unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await new Promise((resolve) => setTimeout(resolve, 10));
expect(spawn).not.toHaveBeenCalled();
});
it('should show a success message when update succeeds', async () => {
mockedIsGitRepository.mockResolvedValue(false);
const info: UpdateObject = {
update: {
name: '@qwen-code/qwen-code',
latest: '1.1.0',
current: '1.0.0',
},
message: 'Update available',
};
mockedCheckForUpdates.mockResolvedValue(info);
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
updateEventEmitter.emit('update-success', info);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(lastFrame()).toContain(
'Update successful! The new version will be used on your next run.',
);
});
it('should show an error message when update fails', async () => {
mockedIsGitRepository.mockResolvedValue(false);
const info: UpdateObject = {
update: {
name: '@qwen-code/qwen-code',
latest: '1.1.0',
current: '1.0.0',
},
message: 'Update available',
};
mockedCheckForUpdates.mockResolvedValue(info);
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
updateEventEmitter.emit('update-failed', info);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(lastFrame()).toContain(
'Automatic update failed. Please try updating manually',
);
});
it('should show an error message when spawn fails', async () => {
mockedIsGitRepository.mockResolvedValue(false);
const info: UpdateObject = {
update: {
name: '@qwen-code/qwen-code',
latest: '1.1.0',
current: '1.0.0',
},
message: 'Update available',
};
mockedCheckForUpdates.mockResolvedValue(info);
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
// We are testing the App's reaction to an `update-failed` event,
// which is what should be emitted when a spawn error occurs elsewhere.
updateEventEmitter.emit('update-failed', info);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(lastFrame()).toContain(
'Automatic update failed. Please try updating manually',
);
});
it('should not auto-update if GEMINI_CLI_DISABLE_AUTOUPDATER is true', async () => {
mockedIsGitRepository.mockResolvedValue(false);
process.env.GEMINI_CLI_DISABLE_AUTOUPDATER = 'true';
const info: UpdateObject = {
update: {
name: '@qwen-code/qwen-code',
latest: '1.1.0',
current: '1.0.0',
},
message: 'Update available',
};
mockedCheckForUpdates.mockResolvedValue(info);
const { spawn } = await import('node:child_process');
const { unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await new Promise((resolve) => setTimeout(resolve, 10));
expect(spawn).not.toHaveBeenCalled();
});
});
it('should display active file when available', async () => {
vi.mocked(ideContext.getOpenFilesContext).mockReturnValue({
activeFile: '/path/to/my-file.ts',
recentOpenFiles: [{ filePath: '/path/to/my-file.ts', content: 'hello' }],
selectedText: 'hello',
vi.mocked(ideContext.getIdeContext).mockReturnValue({
workspaceState: {
openFiles: [
{
path: '/path/to/my-file.ts',
isActive: true,
selectedText: 'hello',
timestamp: 0,
},
],
},
});
const { lastFrame, unmount } = render(
@@ -304,12 +505,14 @@ describe('App UI', () => {
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('1 recent file (ctrl+e to view)');
expect(lastFrame()).toContain('1 open file (ctrl+e to view)');
});
it('should not display active file when not available', async () => {
vi.mocked(ideContext.getOpenFilesContext).mockReturnValue({
activeFile: '',
it('should not display any files when not available', async () => {
vi.mocked(ideContext.getIdeContext).mockReturnValue({
workspaceState: {
openFiles: [],
},
});
const { lastFrame, unmount } = render(
@@ -324,11 +527,54 @@ describe('App UI', () => {
expect(lastFrame()).not.toContain('Open File');
});
it('should display active file and other open files', async () => {
vi.mocked(ideContext.getIdeContext).mockReturnValue({
workspaceState: {
openFiles: [
{
path: '/path/to/my-file.ts',
isActive: true,
selectedText: 'hello',
timestamp: 0,
},
{
path: '/path/to/another-file.ts',
isActive: false,
timestamp: 1,
},
{
path: '/path/to/third-file.ts',
isActive: false,
timestamp: 2,
},
],
},
});
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('3 open files (ctrl+e to view)');
});
it('should display active file and other context', async () => {
vi.mocked(ideContext.getOpenFilesContext).mockReturnValue({
activeFile: '/path/to/my-file.ts',
recentOpenFiles: [{ filePath: '/path/to/my-file.ts', content: 'hello' }],
selectedText: 'hello',
vi.mocked(ideContext.getIdeContext).mockReturnValue({
workspaceState: {
openFiles: [
{
path: '/path/to/my-file.ts',
isActive: true,
selectedText: 'hello',
timestamp: 0,
},
],
},
});
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.md']);
@@ -343,7 +589,7 @@ describe('App UI', () => {
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain(
'Using: 1 recent file (ctrl+e to view) | 1 GEMINI.md file',
'Using: 1 open file (ctrl+e to view) | 1 GEMINI.md file',
);
});
@@ -764,4 +1010,50 @@ describe('App UI', () => {
expect(lastFrame()).toContain('5 errors');
});
});
describe('auth validation', () => {
it('should call validateAuthMethod when useExternalAuth is false', async () => {
const validateAuthMethodSpy = vi.spyOn(auth, 'validateAuthMethod');
mockSettings = createMockSettings({
workspace: {
selectedAuthType: 'USE_GEMINI' as AuthType,
useExternalAuth: false,
theme: 'Default',
},
});
const { unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
expect(validateAuthMethodSpy).toHaveBeenCalledWith('USE_GEMINI');
});
it('should NOT call validateAuthMethod when useExternalAuth is true', async () => {
const validateAuthMethodSpy = vi.spyOn(auth, 'validateAuthMethod');
mockSettings = createMockSettings({
workspace: {
selectedAuthType: 'USE_GEMINI' as AuthType,
useExternalAuth: true,
theme: 'Default',
},
});
const { unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
expect(validateAuthMethodSpy).not.toHaveBeenCalled();
});
});
});

View File

@@ -38,7 +38,6 @@ import { AuthInProgress } from './components/AuthInProgress.js';
import { EditorSettingsDialog } from './components/EditorSettingsDialog.js';
import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js';
import { Colors } from './colors.js';
import { Help } from './components/Help.js';
import { loadHierarchicalGeminiMemory } from '../config/config.js';
import { LoadedSettings } from '../config/settings.js';
import { Tips } from './components/Tips.js';
@@ -60,7 +59,7 @@ import {
FlashFallbackEvent,
logFlashFallback,
AuthType,
type OpenFiles,
type IdeContext,
ideContext,
} from '@qwen-code/qwen-code-core';
import { validateAuthMethod } from '../config/auth.js';
@@ -83,11 +82,12 @@ import {
isGenericQuotaExceededError,
UserTierId,
} from '@qwen-code/qwen-code-core';
import { checkForUpdates } from './utils/updateCheck.js';
import { UpdateObject } from './utils/updateCheck.js';
import ansiEscapes from 'ansi-escapes';
import { OverflowProvider } from './contexts/OverflowContext.js';
import { ShowMoreLines } from './components/ShowMoreLines.js';
import { PrivacyNotice } from './privacy/PrivacyNotice.js';
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
import { appEvents, AppEvent } from '../utils/events.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
@@ -110,15 +110,16 @@ export const AppWrapper = (props: AppProps) => (
const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const isFocused = useFocus();
useBracketedPaste();
const [updateMessage, setUpdateMessage] = useState<string | null>(null);
const [updateInfo, setUpdateInfo] = useState<UpdateObject | null>(null);
const { stdout } = useStdout();
const nightly = version.includes('nightly');
const { history, addItem, clearItems, loadHistory } = useHistory();
useEffect(() => {
checkForUpdates().then(setUpdateMessage);
}, []);
const cleanup = setUpdateHandler(addItem, setUpdateInfo);
return cleanup;
}, [addItem]);
const { history, addItem, clearItems, loadHistory } = useHistory();
const {
consoleMessages,
handleNewMessage,
@@ -144,7 +145,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const [geminiMdFileCount, setGeminiMdFileCount] = useState<number>(0);
const [debugMessage, setDebugMessage] = useState<string>('');
const [showHelp, setShowHelp] = useState<boolean>(false);
const [themeError, setThemeError] = useState<string | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [editorError, setEditorError] = useState<string | null>(null);
@@ -169,13 +169,15 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
useState<boolean>(false);
const [userTier, setUserTier] = useState<UserTierId | undefined>(undefined);
const [openFiles, setOpenFiles] = useState<OpenFiles | undefined>();
const [ideContextState, setIdeContextState] = useState<
IdeContext | undefined
>();
const [isProcessing, setIsProcessing] = useState<boolean>(false);
useEffect(() => {
const unsubscribe = ideContext.subscribeToOpenFiles(setOpenFiles);
const unsubscribe = ideContext.subscribeToIdeContext(setIdeContextState);
// Set the initial value
setOpenFiles(ideContext.getOpenFilesContext());
setIdeContextState(ideContext.getIdeContext());
return unsubscribe;
}, []);
@@ -230,14 +232,19 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
} = useAuthCommand(settings, setAuthError, config);
useEffect(() => {
if (settings.merged.selectedAuthType) {
if (settings.merged.selectedAuthType && !settings.merged.useExternalAuth) {
const error = validateAuthMethod(settings.merged.selectedAuthType);
if (error) {
setAuthError(error);
openAuthDialog();
}
}
}, [settings.merged.selectedAuthType, openAuthDialog, setAuthError]);
}, [
settings.merged.selectedAuthType,
settings.merged.useExternalAuth,
openAuthDialog,
setAuthError,
]);
// Sync user tier from config when authentication changes
useEffect(() => {
@@ -273,6 +280,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
config.getFileService(),
settings.merged,
config.getExtensionContextFilePaths(),
settings.merged.memoryImportFormat || 'tree', // Use setting or default to 'tree'
config.getFileFilteringOptions(),
);
@@ -396,6 +404,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
// Switch model for future use but return false to stop current retry
config.setModel(fallbackModel);
config.setFallbackMode(true);
logFlashFallback(
config,
new FlashFallbackEvent(config.getContentGeneratorConfig().authType!),
@@ -462,7 +471,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
clearItems,
loadHistory,
refreshStatic,
setShowHelp,
setDebugMessage,
openThemeDialog,
openAuthDialog,
@@ -484,7 +492,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
config.getGeminiClient(),
history,
addItem,
setShowHelp,
config,
setDebugMessage,
handleSlashCommand,
@@ -568,7 +575,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
if (Object.keys(mcpServers || {}).length > 0) {
handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc');
}
} else if (key.ctrl && input === 'e' && ideContext) {
} else if (
key.ctrl &&
input === 'e' &&
config.getIdeMode() &&
ideContextState
) {
setShowIDEContextDetail((prev) => !prev);
} else if (key.ctrl && (input === 'c' || input === 'C')) {
handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef);
@@ -754,9 +766,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
return (
<StreamingContext.Provider value={streamingState}>
<Box flexDirection="column" width="90%">
{/* Move UpdateNotification outside Static so it can re-render when updateMessage changes */}
{updateMessage && <UpdateNotification message={updateMessage} />}
{/*
* The Static component is an Ink intrinsic in which there can only be 1 per application.
* Because of this restriction we're hacking it slightly by having a 'header' item here to
@@ -789,6 +798,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
item={h}
isPending={false}
config={config}
commands={slashCommands}
/>
)),
]}
@@ -816,9 +826,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
</Box>
</OverflowProvider>
{showHelp && <Help commands={slashCommands} />}
<Box flexDirection="column" ref={mainControlsRef}>
{/* Move UpdateNotification to render update notification above input area */}
{updateInfo && <UpdateNotification message={updateInfo.message} />}
{startupWarnings.length > 0 && (
<Box
borderStyle="round"
@@ -943,7 +953,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
</Text>
) : (
<ContextSummaryDisplay
openFiles={openFiles}
ideContext={ideContextState}
geminiMdFileCount={geminiMdFileCount}
contextFileNames={contextFileNames}
mcpServers={config.getMcpServers()}
@@ -963,7 +973,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
</Box>
</Box>
{showIDEContextDetail && (
<IDEContextDetailDisplay openFiles={openFiles} />
<IDEContextDetailDisplay
ideContext={ideContextState}
detectedIdeDisplay={config
.getIdeClient()
.getDetectedIdeDisplayName()}
/>
)}
{showErrorDetails && (
<OverflowProvider>

View File

@@ -62,6 +62,7 @@ describe('aboutCommand', () => {
});
it('should call addItem with all version info', async () => {
process.env.SANDBOX = '';
if (!aboutCommand.action) {
throw new Error('The about command must have an action.');
}

View File

@@ -40,14 +40,17 @@ describe('chatCommand', () => {
let mockGetChat: ReturnType<typeof vi.fn>;
let mockSaveCheckpoint: ReturnType<typeof vi.fn>;
let mockLoadCheckpoint: ReturnType<typeof vi.fn>;
let mockDeleteCheckpoint: ReturnType<typeof vi.fn>;
let mockGetHistory: ReturnType<typeof vi.fn>;
const getSubCommand = (name: 'list' | 'save' | 'resume'): SlashCommand => {
const getSubCommand = (
name: 'list' | 'save' | 'resume' | 'delete',
): SlashCommand => {
const subCommand = chatCommand.subCommands?.find(
(cmd) => cmd.name === name,
);
if (!subCommand) {
throw new Error(`/memory ${name} command not found.`);
throw new Error(`/chat ${name} command not found.`);
}
return subCommand;
};
@@ -59,6 +62,7 @@ describe('chatCommand', () => {
});
mockSaveCheckpoint = vi.fn().mockResolvedValue(undefined);
mockLoadCheckpoint = vi.fn().mockResolvedValue([]);
mockDeleteCheckpoint = vi.fn().mockResolvedValue(true);
mockContext = createMockCommandContext({
services: {
@@ -72,6 +76,7 @@ describe('chatCommand', () => {
logger: {
saveCheckpoint: mockSaveCheckpoint,
loadCheckpoint: mockLoadCheckpoint,
deleteCheckpoint: mockDeleteCheckpoint,
initialize: vi.fn().mockResolvedValue(undefined),
},
},
@@ -85,7 +90,7 @@ describe('chatCommand', () => {
it('should have the correct main command definition', () => {
expect(chatCommand.name).toBe('chat');
expect(chatCommand.description).toBe('Manage conversation history.');
expect(chatCommand.subCommands).toHaveLength(3);
expect(chatCommand.subCommands).toHaveLength(4);
});
describe('list subcommand', () => {
@@ -297,4 +302,63 @@ describe('chatCommand', () => {
});
});
});
describe('delete subcommand', () => {
let deleteCommand: SlashCommand;
const tag = 'my-tag';
beforeEach(() => {
deleteCommand = getSubCommand('delete');
});
it('should return an error if tag is missing', async () => {
const result = await deleteCommand?.action?.(mockContext, ' ');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat delete <tag>',
});
});
it('should return an error if checkpoint is not found', async () => {
mockDeleteCheckpoint.mockResolvedValue(false);
const result = await deleteCommand?.action?.(mockContext, tag);
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: `Error: No checkpoint found with tag '${tag}'.`,
});
});
it('should delete the conversation', async () => {
const result = await deleteCommand?.action?.(mockContext, tag);
expect(mockDeleteCheckpoint).toHaveBeenCalledWith(tag);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation checkpoint '${tag}' has been deleted.`,
});
});
describe('completion', () => {
it('should provide completion suggestions', async () => {
const fakeFiles = ['checkpoint-alpha.json', 'checkpoint-beta.json'];
mockFs.readdir.mockImplementation(
(async (_: string): Promise<string[]> =>
fakeFiles as string[]) as unknown as typeof fsPromises.readdir,
);
mockFs.stat.mockImplementation(
(async (_: string): Promise<Stats> =>
({
mtime: new Date(),
}) as Stats) as unknown as typeof fsPromises.stat,
);
const result = await deleteCommand?.completion?.(mockContext, 'a');
expect(result).toEqual(['alpha']);
});
});
});
});

View File

@@ -206,9 +206,49 @@ const resumeCommand: SlashCommand = {
},
};
const deleteCommand: SlashCommand = {
name: 'delete',
description: 'Delete a conversation checkpoint. Usage: /chat delete <tag>',
kind: CommandKind.BUILT_IN,
action: async (context, args): Promise<MessageActionReturn> => {
const tag = args.trim();
if (!tag) {
return {
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat delete <tag>',
};
}
const { logger } = context.services;
await logger.initialize();
const deleted = await logger.deleteCheckpoint(tag);
if (deleted) {
return {
type: 'message',
messageType: 'info',
content: `Conversation checkpoint '${tag}' has been deleted.`,
};
} else {
return {
type: 'message',
messageType: 'error',
content: `Error: No checkpoint found with tag '${tag}'.`,
};
}
},
completion: async (context, partialArg) => {
const chatDetails = await getSavedChatTags(context, true);
return chatDetails
.map((chat) => chat.name)
.filter((name) => name.startsWith(partialArg));
},
};
export const chatCommand: SlashCommand = {
name: 'chat',
description: 'Manage conversation history.',
kind: CommandKind.BUILT_IN,
subCommands: [listCommand, saveCommand, resumeCommand],
subCommands: [listCommand, saveCommand, resumeCommand, deleteCommand],
};

View File

@@ -0,0 +1,172 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { directoryCommand, expandHomeDir } from './directoryCommand.js';
import { Config, WorkspaceContext } from '@qwen-code/qwen-code-core';
import { CommandContext } from './types.js';
import { MessageType } from '../types.js';
import * as os from 'os';
import * as path from 'path';
describe('directoryCommand', () => {
let mockContext: CommandContext;
let mockConfig: Config;
let mockWorkspaceContext: WorkspaceContext;
const addCommand = directoryCommand.subCommands?.find(
(c) => c.name === 'add',
);
const showCommand = directoryCommand.subCommands?.find(
(c) => c.name === 'show',
);
beforeEach(() => {
mockWorkspaceContext = {
addDirectory: vi.fn(),
getDirectories: vi
.fn()
.mockReturnValue([
path.normalize('/home/user/project1'),
path.normalize('/home/user/project2'),
]),
} as unknown as WorkspaceContext;
mockConfig = {
getWorkspaceContext: () => mockWorkspaceContext,
isRestrictiveSandbox: vi.fn().mockReturnValue(false),
getGeminiClient: vi.fn().mockReturnValue({
addDirectoryContext: vi.fn(),
}),
} as unknown as Config;
mockContext = {
services: {
config: mockConfig,
},
ui: {
addItem: vi.fn(),
},
} as unknown as CommandContext;
});
describe('show', () => {
it('should display the list of directories', () => {
if (!showCommand?.action) throw new Error('No action');
showCommand.action(mockContext, '');
expect(mockWorkspaceContext.getDirectories).toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: `Current workspace directories:\n- ${path.normalize(
'/home/user/project1',
)}\n- ${path.normalize('/home/user/project2')}`,
}),
expect.any(Number),
);
});
});
describe('add', () => {
it('should show an error if no path is provided', () => {
if (!addCommand?.action) throw new Error('No action');
addCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Please provide at least one path to add.',
}),
expect.any(Number),
);
});
it('should call addDirectory and show a success message for a single path', async () => {
const newPath = path.normalize('/home/user/new-project');
if (!addCommand?.action) throw new Error('No action');
await addCommand.action(mockContext, newPath);
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: `Successfully added directories:\n- ${newPath}`,
}),
expect.any(Number),
);
});
it('should call addDirectory for each path and show a success message for multiple paths', async () => {
const newPath1 = path.normalize('/home/user/new-project1');
const newPath2 = path.normalize('/home/user/new-project2');
if (!addCommand?.action) throw new Error('No action');
await addCommand.action(mockContext, `${newPath1},${newPath2}`);
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath1);
expect(mockWorkspaceContext.addDirectory).toHaveBeenCalledWith(newPath2);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: `Successfully added directories:\n- ${newPath1}\n- ${newPath2}`,
}),
expect.any(Number),
);
});
it('should show an error if addDirectory throws an exception', async () => {
const error = new Error('Directory does not exist');
vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation(() => {
throw error;
});
const newPath = path.normalize('/home/user/invalid-project');
if (!addCommand?.action) throw new Error('No action');
await addCommand.action(mockContext, newPath);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: `Error adding '${newPath}': ${error.message}`,
}),
expect.any(Number),
);
});
it('should handle a mix of successful and failed additions', async () => {
const validPath = path.normalize('/home/user/valid-project');
const invalidPath = path.normalize('/home/user/invalid-project');
const error = new Error('Directory does not exist');
vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation(
(p: string) => {
if (p === invalidPath) {
throw error;
}
},
);
if (!addCommand?.action) throw new Error('No action');
await addCommand.action(mockContext, `${validPath},${invalidPath}`);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: `Successfully added directories:\n- ${validPath}`,
}),
expect.any(Number),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: `Error adding '${invalidPath}': ${error.message}`,
}),
expect.any(Number),
);
});
});
it('should correctly expand a Windows-style home directory path', () => {
const windowsPath = '%userprofile%\\Documents';
const expectedPath = path.win32.join(os.homedir(), 'Documents');
const result = expandHomeDir(windowsPath);
expect(path.win32.normalize(result)).toBe(
path.win32.normalize(expectedPath),
);
});
});

View File

@@ -0,0 +1,150 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { SlashCommand, CommandContext, CommandKind } from './types.js';
import { MessageType } from '../types.js';
import * as os from 'os';
import * as path from 'path';
export function expandHomeDir(p: string): string {
if (!p) {
return '';
}
let expandedPath = p;
if (p.toLowerCase().startsWith('%userprofile%')) {
expandedPath = os.homedir() + p.substring('%userprofile%'.length);
} else if (p.startsWith('~')) {
expandedPath = os.homedir() + p.substring(1);
}
return path.normalize(expandedPath);
}
export const directoryCommand: SlashCommand = {
name: 'directory',
altNames: ['dir'],
description: 'Manage workspace directories',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'add',
description:
'Add directories to the workspace. Use comma to separate multiple paths',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args: string) => {
const {
ui: { addItem },
services: { config },
} = context;
const [...rest] = args.split(' ');
if (!config) {
addItem(
{
type: MessageType.ERROR,
text: 'Configuration is not available.',
},
Date.now(),
);
return;
}
const workspaceContext = config.getWorkspaceContext();
const pathsToAdd = rest
.join(' ')
.split(',')
.filter((p) => p);
if (pathsToAdd.length === 0) {
addItem(
{
type: MessageType.ERROR,
text: 'Please provide at least one path to add.',
},
Date.now(),
);
return;
}
if (config.isRestrictiveSandbox()) {
return {
type: 'message' as const,
messageType: 'error' as const,
content:
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.',
};
}
const added: string[] = [];
const errors: string[] = [];
for (const pathToAdd of pathsToAdd) {
try {
workspaceContext.addDirectory(expandHomeDir(pathToAdd.trim()));
added.push(pathToAdd.trim());
} catch (e) {
const error = e as Error;
errors.push(`Error adding '${pathToAdd.trim()}': ${error.message}`);
}
}
if (added.length > 0) {
const gemini = config.getGeminiClient();
if (gemini) {
await gemini.addDirectoryContext();
}
addItem(
{
type: MessageType.INFO,
text: `Successfully added directories:\n- ${added.join('\n- ')}`,
},
Date.now(),
);
}
if (errors.length > 0) {
addItem(
{
type: MessageType.ERROR,
text: errors.join('\n'),
},
Date.now(),
);
}
},
},
{
name: 'show',
description: 'Show all directories in the workspace',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
const {
ui: { addItem },
services: { config },
} = context;
if (!config) {
addItem(
{
type: MessageType.ERROR,
text: 'Configuration is not available.',
},
Date.now(),
);
return;
}
const workspaceContext = config.getWorkspaceContext();
const directories = workspaceContext.getDirectories();
const directoryList = directories.map((dir) => `- ${dir}`).join('\n');
addItem(
{
type: MessageType.INFO,
text: `Current workspace directories:\n${directoryList}`,
},
Date.now(),
);
},
},
],
};

View File

@@ -4,37 +4,49 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { helpCommand } from './helpCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import { CommandKind } from './types.js';
describe('helpCommand', () => {
let mockContext: CommandContext;
const originalEnv = { ...process.env };
beforeEach(() => {
mockContext = {} as unknown as CommandContext;
mockContext = createMockCommandContext({
ui: {
addItem: vi.fn(),
},
} as unknown as CommandContext);
});
it("should return a dialog action and log a debug message for '/help'", () => {
const consoleDebugSpy = vi
.spyOn(console, 'debug')
.mockImplementation(() => {});
afterEach(() => {
process.env = { ...originalEnv };
vi.clearAllMocks();
});
it('should add a help message to the UI history', async () => {
if (!helpCommand.action) {
throw new Error('Help command has no action');
}
const result = helpCommand.action(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'help',
});
expect(consoleDebugSpy).toHaveBeenCalledWith('Opening help UI ...');
await helpCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.HELP,
timestamp: expect.any(Date),
}),
expect.any(Number),
);
});
it("should also be triggered by its alternative name '?'", () => {
// This test is more conceptual. The routing of altNames to the command
// is handled by the slash command processor, but we can assert the
// altNames is correctly defined on the command object itself.
expect(helpCommand.altNames).toContain('?');
it('should have the correct command properties', () => {
expect(helpCommand.name).toBe('help');
expect(helpCommand.kind).toBe(CommandKind.BUILT_IN);
expect(helpCommand.description).toBe('for help on Qwen Code');
});
});

View File

@@ -4,18 +4,20 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind, SlashCommand } from './types.js';
import { MessageType, type HistoryItemHelp } from '../types.js';
export const helpCommand: SlashCommand = {
name: 'help',
altNames: ['?'],
description: 'for help on Qwen Code',
kind: CommandKind.BUILT_IN,
action: (_context, _args): OpenDialogActionReturn => {
console.debug('Opening help UI ...');
return {
type: 'dialog',
dialog: 'help',
description: 'for help on Qwen Code',
action: async (context) => {
const helpItem: Omit<HistoryItemHelp, 'id'> = {
type: MessageType.HELP,
timestamp: new Date(),
};
context.ui.addItem(helpItem, Date.now());
},
};

View File

@@ -15,24 +15,16 @@ import {
} from 'vitest';
import { ideCommand } from './ideCommand.js';
import { type CommandContext } from './types.js';
import { type Config } from '@qwen-code/qwen-code-core';
import * as child_process from 'child_process';
import { glob } from 'glob';
import { IDEConnectionStatus } from '@qwen-code/qwen-code-core/index.js';
import { type Config, DetectedIde } from '@qwen-code/qwen-code-core';
import * as core from '@qwen-code/qwen-code-core';
vi.mock('child_process');
vi.mock('glob');
function regexEscape(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
vi.mock('@qwen-code/qwen-code-core');
describe('ideCommand', () => {
let mockContext: CommandContext;
let mockConfig: Config;
let execSyncSpy: MockInstance;
let globSyncSpy: MockInstance;
let platformSpy: MockInstance;
beforeEach(() => {
@@ -40,15 +32,21 @@ describe('ideCommand', () => {
ui: {
addItem: vi.fn(),
},
services: {
settings: {
setValue: vi.fn(),
},
},
} as unknown as CommandContext;
mockConfig = {
getIdeModeFeature: vi.fn(),
getIdeMode: vi.fn(),
getIdeClient: vi.fn(),
setIdeMode: vi.fn(),
setIdeClientDisconnected: vi.fn(),
} as unknown as Config;
execSyncSpy = vi.spyOn(child_process, 'execSync');
globSyncSpy = vi.spyOn(glob, 'sync');
platformSpy = vi.spyOn(process, 'platform', 'get');
});
@@ -56,51 +54,61 @@ describe('ideCommand', () => {
vi.restoreAllMocks();
});
it('should return null if ideMode is not enabled', () => {
vi.mocked(mockConfig.getIdeMode).mockReturnValue(false);
it('should return null if ideModeFeature is not enabled', () => {
vi.mocked(mockConfig.getIdeModeFeature).mockReturnValue(false);
const command = ideCommand(mockConfig);
expect(command).toBeNull();
});
it('should return the ide command if ideMode is enabled', () => {
it('should return the ide command if ideModeFeature is enabled', () => {
vi.mocked(mockConfig.getIdeModeFeature).mockReturnValue(true);
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
getCurrentIde: () => DetectedIde.VSCode,
getDetectedIdeDisplayName: () => 'VS Code',
} as ReturnType<Config['getIdeClient']>);
const command = ideCommand(mockConfig);
expect(command).not.toBeNull();
expect(command?.name).toBe('ide');
expect(command?.subCommands).toHaveLength(2);
expect(command?.subCommands?.[0].name).toBe('status');
expect(command?.subCommands?.[1].name).toBe('install');
expect(command?.subCommands).toHaveLength(3);
expect(command?.subCommands?.[0].name).toBe('disable');
expect(command?.subCommands?.[1].name).toBe('status');
expect(command?.subCommands?.[2].name).toBe('install');
});
describe('status subcommand', () => {
const mockGetConnectionStatus = vi.fn();
beforeEach(() => {
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
vi.mocked(mockConfig.getIdeModeFeature).mockReturnValue(true);
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
getConnectionStatus: mockGetConnectionStatus,
} as ReturnType<Config['getIdeClient']>);
getCurrentIde: () => DetectedIde.VSCode,
getDetectedIdeDisplayName: () => 'VS Code',
} as unknown as ReturnType<Config['getIdeClient']>);
});
it('should show connected status', () => {
mockGetConnectionStatus.mockReturnValue({
status: IDEConnectionStatus.Connected,
status: core.IDEConnectionStatus.Connected,
});
const command = ideCommand(mockConfig);
const result = command!.subCommands![0].action!(mockContext, '');
const result = command!.subCommands!.find((c) => c.name === 'status')!
.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: '🟢 Connected',
content: '🟢 Connected to VS Code',
});
});
it('should show connecting status', () => {
mockGetConnectionStatus.mockReturnValue({
status: IDEConnectionStatus.Connecting,
status: core.IDEConnectionStatus.Connecting,
});
const command = ideCommand(mockConfig);
const result = command!.subCommands![0].action!(mockContext, '');
const result = command!.subCommands!.find((c) => c.name === 'status')!
.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
@@ -110,10 +118,11 @@ describe('ideCommand', () => {
});
it('should show disconnected status', () => {
mockGetConnectionStatus.mockReturnValue({
status: IDEConnectionStatus.Disconnected,
status: core.IDEConnectionStatus.Disconnected,
});
const command = ideCommand(mockConfig);
const result = command!.subCommands![0].action!(mockContext, '');
const result = command!.subCommands!.find((c) => c.name === 'status')!
.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
@@ -125,11 +134,12 @@ describe('ideCommand', () => {
it('should show disconnected status with details', () => {
const details = 'Something went wrong';
mockGetConnectionStatus.mockReturnValue({
status: IDEConnectionStatus.Disconnected,
status: core.IDEConnectionStatus.Disconnected,
details,
});
const command = ideCommand(mockConfig);
const result = command!.subCommands![0].action!(mockContext, '');
const result = command!.subCommands!.find((c) => c.name === 'status')!
.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
@@ -140,128 +150,77 @@ describe('ideCommand', () => {
});
describe('install subcommand', () => {
const mockInstall = vi.fn();
beforeEach(() => {
vi.mocked(mockConfig.getIdeModeFeature).mockReturnValue(true);
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
getCurrentIde: () => DetectedIde.VSCode,
getConnectionStatus: vi.fn(),
getDetectedIdeDisplayName: () => 'VS Code',
} as unknown as ReturnType<Config['getIdeClient']>);
vi.mocked(core.getIdeInstaller).mockReturnValue({
install: mockInstall,
isInstalled: vi.fn(),
});
platformSpy.mockReturnValue('linux');
});
it('should show an error if VSCode is not installed', async () => {
execSyncSpy.mockImplementation(() => {
throw new Error('Command not found');
it('should install the extension', async () => {
mockInstall.mockResolvedValue({
success: true,
message: 'Successfully installed.',
});
const command = ideCommand(mockConfig);
await command!.subCommands![1].action!(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
text: expect.stringMatching(/VS Code command-line tool .* not found/),
}),
expect.any(Number),
await command!.subCommands!.find((c) => c.name === 'install')!.action!(
mockContext,
'',
);
});
it('should show an error if the VSIX file is not found', async () => {
execSyncSpy.mockReturnValue(''); // VSCode is installed
globSyncSpy.mockReturnValue([]); // No .vsix file found
const command = ideCommand(mockConfig);
await command!.subCommands![1].action!(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
text: 'Could not find the required VS Code companion extension. Please file a bug via /bug.',
}),
expect.any(Number),
);
});
it('should install the extension if found in the bundle directory', async () => {
const vsixPath = '/path/to/bundle/gemini.vsix';
execSyncSpy.mockReturnValue(''); // VSCode is installed
globSyncSpy.mockReturnValue([vsixPath]); // Found .vsix file
const command = ideCommand(mockConfig);
await command!.subCommands![1].action!(mockContext, '');
expect(globSyncSpy).toHaveBeenCalledWith(
expect.stringContaining('.vsix'),
);
expect(execSyncSpy).toHaveBeenCalledWith(
expect.stringMatching(
new RegExp(
`code(.cmd)? --install-extension ${regexEscape(vsixPath)} --force`,
),
),
{ stdio: 'pipe' },
);
expect(core.getIdeInstaller).toHaveBeenCalledWith('vscode');
expect(mockInstall).toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: `Installing VS Code companion extension...`,
text: `Installing IDE companion...`,
}),
expect.any(Number),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: 'VS Code companion extension installed successfully. Restart gemini-cli in a fresh terminal window.',
}),
expect.any(Number),
);
});
it('should install the extension if found in the dev directory', async () => {
const vsixPath = '/path/to/dev/gemini.vsix';
execSyncSpy.mockReturnValue(''); // VSCode is installed
// First glob call for bundle returns nothing, second for dev returns path.
globSyncSpy.mockReturnValueOnce([]).mockReturnValueOnce([vsixPath]);
const command = ideCommand(mockConfig);
await command!.subCommands![1].action!(mockContext, '');
expect(globSyncSpy).toHaveBeenCalledTimes(2);
expect(execSyncSpy).toHaveBeenCalledWith(
expect.stringMatching(
new RegExp(
`code(.cmd)? --install-extension ${regexEscape(vsixPath)} --force`,
),
),
{ stdio: 'pipe' },
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: 'VS Code companion extension installed successfully. Restart gemini-cli in a fresh terminal window.',
text: 'Successfully installed.',
}),
expect.any(Number),
);
});
it('should show an error if installation fails', async () => {
const vsixPath = '/path/to/bundle/gemini.vsix';
const errorMessage = 'Installation failed';
execSyncSpy
.mockReturnValueOnce('') // VSCode is installed check
.mockImplementation(() => {
// Installation command
const error: Error & { stderr?: Buffer } = new Error(
'Command failed',
);
error.stderr = Buffer.from(errorMessage);
throw error;
});
globSyncSpy.mockReturnValue([vsixPath]);
mockInstall.mockResolvedValue({
success: false,
message: 'Installation failed.',
});
const command = ideCommand(mockConfig);
await command!.subCommands![1].action!(mockContext, '');
await command!.subCommands!.find((c) => c.name === 'install')!.action!(
mockContext,
'',
);
expect(core.getIdeInstaller).toHaveBeenCalledWith('vscode');
expect(mockInstall).toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: `Installing IDE companion...`,
}),
expect.any(Number),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
text: `Failed to install VS Code companion extension.`,
text: 'Installation failed.',
}),
expect.any(Number),
);

View File

@@ -4,154 +4,158 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { fileURLToPath } from 'url';
import { Config, IDEConnectionStatus } from '@qwen-code/qwen-code-core';
import {
Config,
DetectedIde,
IDEConnectionStatus,
getIdeDisplayName,
getIdeInstaller,
} from '@qwen-code/qwen-code-core';
import {
CommandContext,
SlashCommand,
SlashCommandActionReturn,
CommandKind,
} from './types.js';
import * as child_process from 'child_process';
import * as process from 'process';
import { glob } from 'glob';
import * as path from 'path';
const VSCODE_COMMAND = process.platform === 'win32' ? 'code.cmd' : 'code';
const VSCODE_COMPANION_EXTENSION_FOLDER = 'vscode-ide-companion';
function isVSCodeInstalled(): boolean {
try {
child_process.execSync(
process.platform === 'win32'
? `where.exe ${VSCODE_COMMAND}`
: `command -v ${VSCODE_COMMAND}`,
{ stdio: 'ignore' },
);
return true;
} catch {
return false;
}
}
import { SettingScope } from '../../config/settings.js';
export const ideCommand = (config: Config | null): SlashCommand | null => {
if (!config?.getIdeMode()) {
if (!config || !config.getIdeModeFeature()) {
return null;
}
const ideClient = config.getIdeClient();
const currentIDE = ideClient.getCurrentIde();
if (!currentIDE || !ideClient.getDetectedIdeDisplayName()) {
return {
name: 'ide',
description: 'manage IDE integration',
kind: CommandKind.BUILT_IN,
action: (): SlashCommandActionReturn =>
({
type: 'message',
messageType: 'error',
content: `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: ${Object.values(
DetectedIde,
)
.map((ide) => getIdeDisplayName(ide))
.join(', ')}`,
}) as const,
};
}
return {
const ideSlashCommand: SlashCommand = {
name: 'ide',
description: 'manage IDE integration',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'status',
description: 'check status of IDE integration',
kind: CommandKind.BUILT_IN,
action: (_context: CommandContext): SlashCommandActionReturn => {
const connection = config.getIdeClient()?.getConnectionStatus();
switch (connection?.status) {
case IDEConnectionStatus.Connected:
return {
type: 'message',
messageType: 'info',
content: `🟢 Connected`,
} 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;
}
}
},
},
{
name: 'install',
description: 'install required VS Code companion extension',
kind: CommandKind.BUILT_IN,
action: async (context) => {
if (!isVSCodeInstalled()) {
context.ui.addItem(
{
type: 'error',
text: `VS Code command-line tool "${VSCODE_COMMAND}" not found in your PATH.`,
},
Date.now(),
);
return;
}
const bundleDir = path.dirname(fileURLToPath(import.meta.url));
// The VSIX file is copied to the bundle directory as part of the build.
let vsixFiles = glob.sync(path.join(bundleDir, '*.vsix'));
if (vsixFiles.length === 0) {
// If the VSIX file is not in the bundle, it might be a dev
// environment running with `npm start`. Look for it in the original
// package location, relative to the bundle dir.
const devPath = path.join(
bundleDir,
'..',
'..',
'..',
'..',
'..',
VSCODE_COMPANION_EXTENSION_FOLDER,
'*.vsix',
);
vsixFiles = glob.sync(devPath);
}
if (vsixFiles.length === 0) {
context.ui.addItem(
{
type: 'error',
text: 'Could not find the required VS Code companion extension. Please file a bug via /bug.',
},
Date.now(),
);
return;
}
const vsixPath = vsixFiles[0];
const command = `${VSCODE_COMMAND} --install-extension ${vsixPath} --force`;
context.ui.addItem(
{
type: 'info',
text: `Installing VS Code companion extension...`,
},
Date.now(),
);
try {
child_process.execSync(command, { stdio: 'pipe' });
context.ui.addItem(
{
type: 'info',
text: 'VS Code companion extension installed successfully. Restart gemini-cli in a fresh terminal window.',
},
Date.now(),
);
} catch (_error) {
context.ui.addItem(
{
type: 'error',
text: `Failed to install VS Code companion extension.`,
},
Date.now(),
);
}
},
},
],
subCommands: [],
};
const statusCommand: SlashCommand = {
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;
}
}
},
};
const installCommand: SlashCommand = {
name: 'install',
description: `install required IDE companion for ${ideClient.getDetectedIdeDisplayName()}`,
kind: CommandKind.BUILT_IN,
action: async (context) => {
const installer = getIdeInstaller(currentIDE);
if (!installer) {
context.ui.addItem(
{
type: 'error',
text: `No installer is available for ${ideClient.getDetectedIdeDisplayName()}. Please install the IDE companion manually from its marketplace.`,
},
Date.now(),
);
return;
}
context.ui.addItem(
{
type: 'info',
text: `Installing IDE companion...`,
},
Date.now(),
);
const result = await installer.install();
context.ui.addItem(
{
type: result.success ? 'info' : 'error',
text: result.message,
},
Date.now(),
);
},
};
const enableCommand: SlashCommand = {
name: 'enable',
description: 'enable IDE integration',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
context.services.settings.setValue(SettingScope.User, 'ideMode', true);
config.setIdeMode(true);
config.setIdeClientConnected();
},
};
const disableCommand: SlashCommand = {
name: 'disable',
description: 'disable IDE integration',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
context.services.settings.setValue(SettingScope.User, 'ideMode', false);
config.setIdeMode(false);
config.setIdeClientDisconnected();
},
};
const ideModeEnabled = config.getIdeMode();
if (ideModeEnabled) {
ideSlashCommand.subCommands = [
disableCommand,
statusCommand,
installCommand,
];
} else {
ideSlashCommand.subCommands = [
enableCommand,
statusCommand,
installCommand,
];
}
return ideSlashCommand;
};

View File

@@ -0,0 +1,102 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import { initCommand } from './initCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { type CommandContext } from './types.js';
// Mock the 'fs' module
vi.mock('fs', () => ({
existsSync: vi.fn(),
writeFileSync: vi.fn(),
}));
describe('initCommand', () => {
let mockContext: CommandContext;
const targetDir = '/test/dir';
const geminiMdPath = path.join(targetDir, 'GEMINI.md');
beforeEach(() => {
// Create a fresh mock context for each test
mockContext = createMockCommandContext({
services: {
config: {
getTargetDir: () => targetDir,
},
},
});
});
afterEach(() => {
// Clear all mocks after each test
vi.clearAllMocks();
});
it('should inform the user if GEMINI.md already exists', async () => {
// Arrange: Simulate that the file exists
vi.mocked(fs.existsSync).mockReturnValue(true);
// Act: Run the command's action
const result = await initCommand.action!(mockContext, '');
// Assert: Check for the correct informational message
expect(result).toEqual({
type: 'message',
messageType: 'info',
content:
'A GEMINI.md file already exists in this directory. No changes were made.',
});
// Assert: Ensure no file was written
expect(fs.writeFileSync).not.toHaveBeenCalled();
});
it('should create GEMINI.md and submit a prompt if it does not exist', async () => {
// Arrange: Simulate that the file does not exist
vi.mocked(fs.existsSync).mockReturnValue(false);
// Act: Run the command's action
const result = await initCommand.action!(mockContext, '');
// Assert: Check that writeFileSync was called correctly
expect(fs.writeFileSync).toHaveBeenCalledWith(geminiMdPath, '', 'utf8');
// Assert: Check that an informational message was added to the UI
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: 'info',
text: 'Empty GEMINI.md created. Now analyzing the project to populate it.',
},
expect.any(Number),
);
// Assert: Check that the correct prompt is submitted
expect(result.type).toBe('submit_prompt');
expect(result.content).toContain(
'You are an AI agent that brings the power of Gemini',
);
});
it('should return an error if config is not available', async () => {
// Arrange: Create a context without config
const noConfigContext = createMockCommandContext();
if (noConfigContext.services) {
noConfigContext.services.config = null;
}
// Act: Run the command's action
const result = await initCommand.action!(noConfigContext, '');
// Assert: Check for the correct error message
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
});
});
});

View File

@@ -0,0 +1,93 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'fs';
import * as path from 'path';
import {
CommandContext,
SlashCommand,
SlashCommandActionReturn,
CommandKind,
} from './types.js';
export const initCommand: SlashCommand = {
name: 'init',
description: 'Analyzes the project and creates a tailored GEMINI.md file.',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
_args: string,
): Promise<SlashCommandActionReturn> => {
if (!context.services.config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
const targetDir = context.services.config.getTargetDir();
const geminiMdPath = path.join(targetDir, 'GEMINI.md');
if (fs.existsSync(geminiMdPath)) {
return {
type: 'message',
messageType: 'info',
content:
'A GEMINI.md file already exists in this directory. No changes were made.',
};
}
// Create an empty GEMINI.md file
fs.writeFileSync(geminiMdPath, '', 'utf8');
context.ui.addItem(
{
type: 'info',
text: 'Empty GEMINI.md created. Now analyzing the project to populate it.',
},
Date.now(),
);
return {
type: 'submit_prompt',
content: `
You are an AI agent that brings the power of Gemini directly into the terminal. Your task is to analyze the current directory and generate a comprehensive GEMINI.md file to be used as instructional context for future interactions.
**Analysis Process:**
1. **Initial Exploration:**
* Start by listing the files and directories to get a high-level overview of the structure.
* Read the README file (e.g., \`README.md\`, \`README.txt\`) if it exists. This is often the best place to start.
2. **Iterative Deep Dive (up to 10 files):**
* Based on your initial findings, select a few files that seem most important (e.g., configuration files, main source files, documentation).
* Read them. As you learn more, refine your understanding and decide which files to read next. You don't need to decide all 10 files at once. Let your discoveries guide your exploration.
3. **Identify Project Type:**
* **Code Project:** Look for clues like \`package.json\`, \`requirements.txt\`, \`pom.xml\`, \`go.mod\`, \`Cargo.toml\`, \`build.gradle\`, or a \`src\` directory. If you find them, this is likely a software project.
* **Non-Code Project:** If you don't find code-related files, this might be a directory for documentation, research papers, notes, or something else.
**GEMINI.md Content Generation:**
**For a Code Project:**
* **Project Overview:** Write a clear and concise summary of the project's purpose, main technologies, and architecture.
* **Building and Running:** Document the key commands for building, running, and testing the project. Infer these from the files you've read (e.g., \`scripts\` in \`package.json\`, \`Makefile\`, etc.). If you can't find explicit commands, provide a placeholder with a TODO.
* **Development Conventions:** Describe any coding styles, testing practices, or contribution guidelines you can infer from the codebase.
**For a Non-Code Project:**
* **Directory Overview:** Describe the purpose and contents of the directory. What is it for? What kind of information does it hold?
* **Key Files:** List the most important files and briefly explain what they contain.
* **Usage:** Explain how the contents of this directory are intended to be used.
**Final Output:**
Write the complete content to the \`GEMINI.md\` file. The output must be well-formatted Markdown.
`,
};
},
};

View File

@@ -14,15 +14,10 @@ import {
getMCPDiscoveryState,
DiscoveredMCPTool,
} from '@qwen-code/qwen-code-core';
import open from 'open';
import { MessageActionReturn } from './types.js';
import { Type, CallableTool } from '@google/genai';
// Mock external dependencies
vi.mock('open', () => ({
default: vi.fn(),
}));
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
@@ -144,30 +139,15 @@ describe('mcpCommand', () => {
mockConfig.getMcpServers = vi.fn().mockReturnValue({});
});
it('should display a message with a URL when no MCP servers are configured in a sandbox', async () => {
process.env.SANDBOX = 'sandbox';
it('should display a message with a URL when no MCP servers are configured', async () => {
const result = await mcpCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content:
'No MCP servers configured. Please open the following URL in your browser to view documentation:\nhttps://goo.gle/gemini-cli-docs-mcp',
'No MCP servers configured. Please view MCP documentation in your browser: https://goo.gle/gemini-cli-docs-mcp or use the cli /docs command',
});
expect(open).not.toHaveBeenCalled();
});
it('should display a message and open a URL when no MCP servers are configured outside a sandbox', async () => {
const result = await mcpCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content:
'No MCP servers configured. Opening documentation in your browser: https://goo.gle/gemini-cli-docs-mcp',
});
expect(open).toHaveBeenCalledWith('https://goo.gle/gemini-cli-docs-mcp');
});
});
@@ -232,9 +212,9 @@ describe('mcpCommand', () => {
);
expect(message).toContain('server2_tool1');
// Server 3 - Disconnected
// Server 3 - Disconnected but with cached tools, so shows as Ready
expect(message).toContain(
'🔴 \u001b[1mserver3\u001b[0m - Disconnected (1 tools cached)',
'🟢 \u001b[1mserver3\u001b[0m - Ready (1 tool)',
);
expect(message).toContain('server3_tool1');

View File

@@ -21,7 +21,6 @@ import {
mcpServerRequiresOAuth,
getErrorMessage,
} from '@qwen-code/qwen-code-core';
import open from 'open';
const COLOR_GREEN = '\u001b[32m';
const COLOR_YELLOW = '\u001b[33m';
@@ -60,21 +59,11 @@ const getMcpStatus = async (
if (serverNames.length === 0 && blockedMcpServers.length === 0) {
const docsUrl = 'https://goo.gle/gemini-cli-docs-mcp';
if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') {
return {
type: 'message',
messageType: 'info',
content: `No MCP servers configured. Please open the following URL in your browser to view documentation:\n${docsUrl}`,
};
} else {
// Open the URL in the browser
await open(docsUrl);
return {
type: 'message',
messageType: 'info',
content: `No MCP servers configured. Opening documentation in your browser: ${docsUrl}`,
};
}
return {
type: 'message',
messageType: 'info',
content: `No MCP servers configured. Please view MCP documentation in your browser: ${docsUrl} or use the cli /docs command`,
};
}
// Check if any servers are still connecting
@@ -105,7 +94,15 @@ const getMcpStatus = async (
const promptRegistry = await config.getPromptRegistry();
const serverPrompts = promptRegistry.getPromptsByServer(serverName) || [];
const status = getMCPServerStatus(serverName);
const originalStatus = getMCPServerStatus(serverName);
const hasCachedItems = serverTools.length > 0 || serverPrompts.length > 0;
// If the server is "disconnected" but has prompts or cached tools, display it as Ready
// by using CONNECTED as the display status.
const status =
originalStatus === MCPServerStatus.DISCONNECTED && hasCachedItems
? MCPServerStatus.CONNECTED
: originalStatus;
// Add status indicator with descriptive text
let statusIndicator = '';
@@ -271,11 +268,14 @@ const getMcpStatus = async (
message += ' No tools or prompts available\n';
} else if (serverTools.length === 0) {
message += ' No tools available';
if (status === MCPServerStatus.DISCONNECTED && needsAuthHint) {
if (originalStatus === MCPServerStatus.DISCONNECTED && needsAuthHint) {
message += ` ${COLOR_GREY}(type: "/mcp auth ${serverName}" to authenticate this server)${RESET_COLOR}`;
}
message += '\n';
} else if (status === MCPServerStatus.DISCONNECTED && needsAuthHint) {
} else if (
originalStatus === MCPServerStatus.DISCONNECTED &&
needsAuthHint
) {
// This case is for when serverTools.length > 0
message += ` ${COLOR_GREY}(type: "/mcp auth ${serverName}" to authenticate this server)${RESET_COLOR}\n`;
}

View File

@@ -92,6 +92,7 @@ export const memoryCommand: SlashCommand = {
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,
);

View File

@@ -0,0 +1,66 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, expect, it, afterEach, beforeEach } from 'vitest';
import * as child_process from 'child_process';
import { setupGithubCommand } from './setupGithubCommand.js';
import { CommandContext, ToolActionReturn } from './types.js';
vi.mock('child_process');
describe('setupGithubCommand', () => {
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('returns a tool action to download github workflows and handles paths', () => {
const fakeRepoRoot = '/github.com/fake/repo/root';
vi.mocked(child_process.execSync).mockReturnValue(fakeRepoRoot);
const result = setupGithubCommand.action?.(
{} as CommandContext,
'',
) as ToolActionReturn;
expect(result.type).toBe('tool');
expect(result.toolName).toBe('run_shell_command');
expect(child_process.execSync).toHaveBeenCalledWith(
'git rev-parse --show-toplevel',
{
encoding: 'utf-8',
},
);
expect(child_process.execSync).toHaveBeenCalledWith('git remote -v', {
encoding: 'utf-8',
});
const { command } = result.toolArgs;
const expectedSubstrings = [
`mkdir -p "${fakeRepoRoot}/.github/workflows"`,
`curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-cli.yml"`,
`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/',
];
for (const substring of expectedSubstrings) {
expect(command).toContain(substring);
}
});
it('throws an error if git root cannot be determined', () => {
vi.mocked(child_process.execSync).mockReturnValue('');
expect(() => {
setupGithubCommand.action?.({} as CommandContext, '');
}).toThrow('Unable to determine the Git root directory.');
});
});

View File

@@ -0,0 +1,59 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'path';
import { execSync } from 'child_process';
import { isGitHubRepository } from '../../utils/gitUtils.js';
import {
CommandKind,
SlashCommand,
SlashCommandActionReturn,
} from './types.js';
export const setupGithubCommand: SlashCommand = {
name: 'setup-github',
description: 'Set up GitHub Actions',
kind: CommandKind.BUILT_IN,
action: (): SlashCommandActionReturn => {
const gitRootRepo = execSync('git rev-parse --show-toplevel', {
encoding: 'utf-8',
}).trim();
if (!isGitHubRepository()) {
throw new Error('Unable to determine the Git root directory.');
}
const version = 'v0';
const workflowBaseUrl = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/heads/${version}/examples/workflows/`;
const workflows = [
'gemini-cli/gemini-cli.yml',
'issue-triage/gemini-issue-automated-triage.yml',
'issue-triage/gemini-issue-scheduled-triage.yml',
'pr-review/gemini-pr-review.yml',
];
const command = [
'set -e',
`mkdir -p "${gitRootRepo}/.github/workflows"`,
...workflows.map((workflow) => {
const fileName = path.basename(workflow);
return `curl -fsSL -o "${gitRootRepo}/.github/workflows/${fileName}" "${workflowBaseUrl}/${workflow}"`;
}),
'echo "Workflows downloaded successfully."',
].join(' && ');
return {
type: 'tool',
toolName: 'run_shell_command',
toolArgs: {
description:
'Setting up GitHub Actions to triage issues and review PRs with Gemini.',
command,
},
};
},
};

View File

@@ -99,7 +99,7 @@ export interface MessageActionReturn {
*/
export interface OpenDialogActionReturn {
type: 'dialog';
dialog: 'help' | 'auth' | 'theme' | 'editor' | 'privacy';
dialog: 'auth' | 'theme' | 'editor' | 'privacy';
}
/**
@@ -158,6 +158,9 @@ export interface SlashCommand {
kind: CommandKind;
// Optional metadata for extension commands
extensionName?: string;
// The action to run. Optional for parent commands that only group sub-commands.
action?: (
context: CommandContext,

View File

@@ -8,7 +8,7 @@ import React from 'react';
import { Text } from 'ink';
import { Colors } from '../colors.js';
import {
type OpenFiles,
type IdeContext,
type MCPServerConfig,
} from '@qwen-code/qwen-code-core';
@@ -18,7 +18,7 @@ interface ContextSummaryDisplayProps {
mcpServers?: Record<string, MCPServerConfig>;
blockedMcpServers?: Array<{ name: string; extensionName: string }>;
showToolDescriptions?: boolean;
openFiles?: OpenFiles;
ideContext?: IdeContext;
}
export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
@@ -27,26 +27,28 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
mcpServers,
blockedMcpServers,
showToolDescriptions,
openFiles,
ideContext,
}) => {
const mcpServerCount = Object.keys(mcpServers || {}).length;
const blockedMcpServerCount = blockedMcpServers?.length || 0;
const openFileCount = ideContext?.workspaceState?.openFiles?.length ?? 0;
if (
geminiMdFileCount === 0 &&
mcpServerCount === 0 &&
blockedMcpServerCount === 0 &&
(openFiles?.recentOpenFiles?.length ?? 0) === 0
openFileCount === 0
) {
return <Text> </Text>; // Render an empty space to reserve height
}
const recentFilesText = (() => {
const count = openFiles?.recentOpenFiles?.length ?? 0;
if (count === 0) {
const openFilesText = (() => {
if (openFileCount === 0) {
return '';
}
return `${count} recent file${count > 1 ? 's' : ''} (ctrl+e to view)`;
return `${openFileCount} open file${
openFileCount > 1 ? 's' : ''
} (ctrl+e to view)`;
})();
const geminiMdText = (() => {
@@ -84,8 +86,8 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
let summaryText = 'Using: ';
const summaryParts = [];
if (recentFilesText) {
summaryParts.push(recentFilesText);
if (openFilesText) {
summaryParts.push(openFilesText);
}
if (geminiMdText) {
summaryParts.push(geminiMdText);

View File

@@ -0,0 +1,32 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Text, useInput } from 'ink';
import { useEffect, useRef, useState } from 'react';
import { Colors } from '../colors.js';
export const DebugProfiler = () => {
const numRenders = useRef(0);
const [showNumRenders, setShowNumRenders] = useState(false);
useEffect(() => {
numRenders.current++;
});
useInput((input, key) => {
if (key.ctrl && input === 'b') {
setShowNumRenders((prev) => !prev);
}
});
if (!showNumRenders) {
return null;
}
return (
<Text color={Colors.AccentYellow}>Renders: {numRenders.current} </Text>
);
};

View File

@@ -17,6 +17,8 @@ import process from 'node:process';
import Gradient from 'ink-gradient';
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
import { DebugProfiler } from './DebugProfiler.js';
interface FooterProps {
model: string;
targetDir: string;
@@ -52,6 +54,7 @@ export const Footer: React.FC<FooterProps> = ({
return (
<Box justifyContent="space-between" width="100%">
<Box>
{debugMode && <DebugProfiler />}
{vimMode && <Text color={Colors.Gray}>[{vimMode}] </Text>}
{nightly ? (
<Gradient colors={Colors.GradientColors}>

View File

@@ -103,9 +103,15 @@ export const Help: React.FC<Help> = ({ commands }) => (
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Enter
Alt+Left/Right
</Text>{' '}
- Send message
- Jump through words in the input
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Ctrl+C
</Text>{' '}
- Quit application
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
@@ -117,21 +123,15 @@ export const Help: React.FC<Help> = ({ commands }) => (
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Up/Down
Ctrl+L
</Text>{' '}
- Cycle through your prompt history
- Clear the screen
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Alt+Left/Right
{process.platform === 'darwin' ? 'Ctrl+X / Meta+Enter' : 'Ctrl+X'}
</Text>{' '}
- Jump through words in the input
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Shift+Tab
</Text>{' '}
- Toggle auto-accepting edits
- Open input in external editor
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
@@ -139,6 +139,12 @@ export const Help: React.FC<Help> = ({ commands }) => (
</Text>{' '}
- Toggle YOLO mode
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Enter
</Text>{' '}
- Send message
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Esc
@@ -147,9 +153,22 @@ export const Help: React.FC<Help> = ({ commands }) => (
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Ctrl+C
Shift+Tab
</Text>{' '}
- Quit application
- Toggle auto-accepting edits
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>
Up/Down
</Text>{' '}
- Cycle through your prompt history
</Text>
<Box height={1} />
<Text color={Colors.Foreground}>
For a full list of shortcuts, see{' '}
<Text bold color={Colors.AccentPurple}>
docs/keyboard-shortcuts.md
</Text>
</Text>
</Box>
);

View File

@@ -35,6 +35,18 @@ describe('<HistoryItemDisplay />', () => {
expect(lastFrame()).toContain('Hello');
});
it('renders UserMessage for "user" type with slash command', () => {
const item: HistoryItem = {
...baseItem,
type: MessageType.USER,
text: '/theme',
};
const { lastFrame } = render(
<HistoryItemDisplay {...baseItem} item={item} />,
);
expect(lastFrame()).toContain('/theme');
});
it('renders StatsDisplay for "stats" type', () => {
const item: HistoryItem = {
...baseItem,

View File

@@ -21,6 +21,8 @@ import { ModelStatsDisplay } from './ModelStatsDisplay.js';
import { ToolStatsDisplay } from './ToolStatsDisplay.js';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import { Config } from '@qwen-code/qwen-code-core';
import { Help } from './Help.js';
import { SlashCommand } from '../commands/types.js';
interface HistoryItemDisplayProps {
item: HistoryItem;
@@ -29,6 +31,7 @@ interface HistoryItemDisplayProps {
isPending: boolean;
config?: Config;
isFocused?: boolean;
commands?: readonly SlashCommand[];
}
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
@@ -37,6 +40,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
terminalWidth,
isPending,
config,
commands,
isFocused = true,
}) => (
<Box flexDirection="column" key={item.id}>
@@ -71,6 +75,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
gcpProject={item.gcpProject}
/>
)}
{item.type === 'help' && commands && <Help commands={commands} />}
{item.type === 'stats' && <StatsDisplay duration={item.duration} />}
{item.type === 'model_stats' && <ModelStatsDisplay />}
{item.type === 'tool_stats' && <ToolStatsDisplay />}

View File

@@ -4,26 +4,24 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { type File, type IdeContext } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import { type OpenFiles } from '@qwen-code/qwen-code-core';
import { Colors } from '../colors.js';
import path from 'node:path';
import { Colors } from '../colors.js';
interface IDEContextDetailDisplayProps {
openFiles: OpenFiles | undefined;
ideContext: IdeContext | undefined;
detectedIdeDisplay: string | undefined;
}
export function IDEContextDetailDisplay({
openFiles,
ideContext,
detectedIdeDisplay,
}: IDEContextDetailDisplayProps) {
if (
!openFiles ||
!openFiles.recentOpenFiles ||
openFiles.recentOpenFiles.length === 0
) {
const openFiles = ideContext?.workspaceState?.openFiles;
if (!openFiles || openFiles.length === 0) {
return null;
}
const recentFiles = openFiles.recentOpenFiles || [];
return (
<Box
@@ -34,15 +32,16 @@ export function IDEContextDetailDisplay({
paddingX={1}
>
<Text color={Colors.AccentCyan} bold>
IDE Context (ctrl+e to toggle)
{detectedIdeDisplay ? detectedIdeDisplay : 'IDE'} Context (ctrl+e to
toggle)
</Text>
{recentFiles.length > 0 && (
{openFiles.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold>Recent files:</Text>
{recentFiles.map((file) => (
<Text key={file.filePath}>
- {path.basename(file.filePath)}
{file.filePath === openFiles.activeFile ? ' (active)' : ''}
<Text bold>Open files:</Text>
{openFiles.map((file: File) => (
<Text key={file.path}>
- {path.basename(file.path)}
{file.isActive ? ' (active)' : ''}
</Text>
))}
</Box>

View File

@@ -19,7 +19,10 @@ import {
useShellHistory,
UseShellHistoryReturn,
} from '../hooks/useShellHistory.js';
import { useCompletion, UseCompletionReturn } from '../hooks/useCompletion.js';
import {
useCommandCompletion,
UseCommandCompletionReturn,
} from '../hooks/useCommandCompletion.js';
import {
useInputHistory,
UseInputHistoryReturn,
@@ -28,7 +31,7 @@ import * as clipboardUtils from '../utils/clipboardUtils.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
vi.mock('../hooks/useShellHistory.js');
vi.mock('../hooks/useCompletion.js');
vi.mock('../hooks/useCommandCompletion.js');
vi.mock('../hooks/useInputHistory.js');
vi.mock('../utils/clipboardUtils.js');
@@ -83,13 +86,13 @@ const mockSlashCommands: SlashCommand[] = [
describe('InputPrompt', () => {
let props: InputPromptProps;
let mockShellHistory: UseShellHistoryReturn;
let mockCompletion: UseCompletionReturn;
let mockCommandCompletion: UseCommandCompletionReturn;
let mockInputHistory: UseInputHistoryReturn;
let mockBuffer: TextBuffer;
let mockCommandContext: CommandContext;
const mockedUseShellHistory = vi.mocked(useShellHistory);
const mockedUseCompletion = vi.mocked(useCompletion);
const mockedUseCommandCompletion = vi.mocked(useCommandCompletion);
const mockedUseInputHistory = vi.mocked(useInputHistory);
beforeEach(() => {
@@ -115,7 +118,9 @@ describe('InputPrompt', () => {
visualScrollRow: 0,
handleInput: vi.fn(),
move: vi.fn(),
moveToOffset: vi.fn(),
moveToOffset: (offset: number) => {
mockBuffer.cursor = [0, offset];
},
killLineRight: vi.fn(),
killLineLeft: vi.fn(),
openInExternalEditor: vi.fn(),
@@ -133,6 +138,7 @@ describe('InputPrompt', () => {
} as unknown as TextBuffer;
mockShellHistory = {
history: [],
addCommandToHistory: vi.fn(),
getPreviousCommand: vi.fn().mockReturnValue(null),
getNextCommand: vi.fn().mockReturnValue(null),
@@ -140,7 +146,7 @@ describe('InputPrompt', () => {
};
mockedUseShellHistory.mockReturnValue(mockShellHistory);
mockCompletion = {
mockCommandCompletion = {
suggestions: [],
activeSuggestionIndex: -1,
isLoadingSuggestions: false,
@@ -154,7 +160,7 @@ describe('InputPrompt', () => {
setShowSuggestions: vi.fn(),
handleAutocomplete: vi.fn(),
};
mockedUseCompletion.mockReturnValue(mockCompletion);
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);
mockInputHistory = {
navigateUp: vi.fn(),
@@ -172,6 +178,9 @@ describe('InputPrompt', () => {
getProjectRoot: () => path.join('test', 'project'),
getTargetDir: () => path.join('test', 'project', 'src'),
getVimMode: () => false,
getWorkspaceContext: () => ({
getDirectories: () => ['/test/project/src'],
}),
} as unknown as Config,
slashCommands: mockSlashCommands,
commandContext: mockCommandContext,
@@ -262,8 +271,8 @@ describe('InputPrompt', () => {
});
it('should call completion.navigateUp for both up arrow and Ctrl+P when suggestions are showing', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [
{ label: 'memory', value: 'memory' },
@@ -282,15 +291,15 @@ describe('InputPrompt', () => {
stdin.write('\u0010'); // Ctrl+P
await wait();
expect(mockCompletion.navigateUp).toHaveBeenCalledTimes(2);
expect(mockCompletion.navigateDown).not.toHaveBeenCalled();
expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(2);
expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled();
unmount();
});
it('should call completion.navigateDown for both down arrow and Ctrl+N when suggestions are showing', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [
{ label: 'memory', value: 'memory' },
@@ -308,15 +317,15 @@ describe('InputPrompt', () => {
stdin.write('\u000E'); // Ctrl+N
await wait();
expect(mockCompletion.navigateDown).toHaveBeenCalledTimes(2);
expect(mockCompletion.navigateUp).not.toHaveBeenCalled();
expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(2);
expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled();
unmount();
});
it('should NOT call completion navigation when suggestions are not showing', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: false,
});
props.buffer.setText('some text');
@@ -333,8 +342,8 @@ describe('InputPrompt', () => {
stdin.write('\u000E'); // Ctrl+N
await wait();
expect(mockCompletion.navigateUp).not.toHaveBeenCalled();
expect(mockCompletion.navigateDown).not.toHaveBeenCalled();
expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled();
expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled();
unmount();
});
@@ -463,8 +472,8 @@ describe('InputPrompt', () => {
it('should complete a partial parent command', async () => {
// SCENARIO: /mem -> Tab
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'memory', value: 'memory', description: '...' }],
activeSuggestionIndex: 0,
@@ -477,14 +486,14 @@ describe('InputPrompt', () => {
stdin.write('\t'); // Press Tab
await wait();
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
unmount();
});
it('should append a sub-command when the parent command is already complete', async () => {
// SCENARIO: /memory -> Tab (to accept 'add')
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [
{ label: 'show', value: 'show' },
@@ -500,14 +509,14 @@ describe('InputPrompt', () => {
stdin.write('\t'); // Press Tab
await wait();
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(1);
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(1);
unmount();
});
it('should handle the "backspace" edge case correctly', async () => {
// SCENARIO: /memory -> Backspace -> /memory -> Tab (to accept 'show')
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [
{ label: 'show', value: 'show' },
@@ -525,14 +534,14 @@ describe('InputPrompt', () => {
await wait();
// It should NOT become '/show'. It should correctly become '/memory show'.
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
unmount();
});
it('should complete a partial argument for a command', async () => {
// SCENARIO: /chat resume fi- -> Tab
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'fix-foo', value: 'fix-foo' }],
activeSuggestionIndex: 0,
@@ -545,13 +554,13 @@ describe('InputPrompt', () => {
stdin.write('\t'); // Press Tab
await wait();
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
unmount();
});
it('should autocomplete on Enter when suggestions are active, without submitting', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'memory', value: 'memory' }],
activeSuggestionIndex: 0,
@@ -565,7 +574,7 @@ describe('InputPrompt', () => {
await wait();
// The app should autocomplete the text, NOT submit.
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(props.onSubmit).not.toHaveBeenCalled();
unmount();
@@ -581,8 +590,8 @@ describe('InputPrompt', () => {
},
];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'help', value: 'help' }],
activeSuggestionIndex: 0,
@@ -595,7 +604,7 @@ describe('InputPrompt', () => {
stdin.write('\t'); // Press Tab for autocomplete
await wait();
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
unmount();
});
@@ -613,8 +622,8 @@ describe('InputPrompt', () => {
});
it('should submit directly on Enter when isPerfectMatch is true', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: false,
isPerfectMatch: true,
});
@@ -631,8 +640,8 @@ describe('InputPrompt', () => {
});
it('should submit directly on Enter when a complete leaf command is typed', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: false,
isPerfectMatch: false, // Added explicit isPerfectMatch false
});
@@ -649,8 +658,8 @@ describe('InputPrompt', () => {
});
it('should autocomplete an @-path on Enter without submitting', async () => {
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'index.ts', value: 'index.ts' }],
activeSuggestionIndex: 0,
@@ -663,7 +672,7 @@ describe('InputPrompt', () => {
stdin.write('\r');
await wait();
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(props.onSubmit).not.toHaveBeenCalled();
unmount();
});
@@ -695,7 +704,7 @@ describe('InputPrompt', () => {
await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('');
expect(mockCompletion.resetCompletionState).toHaveBeenCalled();
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
expect(props.onSubmit).not.toHaveBeenCalled();
unmount();
});
@@ -719,8 +728,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@src/components'];
mockBuffer.cursor = [0, 15];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'Button.tsx', value: 'Button.tsx' }],
});
@@ -729,11 +738,13 @@ describe('InputPrompt', () => {
await wait();
// Verify useCompletion was called with correct signature
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -745,8 +756,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['/memory'];
mockBuffer.cursor = [0, 7];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'show', value: 'show' }],
});
@@ -754,11 +765,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -770,8 +783,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@src/file.ts hello'];
mockBuffer.cursor = [0, 18];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: false,
suggestions: [],
});
@@ -779,11 +792,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -795,8 +810,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['/memory add'];
mockBuffer.cursor = [0, 11];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: false,
suggestions: [],
});
@@ -804,11 +819,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -820,8 +837,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['hello world'];
mockBuffer.cursor = [0, 5];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: false,
suggestions: [],
});
@@ -829,11 +846,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -845,8 +864,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['first line', '/memory'];
mockBuffer.cursor = [1, 7];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: false,
suggestions: [],
});
@@ -855,11 +874,13 @@ describe('InputPrompt', () => {
await wait();
// Verify useCompletion was called with the buffer
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -871,8 +892,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['/memory'];
mockBuffer.cursor = [0, 7];
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'show', value: 'show' }],
});
@@ -880,11 +901,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -897,8 +920,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@src/file👍.txt'];
mockBuffer.cursor = [0, 14]; // After the emoji character
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'file👍.txt', value: 'file👍.txt' }],
});
@@ -906,11 +929,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -923,8 +948,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@src/file👍.txt hello'];
mockBuffer.cursor = [0, 20]; // After the space
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: false,
suggestions: [],
});
@@ -932,11 +957,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -949,8 +976,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@src/my\\ file.txt'];
mockBuffer.cursor = [0, 16]; // After the escaped space and filename
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'my file.txt', value: 'my file.txt' }],
});
@@ -958,11 +985,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -975,8 +1004,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@path/my\\ file.txt hello'];
mockBuffer.cursor = [0, 24]; // After "hello"
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: false,
suggestions: [],
});
@@ -984,11 +1013,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -1001,8 +1032,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@docs/my\\ long\\ file\\ name.md'];
mockBuffer.cursor = [0, 29]; // At the end
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [
{ label: 'my long file name.md', value: 'my long file name.md' },
@@ -1012,11 +1043,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -1029,8 +1062,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['/memory\\ test'];
mockBuffer.cursor = [0, 13]; // At the end
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'test-command', value: 'test-command' }],
});
@@ -1038,11 +1071,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -1055,8 +1090,8 @@ describe('InputPrompt', () => {
mockBuffer.lines = ['@' + path.join('files', 'emoji\\ 👍\\ test.txt')];
mockBuffer.cursor = [0, 25]; // After the escaped space and emoji
mockedUseCompletion.mockReturnValue({
...mockCompletion,
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [
{ label: 'emoji 👍 test.txt', value: 'emoji 👍 test.txt' },
@@ -1066,11 +1101,13 @@ describe('InputPrompt', () => {
const { unmount } = render(<InputPrompt {...props} />);
await wait();
expect(mockedUseCompletion).toHaveBeenCalledWith(
expect(mockedUseCommandCompletion).toHaveBeenCalledWith(
mockBuffer,
['/test/project/src'],
path.join('test', 'project', 'src'),
mockSlashCommands,
mockCommandContext,
false,
expect.any(Object),
);
@@ -1152,4 +1189,92 @@ describe('InputPrompt', () => {
unmount();
});
});
describe('reverse search', () => {
beforeEach(async () => {
props.shellModeActive = true;
vi.mocked(useShellHistory).mockReturnValue({
history: ['echo hello', 'echo world', 'ls'],
getPreviousCommand: vi.fn(),
getNextCommand: vi.fn(),
addCommandToHistory: vi.fn(),
resetHistoryPosition: vi.fn(),
});
});
it('invokes reverse search on Ctrl+R', async () => {
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x12');
await wait();
const frame = stdout.lastFrame();
expect(frame).toContain('(r:)');
expect(frame).toContain('echo hello');
expect(frame).toContain('echo world');
expect(frame).toContain('ls');
unmount();
});
it('resets reverse search state on Escape', async () => {
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
await wait();
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');
unmount();
});
it('completes the highlighted entry on Tab and exits reverse-search', async () => {
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
stdin.write('\x12');
await wait();
stdin.write('\t');
await wait();
expect(stdout.lastFrame()).not.toContain('(r:)');
expect(props.buffer.setText).toHaveBeenCalledWith('echo hello');
unmount();
});
it('submits the highlighted entry on Enter and exits reverse-search', async () => {
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
stdin.write('\x12');
await wait();
expect(stdout.lastFrame()).toContain('(r:)');
stdin.write('\r');
await wait();
expect(stdout.lastFrame()).not.toContain('(r:)');
expect(props.onSubmit).toHaveBeenCalledWith('echo hello');
unmount();
});
it('text and cursor position should be restored after reverse search', async () => {
props.buffer.setText('initial text');
props.buffer.cursor = [0, 3];
const { stdin, stdout, unmount } = render(<InputPrompt {...props} />);
stdin.write('\x12');
await wait();
expect(stdout.lastFrame()).toContain('(r:)');
stdin.write('\x1B');
await wait();
expect(stdout.lastFrame()).not.toContain('(r:)');
expect(props.buffer.text).toBe('initial text');
expect(props.buffer.cursor).toEqual([0, 3]);
unmount();
});
});
});

View File

@@ -9,12 +9,13 @@ import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import { TextBuffer } from './shared/text-buffer.js';
import { TextBuffer, logicalPosToOffset } from './shared/text-buffer.js';
import { cpSlice, cpLen } from '../utils/textUtils.js';
import chalk from 'chalk';
import stringWidth from 'string-width';
import { useShellHistory } from '../hooks/useShellHistory.js';
import { useCompletion } from '../hooks/useCompletion.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
import { useKeypress, Key } from '../hooks/useKeypress.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config } from '@qwen-code/qwen-code-core';
@@ -60,16 +61,41 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}) => {
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const completion = useCompletion(
const [dirs, setDirs] = useState<readonly string[]>(
config.getWorkspaceContext().getDirectories(),
);
const dirsChanged = config.getWorkspaceContext().getDirectories();
useEffect(() => {
if (dirs.length !== dirsChanged.length) {
setDirs(dirsChanged);
}
}, [dirs.length, dirsChanged]);
const [reverseSearchActive, setReverseSearchActive] = useState(false);
const [textBeforeReverseSearch, setTextBeforeReverseSearch] = useState('');
const [cursorPosition, setCursorPosition] = useState<[number, number]>([
0, 0,
]);
const shellHistory = useShellHistory(config.getProjectRoot());
const historyData = shellHistory.history;
const completion = useCommandCompletion(
buffer,
dirs,
config.getTargetDir(),
slashCommands,
commandContext,
reverseSearchActive,
config,
);
const reverseSearchCompletion = useReverseSearchCompletion(
buffer,
historyData,
reverseSearchActive,
);
const resetCompletionState = completion.resetCompletionState;
const shellHistory = useShellHistory(config.getProjectRoot());
const resetReverseSearchCompletionState =
reverseSearchCompletion.resetCompletionState;
const handleSubmitAndClear = useCallback(
(submittedValue: string) => {
@@ -81,8 +107,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.setText('');
onSubmit(submittedValue);
resetCompletionState();
resetReverseSearchCompletionState();
},
[onSubmit, buffer, resetCompletionState, shellModeActive, shellHistory],
[
onSubmit,
buffer,
resetCompletionState,
shellModeActive,
shellHistory,
resetReverseSearchCompletionState,
],
);
const customSetTextAndResetCompletionSignal = useCallback(
@@ -107,6 +141,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
useEffect(() => {
if (justNavigatedHistory) {
resetCompletionState();
resetReverseSearchCompletionState();
setJustNavigatedHistory(false);
}
}, [
@@ -114,6 +149,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.text,
resetCompletionState,
setJustNavigatedHistory,
resetReverseSearchCompletionState,
]);
// Handle clipboard image pasting with Ctrl+V
@@ -186,6 +222,19 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
if (key.name === 'escape') {
if (reverseSearchActive) {
setReverseSearchActive(false);
reverseSearchCompletion.resetCompletionState();
buffer.setText(textBeforeReverseSearch);
const offset = logicalPosToOffset(
buffer.lines,
cursorPosition[0],
cursorPosition[1],
);
buffer.moveToOffset(offset);
return;
}
if (shellModeActive) {
setShellModeActive(false);
return;
@@ -197,11 +246,61 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}
if (shellModeActive && key.ctrl && key.name === 'r') {
setReverseSearchActive(true);
setTextBeforeReverseSearch(buffer.text);
setCursorPosition(buffer.cursor);
return;
}
if (key.ctrl && key.name === 'l') {
onClearScreen();
return;
}
if (reverseSearchActive) {
const {
activeSuggestionIndex,
navigateUp,
navigateDown,
showSuggestions,
suggestions,
} = reverseSearchCompletion;
if (showSuggestions) {
if (key.name === 'up') {
navigateUp();
return;
}
if (key.name === 'down') {
navigateDown();
return;
}
if (key.name === 'tab') {
reverseSearchCompletion.handleAutocomplete(activeSuggestionIndex);
reverseSearchCompletion.resetCompletionState();
setReverseSearchActive(false);
return;
}
}
if (key.name === 'return' && !key.ctrl) {
const textToSubmit =
showSuggestions && activeSuggestionIndex > -1
? suggestions[activeSuggestionIndex].value
: buffer.text;
handleSubmitAndClear(textToSubmit);
reverseSearchCompletion.resetCompletionState();
setReverseSearchActive(false);
return;
}
// Prevent up/down from falling through to regular history navigation
if (key.name === 'up' || key.name === 'down') {
return;
}
}
// If the command is a perfect match, pressing enter should execute it.
if (completion.isPerfectMatch && key.name === 'return') {
handleSubmitAndClear(buffer.text);
@@ -261,7 +360,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
} else {
// Shell History Navigation
if (key.name === 'up') {
const prevCommand = shellHistory.getPreviousCommand();
if (prevCommand !== null) buffer.setText(prevCommand);
@@ -273,7 +371,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
}
if (key.name === 'return' && !key.ctrl && !key.meta && !key.paste) {
if (buffer.text.trim()) {
const [row, col] = buffer.cursor;
@@ -351,9 +448,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
inputHistory,
handleSubmitAndClear,
shellHistory,
reverseSearchCompletion,
handleClipboardImage,
resetCompletionState,
vimHandleInput,
reverseSearchActive,
textBeforeReverseSearch,
cursorPosition,
],
);
@@ -374,7 +475,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
<Text
color={shellModeActive ? Colors.AccentYellow : Colors.AccentPurple}
>
{shellModeActive ? '! ' : '> '}
{shellModeActive ? (
reverseSearchActive ? (
<Text color={Colors.AccentCyan}>(r:) </Text>
) : (
'! '
)
) : (
'> '
)}
</Text>
<Box flexGrow={1} flexDirection="column">
{buffer.text.length === 0 && placeholder ? (
@@ -438,6 +547,18 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
/>
</Box>
)}
{reverseSearchActive && (
<Box>
<SuggestionsDisplay
suggestions={reverseSearchCompletion.suggestions}
activeIndex={reverseSearchCompletion.activeSuggestionIndex}
isLoading={reverseSearchCompletion.isLoadingSuggestions}
width={suggestionsWidth}
scrollOffset={reverseSearchCompletion.visibleStartIndex}
userInput={buffer.text}
/>
</Box>
)}
</>
);
};

View File

@@ -5,7 +5,7 @@
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { ModelStatsDisplay } from './ModelStatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import { SessionMetrics } from '../contexts/SessionContext.js';
@@ -38,6 +38,19 @@ const renderWithMockedStats = (metrics: SessionMetrics) => {
};
describe('<ModelStatsDisplay />', () => {
beforeAll(() => {
vi.spyOn(Number.prototype, 'toLocaleString').mockImplementation(function (
this: number,
) {
// Use a stable 'en-US' format for test consistency.
return new Intl.NumberFormat('en-US').format(this);
});
});
afterAll(() => {
vi.restoreAllMocks();
});
it('should render "no API calls" message when there are no active models', () => {
const { lastFrame } = renderWithMockedStats({
models: {},

View File

@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text } from 'ink';
import { Colors } from '../colors.js';
interface PrepareLabelProps {
label: string;
matchedIndex?: number;
userInput: string;
textColor: string;
highlightColor?: string;
}
export const PrepareLabel: React.FC<PrepareLabelProps> = ({
label,
matchedIndex,
userInput,
textColor,
highlightColor = Colors.AccentYellow,
}) => {
if (
matchedIndex === undefined ||
matchedIndex < 0 ||
matchedIndex >= label.length ||
userInput.length === 0
) {
return <Text color={textColor}>{label}</Text>;
}
const start = label.slice(0, matchedIndex);
const match = label.slice(matchedIndex, matchedIndex + userInput.length);
const end = label.slice(matchedIndex + userInput.length);
return (
<Text>
<Text color={textColor}>{start}</Text>
<Text color="black" bold backgroundColor={highlightColor}>
{match}
</Text>
<Text color={textColor}>{end}</Text>
</Text>
);
};

View File

@@ -6,10 +6,12 @@
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { PrepareLabel } from './PrepareLabel.js';
export interface Suggestion {
label: string;
value: string;
description?: string;
matchedIndex?: number;
}
interface SuggestionsDisplayProps {
suggestions: Suggestion[];
@@ -58,18 +60,25 @@ export function SuggestionsDisplay({
const originalIndex = startIndex + index;
const isActive = originalIndex === activeIndex;
const textColor = isActive ? Colors.AccentPurple : Colors.Gray;
const labelElement = (
<PrepareLabel
label={suggestion.label}
matchedIndex={suggestion.matchedIndex}
userInput={userInput}
textColor={textColor}
/>
);
return (
<Box key={`${suggestion}-${originalIndex}`} width={width}>
<Box key={`${suggestion.value}-${originalIndex}`} width={width}>
<Box flexDirection="row">
{userInput.startsWith('/') ? (
// only use box model for (/) command mode
<Box width={20} flexShrink={0}>
<Text color={textColor}>{suggestion.label}</Text>
{labelElement}
</Box>
) : (
// use regular text for other modes (@ context)
<Text color={textColor}>{suggestion.label}</Text>
labelElement
)}
{suggestion.description ? (
<Box flexGrow={1}>

View File

@@ -118,7 +118,10 @@ export const ToolConfirmationMessage: React.FC<
label: 'Modify with external editor',
value: ToolConfirmationOutcome.ModifyWithEditor,
},
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
{
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
},
);
bodyContent = (
<DiffRenderer
@@ -142,10 +145,12 @@ export const ToolConfirmationMessage: React.FC<
label: `Yes, allow always ...`,
value: ToolConfirmationOutcome.ProceedAlways,
},
{
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
},
);
options.push({ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel });
let bodyContentHeight = availableBodyContentHeight();
if (bodyContentHeight !== undefined) {
bodyContentHeight -= 2; // Account for padding;
@@ -180,7 +185,10 @@ export const ToolConfirmationMessage: React.FC<
label: 'Yes, allow always',
value: ToolConfirmationOutcome.ProceedAlways,
},
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
{
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
},
);
bodyContent = (
@@ -221,7 +229,10 @@ export const ToolConfirmationMessage: React.FC<
label: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
value: ToolConfirmationOutcome.ProceedAlwaysServer,
},
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
{
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
},
);
}

View File

@@ -15,11 +15,15 @@ interface UserMessageProps {
export const UserMessage: React.FC<UserMessageProps> = ({ text }) => {
const prefix = '> ';
const prefixWidth = prefix.length;
const isSlashCommand = text.startsWith('/');
const textColor = isSlashCommand ? Colors.AccentPurple : Colors.Gray;
const borderColor = isSlashCommand ? Colors.AccentPurple : Colors.Gray;
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
borderColor={borderColor}
flexDirection="row"
paddingX={2}
paddingY={0}
@@ -27,10 +31,10 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text }) => {
alignSelf="flex-start"
>
<Box width={prefixWidth}>
<Text color={Colors.Gray}>{prefix}</Text>
<Text color={textColor}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={Colors.Gray}>
<Text wrap="wrap" color={textColor}>
{text}
</Text>
</Box>

View File

@@ -32,6 +32,7 @@ describe('textBufferReducer', () => {
it('should return the initial state if state is undefined', () => {
const action = { type: 'unknown_action' } as unknown as TextBufferAction;
const state = textBufferReducer(initialState, action);
expect(state).toHaveOnlyValidCharacters();
expect(state).toEqual(initialState);
});
@@ -42,6 +43,7 @@ describe('textBufferReducer', () => {
payload: 'hello\nworld',
};
const state = textBufferReducer(initialState, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['hello', 'world']);
expect(state.cursorRow).toBe(1);
expect(state.cursorCol).toBe(5);
@@ -55,6 +57,7 @@ describe('textBufferReducer', () => {
pushToUndo: false,
};
const state = textBufferReducer(initialState, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['no undo']);
expect(state.undoStack.length).toBe(0);
});
@@ -64,6 +67,7 @@ describe('textBufferReducer', () => {
it('should insert a character', () => {
const action: TextBufferAction = { type: 'insert', payload: 'a' };
const state = textBufferReducer(initialState, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['a']);
expect(state.cursorCol).toBe(1);
});
@@ -72,6 +76,7 @@ describe('textBufferReducer', () => {
const stateWithText = { ...initialState, lines: ['hello'] };
const action: TextBufferAction = { type: 'insert', payload: '\n' };
const state = textBufferReducer(stateWithText, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['', 'hello']);
expect(state.cursorRow).toBe(1);
expect(state.cursorCol).toBe(0);
@@ -88,6 +93,7 @@ describe('textBufferReducer', () => {
};
const action: TextBufferAction = { type: 'backspace' };
const state = textBufferReducer(stateWithText, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['']);
expect(state.cursorCol).toBe(0);
});
@@ -101,6 +107,7 @@ describe('textBufferReducer', () => {
};
const action: TextBufferAction = { type: 'backspace' };
const state = textBufferReducer(stateWithText, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['helloworld']);
expect(state.cursorRow).toBe(0);
expect(state.cursorCol).toBe(5);
@@ -115,12 +122,14 @@ describe('textBufferReducer', () => {
payload: 'test',
};
const stateAfterInsert = textBufferReducer(initialState, insertAction);
expect(stateAfterInsert).toHaveOnlyValidCharacters();
expect(stateAfterInsert.lines).toEqual(['test']);
expect(stateAfterInsert.undoStack.length).toBe(1);
// 2. Undo
const undoAction: TextBufferAction = { type: 'undo' };
const stateAfterUndo = textBufferReducer(stateAfterInsert, undoAction);
expect(stateAfterUndo).toHaveOnlyValidCharacters();
expect(stateAfterUndo.lines).toEqual(['']);
expect(stateAfterUndo.undoStack.length).toBe(0);
expect(stateAfterUndo.redoStack.length).toBe(1);
@@ -128,6 +137,7 @@ describe('textBufferReducer', () => {
// 3. Redo
const redoAction: TextBufferAction = { type: 'redo' };
const stateAfterRedo = textBufferReducer(stateAfterUndo, redoAction);
expect(stateAfterRedo).toHaveOnlyValidCharacters();
expect(stateAfterRedo.lines).toEqual(['test']);
expect(stateAfterRedo.undoStack.length).toBe(1);
expect(stateAfterRedo.redoStack.length).toBe(0);
@@ -144,6 +154,7 @@ describe('textBufferReducer', () => {
};
const action: TextBufferAction = { type: 'create_undo_snapshot' };
const state = textBufferReducer(stateWithText, action);
expect(state).toHaveOnlyValidCharacters();
expect(state.lines).toEqual(['hello']);
expect(state.cursorRow).toBe(0);
@@ -157,16 +168,19 @@ describe('textBufferReducer', () => {
});
// Helper to get the state from the hook
const getBufferState = (result: { current: TextBuffer }) => ({
text: result.current.text,
lines: [...result.current.lines], // Clone for safety
cursor: [...result.current.cursor] as [number, number],
allVisualLines: [...result.current.allVisualLines],
viewportVisualLines: [...result.current.viewportVisualLines],
visualCursor: [...result.current.visualCursor] as [number, number],
visualScrollRow: result.current.visualScrollRow,
preferredCol: result.current.preferredCol,
});
const getBufferState = (result: { current: TextBuffer }) => {
expect(result.current).toHaveOnlyValidCharacters();
return {
text: result.current.text,
lines: [...result.current.lines], // Clone for safety
cursor: [...result.current.cursor] as [number, number],
allVisualLines: [...result.current.allVisualLines],
viewportVisualLines: [...result.current.viewportVisualLines],
visualCursor: [...result.current.visualCursor] as [number, number],
visualScrollRow: result.current.visualScrollRow,
preferredCol: result.current.preferredCol,
};
};
describe('useTextBuffer', () => {
let viewport: Viewport;
@@ -1152,6 +1166,22 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
expect(state.text).toBe('fiXrd');
expect(state.cursor).toEqual([0, 3]); // After 'X'
});
it('should replace a single-line range with multi-line text', () => {
const { result } = renderHook(() =>
useTextBuffer({
initialText: 'one two three',
viewport,
isValidPath: () => false,
}),
);
// Replace "two" with "new\nline"
act(() => result.current.replaceRange(0, 4, 0, 7, 'new\nline'));
const state = getBufferState(result);
expect(state.lines).toEqual(['one new', 'line three']);
expect(state.text).toBe('one new\nline three');
expect(state.cursor).toEqual([1, 4]); // cursor after 'line'
});
});
describe('Input Sanitization', () => {
@@ -1159,7 +1189,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const textWithAnsi = '\x1B[31mHello\x1B[0m';
const textWithAnsi = '\x1B[31mHello\x1B[0m \x1B[32mWorld\x1B[0m';
act(() =>
result.current.handleInput({
name: '',
@@ -1170,7 +1200,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
sequence: textWithAnsi,
}),
);
expect(getBufferState(result).text).toBe('Hello');
expect(getBufferState(result).text).toBe('Hello World');
});
it('should strip control characters from input', () => {
@@ -1425,6 +1455,7 @@ describe('textBufferReducer vim operations', () => {
};
const result = textBufferReducer(initialState, action);
expect(result).toHaveOnlyValidCharacters();
// After deleting line2, we should have line1 and line3, with cursor on line3 (now at index 1)
expect(result.lines).toEqual(['line1', 'line3']);
@@ -1452,6 +1483,7 @@ describe('textBufferReducer vim operations', () => {
};
const result = textBufferReducer(initialState, action);
expect(result).toHaveOnlyValidCharacters();
// Should delete line2 and line3, leaving line1 and line4
expect(result.lines).toEqual(['line1', 'line4']);
@@ -1479,6 +1511,7 @@ describe('textBufferReducer vim operations', () => {
};
const result = textBufferReducer(initialState, action);
expect(result).toHaveOnlyValidCharacters();
// Should clear the line content but keep the line
expect(result.lines).toEqual(['']);
@@ -1506,6 +1539,7 @@ describe('textBufferReducer vim operations', () => {
};
const result = textBufferReducer(initialState, action);
expect(result).toHaveOnlyValidCharacters();
// Should delete the last line completely, not leave empty line
expect(result.lines).toEqual(['line1']);
@@ -1534,6 +1568,7 @@ describe('textBufferReducer vim operations', () => {
};
const afterDelete = textBufferReducer(initialState, deleteAction);
expect(afterDelete).toHaveOnlyValidCharacters();
// After deleting all lines, should have one empty line
expect(afterDelete.lines).toEqual(['']);
@@ -1547,6 +1582,7 @@ describe('textBufferReducer vim operations', () => {
};
const afterPaste = textBufferReducer(afterDelete, pasteAction);
expect(afterPaste).toHaveOnlyValidCharacters();
// All lines including the first one should be present
expect(afterPaste.lines).toEqual(['new1', 'new2', 'new3', 'new4']);

View File

@@ -271,26 +271,23 @@ export const replaceRangeInternal = (
.replace(/\r/g, '\n');
const replacementParts = normalisedReplacement.split('\n');
// Replace the content
if (startRow === endRow) {
newLines[startRow] = prefix + normalisedReplacement + suffix;
// The combined first line of the new text
const firstLine = prefix + replacementParts[0];
if (replacementParts.length === 1) {
// No newlines in replacement: combine prefix, replacement, and suffix on one line.
newLines.splice(startRow, endRow - startRow + 1, firstLine + suffix);
} else {
const firstLine = prefix + replacementParts[0];
if (replacementParts.length === 1) {
// Single line of replacement text, but spanning multiple original lines
newLines.splice(startRow, endRow - startRow + 1, firstLine + suffix);
} else {
// Multi-line replacement text
const lastLine = replacementParts[replacementParts.length - 1] + suffix;
const middleLines = replacementParts.slice(1, -1);
newLines.splice(
startRow,
endRow - startRow + 1,
firstLine,
...middleLines,
lastLine,
);
}
// Newlines in replacement: create new lines.
const lastLine = replacementParts[replacementParts.length - 1] + suffix;
const middleLines = replacementParts.slice(1, -1);
newLines.splice(
startRow,
endRow - startRow + 1,
firstLine,
...middleLines,
lastLine,
);
}
const finalCursorRow = startRow + replacementParts.length - 1;

View File

@@ -36,7 +36,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(2);
expect(result.preferredCol).toBeNull();
});
@@ -49,7 +49,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(0);
});
@@ -61,7 +61,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(4); // On last character '1' of 'line1'
});
@@ -74,7 +74,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(1); // On 'b' after 5 left movements
});
@@ -88,6 +88,7 @@ describe('vim-buffer-actions', () => {
type: 'vim_move_right' as const,
payload: { count: 1 },
});
expect(state).toHaveOnlyValidCharacters();
expect(state.cursorRow).toBe(1);
expect(state.cursorCol).toBe(0); // Should be on 'f'
@@ -96,6 +97,7 @@ describe('vim-buffer-actions', () => {
type: 'vim_move_left' as const,
payload: { count: 1 },
});
expect(state).toHaveOnlyValidCharacters();
expect(state.cursorRow).toBe(0);
expect(state.cursorCol).toBe(10); // Should be on 'd', not past it
});
@@ -110,7 +112,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(5);
});
@@ -122,7 +124,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(4); // Last character of 'hello'
});
@@ -134,7 +136,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
});
@@ -146,7 +148,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_up' as const, payload: { count: 2 } };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(3);
});
@@ -156,7 +158,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_up' as const, payload: { count: 5 } };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0);
});
@@ -165,7 +167,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_up' as const, payload: { count: 1 } };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(5); // End of 'short'
});
@@ -180,7 +182,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(2);
expect(result.cursorCol).toBe(2);
});
@@ -193,7 +195,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(1);
});
});
@@ -207,7 +209,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(6); // Start of 'world'
});
@@ -219,7 +221,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(12); // Start of 'test'
});
@@ -231,7 +233,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(5); // Start of ','
});
});
@@ -245,7 +247,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(6); // Start of 'world'
});
@@ -257,7 +259,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(0); // Start of 'hello'
});
});
@@ -271,7 +273,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(4); // End of 'hello'
});
@@ -283,7 +285,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(10); // End of 'world'
});
});
@@ -294,7 +296,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_to_line_start' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(0);
});
@@ -303,7 +305,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_to_line_end' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(10); // Last character of 'hello world'
});
@@ -312,7 +314,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_to_first_nonwhitespace' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(3); // Position of 'h'
});
@@ -321,7 +323,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_to_first_line' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
@@ -331,7 +333,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_to_last_line' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(2);
expect(result.cursorCol).toBe(0);
});
@@ -344,7 +346,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(1); // 0-indexed
expect(result.cursorCol).toBe(0);
});
@@ -357,7 +359,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(1); // Last line
});
});
@@ -373,7 +375,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hllo');
expect(result.cursorCol).toBe(1);
});
@@ -386,7 +388,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('ho');
expect(result.cursorCol).toBe(1);
});
@@ -399,7 +401,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hel');
expect(result.cursorCol).toBe(3);
});
@@ -412,7 +414,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello');
expect(result.cursorCol).toBe(5);
});
@@ -427,7 +429,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('world test');
expect(result.cursorCol).toBe(0);
});
@@ -440,7 +442,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('test');
expect(result.cursorCol).toBe(0);
});
@@ -453,7 +455,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello ');
expect(result.cursorCol).toBe(6);
});
@@ -468,7 +470,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello test');
expect(result.cursorCol).toBe(6);
});
@@ -481,7 +483,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('test');
expect(result.cursorCol).toBe(0);
});
@@ -496,7 +498,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line1', 'line3']);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
@@ -510,7 +512,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line3']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
@@ -524,7 +526,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
@@ -537,7 +539,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_delete_to_end_of_line' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello');
expect(result.cursorCol).toBe(5);
});
@@ -547,7 +549,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_delete_to_end_of_line' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello');
});
});
@@ -560,7 +562,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_insert_at_cursor' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(2);
});
@@ -572,7 +574,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_append_at_cursor' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(3);
});
@@ -581,7 +583,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_append_at_cursor' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(5);
});
});
@@ -592,7 +594,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_append_at_line_end' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(11);
});
});
@@ -603,7 +605,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_insert_at_line_start' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(2);
});
@@ -612,34 +614,32 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_insert_at_line_start' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(3);
});
});
describe('vim_open_line_below', () => {
it('should insert newline at end of current line', () => {
it('should insert a new line below the current one', () => {
const state = createTestState(['hello world'], 0, 5);
const action = { type: 'vim_open_line_below' as const };
const result = handleVimAction(state, action);
// The implementation inserts newline at end of current line and cursor moves to column 0
expect(result.lines[0]).toBe('hello world\n');
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0); // Cursor position after replaceRangeInternal
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['hello world', '']);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
});
});
describe('vim_open_line_above', () => {
it('should insert newline before current line', () => {
it('should insert a new line above the current one', () => {
const state = createTestState(['hello', 'world'], 1, 2);
const action = { type: 'vim_open_line_above' as const };
const result = handleVimAction(state, action);
// The implementation inserts newline at beginning of current line
expect(result.lines).toEqual(['hello', '\nworld']);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['hello', '', 'world']);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
});
@@ -651,7 +651,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_escape_insert_mode' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(2);
});
@@ -660,7 +660,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_escape_insert_mode' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(0);
});
});
@@ -676,7 +676,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('world test');
expect(result.cursorCol).toBe(0);
});
@@ -691,7 +691,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('');
expect(result.cursorCol).toBe(0);
});
@@ -706,7 +706,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hel world');
expect(result.cursorCol).toBe(3);
});
@@ -719,7 +719,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hellorld'); // Deletes ' wo' (3 chars to the right)
expect(result.cursorCol).toBe(5);
});
@@ -732,7 +732,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// The movement 'j' with count 2 changes 2 lines starting from cursor row
// Since we're at cursor position 2, it changes lines starting from current row
expect(result.lines).toEqual(['line1', 'line2', 'line3']); // No change because count > available lines
@@ -751,7 +751,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
@@ -761,7 +761,7 @@ describe('vim-buffer-actions', () => {
const action = { type: 'vim_move_to_line_end' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(0); // Should be last character position
});
@@ -773,7 +773,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// Should move to next line with content
expect(result.cursorRow).toBe(2);
expect(result.cursorCol).toBe(0);
@@ -789,7 +789,7 @@ describe('vim-buffer-actions', () => {
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.undoStack).toHaveLength(2); // Original plus new snapshot
});
});

View File

@@ -17,28 +17,23 @@ export interface EditorDisplay {
}
export const EDITOR_DISPLAY_NAMES: Record<EditorType, string> = {
zed: 'Zed',
cursor: 'Cursor',
emacs: 'Emacs',
neovim: 'Neovim',
vim: 'Vim',
vscode: 'VS Code',
vscodium: 'VSCodium',
windsurf: 'Windsurf',
cursor: 'Cursor',
vim: 'Vim',
neovim: 'Neovim',
zed: 'Zed',
};
class EditorSettingsManager {
private readonly availableEditors: EditorDisplay[];
constructor() {
const editorTypes: EditorType[] = [
'zed',
'vscode',
'vscodium',
'windsurf',
'cursor',
'vim',
'neovim',
];
const editorTypes = Object.keys(
EDITOR_DISPLAY_NAMES,
).sort() as EditorType[];
this.availableEditors = [
{
name: 'None',

View File

@@ -57,6 +57,10 @@ describe('handleAtCommand', () => {
respectGeminiIgnore: true,
}),
getEnableRecursiveFileSearch: vi.fn(() => true),
getWorkspaceContext: () => ({
isPathWithinWorkspace: () => true,
getDirectories: () => [testRootDir],
}),
} as unknown as Config;
const registry = new ToolRegistry(mockConfig);
@@ -685,5 +689,397 @@ describe('handleAtCommand', () => {
`Ignored 1 files:\nGemini-ignored: ${geminiIgnoredFile}`,
);
});
// });
describe('punctuation termination in @ commands', () => {
const punctuationTestCases = [
{
name: 'comma',
fileName: 'test.txt',
fileContent: 'File content here',
queryTemplate: (filePath: string) =>
`Look at @${filePath}, then explain it.`,
messageId: 400,
},
{
name: 'period',
fileName: 'readme.md',
fileContent: 'File content here',
queryTemplate: (filePath: string) =>
`Check @${filePath}. What does it say?`,
messageId: 401,
},
{
name: 'semicolon',
fileName: 'example.js',
fileContent: 'Code example',
queryTemplate: (filePath: string) =>
`Review @${filePath}; check for bugs.`,
messageId: 402,
},
{
name: 'exclamation mark',
fileName: 'important.txt',
fileContent: 'Important content',
queryTemplate: (filePath: string) =>
`Look at @${filePath}! This is critical.`,
messageId: 403,
},
{
name: 'question mark',
fileName: 'config.json',
fileContent: 'Config settings',
queryTemplate: (filePath: string) =>
`What is in @${filePath}? Please explain.`,
messageId: 404,
},
{
name: 'opening parenthesis',
fileName: 'func.ts',
fileContent: 'Function definition',
queryTemplate: (filePath: string) =>
`Analyze @${filePath}(the main function).`,
messageId: 405,
},
{
name: 'closing parenthesis',
fileName: 'data.json',
fileContent: 'Test data',
queryTemplate: (filePath: string) =>
`Use data from @${filePath}) for testing.`,
messageId: 406,
},
{
name: 'opening square bracket',
fileName: 'array.js',
fileContent: 'Array data',
queryTemplate: (filePath: string) =>
`Check @${filePath}[0] for the first element.`,
messageId: 407,
},
{
name: 'closing square bracket',
fileName: 'list.md',
fileContent: 'List content',
queryTemplate: (filePath: string) =>
`Review item @${filePath}] from the list.`,
messageId: 408,
},
{
name: 'opening curly brace',
fileName: 'object.ts',
fileContent: 'Object definition',
queryTemplate: (filePath: string) =>
`Parse @${filePath}{prop1: value1}.`,
messageId: 409,
},
{
name: 'closing curly brace',
fileName: 'config.yaml',
fileContent: 'Configuration',
queryTemplate: (filePath: string) =>
`Use settings from @${filePath}} for deployment.`,
messageId: 410,
},
];
it.each(punctuationTestCases)(
'should terminate @path at $name',
async ({ fileName, fileContent, queryTemplate, messageId }) => {
const filePath = await createTestFile(
path.join(testRootDir, fileName),
fileContent,
);
const query = queryTemplate(filePath);
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId,
signal: abortController.signal,
});
expect(result).toEqual({
processedQuery: [
{ text: query },
{ text: '\n--- Content from referenced files ---' },
{ text: `\nContent from @${filePath}:\n` },
{ text: fileContent },
{ text: '\n--- End of content ---' },
],
shouldProceed: true,
});
},
);
it('should handle multiple @paths terminated by different punctuation', async () => {
const content1 = 'First file';
const file1Path = await createTestFile(
path.join(testRootDir, 'first.txt'),
content1,
);
const content2 = 'Second file';
const file2Path = await createTestFile(
path.join(testRootDir, 'second.txt'),
content2,
);
const query = `Compare @${file1Path}, @${file2Path}; what's different?`;
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 411,
signal: abortController.signal,
});
expect(result).toEqual({
processedQuery: [
{ text: `Compare @${file1Path}, @${file2Path}; what's different?` },
{ text: '\n--- Content from referenced files ---' },
{ text: `\nContent from @${file1Path}:\n` },
{ text: content1 },
{ text: `\nContent from @${file2Path}:\n` },
{ text: content2 },
{ text: '\n--- End of content ---' },
],
shouldProceed: true,
});
});
it('should still handle escaped spaces in paths before punctuation', async () => {
const fileContent = 'Spaced file content';
const filePath = await createTestFile(
path.join(testRootDir, 'spaced file.txt'),
fileContent,
);
const escapedPath = path.join(testRootDir, 'spaced\\ file.txt');
const query = `Check @${escapedPath}, it has spaces.`;
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 412,
signal: abortController.signal,
});
expect(result).toEqual({
processedQuery: [
{ text: `Check @${filePath}, it has spaces.` },
{ text: '\n--- Content from referenced files ---' },
{ text: `\nContent from @${filePath}:\n` },
{ text: fileContent },
{ text: '\n--- End of content ---' },
],
shouldProceed: true,
});
});
it('should not break file paths with periods in extensions', async () => {
const fileContent = 'TypeScript content';
const filePath = await createTestFile(
path.join(testRootDir, 'example.d.ts'),
fileContent,
);
const query = `Analyze @${filePath} for type definitions.`;
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 413,
signal: abortController.signal,
});
expect(result).toEqual({
processedQuery: [
{ text: `Analyze @${filePath} for type definitions.` },
{ text: '\n--- Content from referenced files ---' },
{ text: `\nContent from @${filePath}:\n` },
{ text: fileContent },
{ text: '\n--- End of content ---' },
],
shouldProceed: true,
});
});
it('should handle file paths ending with period followed by space', async () => {
const fileContent = 'Config content';
const filePath = await createTestFile(
path.join(testRootDir, 'config.json'),
fileContent,
);
const query = `Check @${filePath}. This file contains settings.`;
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 414,
signal: abortController.signal,
});
expect(result).toEqual({
processedQuery: [
{ text: `Check @${filePath}. This file contains settings.` },
{ text: '\n--- Content from referenced files ---' },
{ text: `\nContent from @${filePath}:\n` },
{ text: fileContent },
{ text: '\n--- End of content ---' },
],
shouldProceed: true,
});
});
it('should handle comma termination with complex file paths', async () => {
const fileContent = 'Package info';
const filePath = await createTestFile(
path.join(testRootDir, 'package.json'),
fileContent,
);
const query = `Review @${filePath}, then check dependencies.`;
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 415,
signal: abortController.signal,
});
expect(result).toEqual({
processedQuery: [
{ text: `Review @${filePath}, then check dependencies.` },
{ text: '\n--- Content from referenced files ---' },
{ text: `\nContent from @${filePath}:\n` },
{ text: fileContent },
{ text: '\n--- End of content ---' },
],
shouldProceed: true,
});
});
it('should not terminate at period within file name', async () => {
const fileContent = 'Version info';
const filePath = await createTestFile(
path.join(testRootDir, 'version.1.2.3.txt'),
fileContent,
);
const query = `Check @${filePath} contains version information.`;
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 416,
signal: abortController.signal,
});
expect(result).toEqual({
processedQuery: [
{ text: `Check @${filePath} contains version information.` },
{ text: '\n--- Content from referenced files ---' },
{ text: `\nContent from @${filePath}:\n` },
{ text: fileContent },
{ text: '\n--- End of content ---' },
],
shouldProceed: true,
});
});
it('should handle end of string termination for period and comma', async () => {
const fileContent = 'End file content';
const filePath = await createTestFile(
path.join(testRootDir, 'end.txt'),
fileContent,
);
const query = `Show me @${filePath}.`;
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 417,
signal: abortController.signal,
});
expect(result).toEqual({
processedQuery: [
{ text: `Show me @${filePath}.` },
{ text: '\n--- Content from referenced files ---' },
{ text: `\nContent from @${filePath}:\n` },
{ text: fileContent },
{ text: '\n--- End of content ---' },
],
shouldProceed: true,
});
});
it('should handle files with special characters in names', async () => {
const fileContent = 'File with special chars content';
const filePath = await createTestFile(
path.join(testRootDir, 'file$with&special#chars.txt'),
fileContent,
);
const query = `Check @${filePath} for content.`;
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 418,
signal: abortController.signal,
});
expect(result).toEqual({
processedQuery: [
{ text: `Check @${filePath} for content.` },
{ text: '\n--- Content from referenced files ---' },
{ text: `\nContent from @${filePath}:\n` },
{ text: fileContent },
{ text: '\n--- End of content ---' },
],
shouldProceed: true,
});
});
it('should handle basic file names without special characters', async () => {
const fileContent = 'Basic file content';
const filePath = await createTestFile(
path.join(testRootDir, 'basicfile.txt'),
fileContent,
);
const query = `Check @${filePath} please.`;
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 421,
signal: abortController.signal,
});
expect(result).toEqual({
processedQuery: [
{ text: `Check @${filePath} please.` },
{ text: '\n--- Content from referenced files ---' },
{ text: `\nContent from @${filePath}:\n` },
{ text: fileContent },
{ text: '\n--- End of content ---' },
],
shouldProceed: true,
});
});
});
});

View File

@@ -87,9 +87,17 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
inEscape = false;
} else if (char === '\\') {
inEscape = true;
} else if (/\s/.test(char)) {
// Path ends at first whitespace not escaped
} else if (/[,\s;!?()[\]{}]/.test(char)) {
// Path ends at first whitespace or punctuation not escaped
break;
} else if (char === '.') {
// For . we need to be more careful - only terminate if followed by whitespace or end of string
// This allows file extensions like .txt, .js but terminates at sentence endings like "file.txt. Next sentence"
const nextChar =
pathEndIndex + 1 < query.length ? query[pathEndIndex + 1] : '';
if (nextChar === '' || /\s/.test(nextChar)) {
break;
}
}
pathEndIndex++;
}
@@ -188,6 +196,14 @@ export async function handleAtCommand({
// Check if path should be ignored based on filtering options
const workspaceContext = config.getWorkspaceContext();
if (!workspaceContext.isPathWithinWorkspace(pathName)) {
onDebugMessage(
`Path ${pathName} is not in the workspace and will be skipped.`,
);
continue;
}
const gitIgnored =
respectFileIgnore.respectGitIgnore &&
fileDiscovery.shouldIgnoreFile(pathName, {
@@ -215,90 +231,88 @@ export async function handleAtCommand({
continue;
}
let currentPathSpec = pathName;
let resolvedSuccessfully = false;
try {
const absolutePath = path.resolve(config.getTargetDir(), pathName);
const stats = await fs.stat(absolutePath);
if (stats.isDirectory()) {
currentPathSpec =
pathName + (pathName.endsWith(path.sep) ? `**` : `/**`);
onDebugMessage(
`Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`,
);
} else {
onDebugMessage(`Path ${pathName} resolved to file: ${absolutePath}`);
}
resolvedSuccessfully = true;
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
if (config.getEnableRecursiveFileSearch() && globTool) {
for (const dir of config.getWorkspaceContext().getDirectories()) {
let currentPathSpec = pathName;
let resolvedSuccessfully = false;
try {
const absolutePath = path.resolve(dir, pathName);
const stats = await fs.stat(absolutePath);
if (stats.isDirectory()) {
currentPathSpec =
pathName + (pathName.endsWith(path.sep) ? `**` : `/**`);
onDebugMessage(
`Path ${pathName} not found directly, attempting glob search.`,
`Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`,
);
try {
const globResult = await globTool.execute(
{
pattern: `**/*${pathName}*`,
path: config.getTargetDir(),
},
signal,
} else {
onDebugMessage(`Path ${pathName} resolved to file: ${absolutePath}`);
}
resolvedSuccessfully = true;
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
if (config.getEnableRecursiveFileSearch() && globTool) {
onDebugMessage(
`Path ${pathName} not found directly, attempting glob search.`,
);
if (
globResult.llmContent &&
typeof globResult.llmContent === 'string' &&
!globResult.llmContent.startsWith('No files found') &&
!globResult.llmContent.startsWith('Error:')
) {
const lines = globResult.llmContent.split('\n');
if (lines.length > 1 && lines[1]) {
const firstMatchAbsolute = lines[1].trim();
currentPathSpec = path.relative(
config.getTargetDir(),
firstMatchAbsolute,
);
onDebugMessage(
`Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`,
);
resolvedSuccessfully = true;
try {
const globResult = await globTool.execute(
{
pattern: `**/*${pathName}*`,
path: dir,
},
signal,
);
if (
globResult.llmContent &&
typeof globResult.llmContent === 'string' &&
!globResult.llmContent.startsWith('No files found') &&
!globResult.llmContent.startsWith('Error:')
) {
const lines = globResult.llmContent.split('\n');
if (lines.length > 1 && lines[1]) {
const firstMatchAbsolute = lines[1].trim();
currentPathSpec = path.relative(dir, firstMatchAbsolute);
onDebugMessage(
`Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`,
);
resolvedSuccessfully = true;
} else {
onDebugMessage(
`Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`,
);
}
} else {
onDebugMessage(
`Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`,
`Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`,
);
}
} else {
} catch (globError) {
console.error(
`Error during glob search for ${pathName}: ${getErrorMessage(globError)}`,
);
onDebugMessage(
`Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`,
`Error during glob search for ${pathName}. Path ${pathName} will be skipped.`,
);
}
} catch (globError) {
console.error(
`Error during glob search for ${pathName}: ${getErrorMessage(globError)}`,
);
} else {
onDebugMessage(
`Error during glob search for ${pathName}. Path ${pathName} will be skipped.`,
`Glob tool not found. Path ${pathName} will be skipped.`,
);
}
} else {
console.error(
`Error stating path ${pathName}: ${getErrorMessage(error)}`,
);
onDebugMessage(
`Glob tool not found. Path ${pathName} will be skipped.`,
`Error stating path ${pathName}. Path ${pathName} will be skipped.`,
);
}
} else {
console.error(
`Error stating path ${pathName}: ${getErrorMessage(error)}`,
);
onDebugMessage(
`Error stating path ${pathName}. Path ${pathName} will be skipped.`,
);
}
}
if (resolvedSuccessfully) {
pathSpecsToRead.push(currentPathSpec);
atPathToResolvedSpecMap.set(originalAtPath, currentPathSpec);
contentLabelsForDisplay.push(pathName);
if (resolvedSuccessfully) {
pathSpecsToRead.push(currentPathSpec);
atPathToResolvedSpecMap.set(originalAtPath, currentPathSpec);
contentLabelsForDisplay.push(pathName);
break;
}
}
}
@@ -314,8 +328,7 @@ export async function handleAtCommand({
if (
i > 0 &&
initialQueryText.length > 0 &&
!initialQueryText.endsWith(' ') &&
resolvedSpec
!initialQueryText.endsWith(' ')
) {
// Add space if previous part was text and didn't end with space, or if previous was @path
const prevPart = commandParts[i - 1];

View File

@@ -4,15 +4,36 @@
* SPDX-License-Identifier: Apache-2.0
*/
const { logSlashCommand, SlashCommandEvent } = vi.hoisted(() => ({
logSlashCommand: vi.fn(),
SlashCommandEvent: vi.fn((command, subCommand) => ({ command, subCommand })),
}));
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...original,
logSlashCommand,
SlashCommandEvent,
getIdeInstaller: vi.fn().mockReturnValue(null),
};
});
const { mockProcessExit } = vi.hoisted(() => ({
mockProcessExit: vi.fn((_code?: number): never => undefined as never),
}));
vi.mock('node:process', () => ({
default: {
vi.mock('node:process', () => {
const mockProcess = {
exit: mockProcessExit,
},
}));
platform: 'test-platform',
};
return {
...mockProcess,
default: mockProcess,
};
});
const mockBuiltinLoadCommands = vi.fn();
vi.mock('../../services/BuiltinCommandLoader.js', () => ({
@@ -69,16 +90,18 @@ describe('useSlashCommandProcessor', () => {
const mockAddItem = vi.fn();
const mockClearItems = vi.fn();
const mockLoadHistory = vi.fn();
const mockSetShowHelp = vi.fn();
const mockOpenThemeDialog = vi.fn();
const mockOpenAuthDialog = vi.fn();
const mockSetQuittingMessages = vi.fn();
const mockConfig = {
getProjectRoot: () => '/mock/cwd',
getSessionId: () => 'test-session',
getGeminiClient: () => ({
getProjectRoot: vi.fn(() => '/mock/cwd'),
getSessionId: vi.fn(() => 'test-session'),
getGeminiClient: vi.fn(() => ({
setHistory: vi.fn().mockResolvedValue(undefined),
}),
})),
getExtensions: vi.fn(() => []),
getIdeMode: vi.fn(() => false),
} as unknown as Config;
const mockSettings = {} as LoadedSettings;
@@ -109,9 +132,8 @@ describe('useSlashCommandProcessor', () => {
mockClearItems,
mockLoadHistory,
vi.fn(), // refreshStatic
mockSetShowHelp,
vi.fn(), // onDebugMessage
vi.fn(), // openThemeDialog
mockOpenThemeDialog, // openThemeDialog
mockOpenAuthDialog,
vi.fn(), // openEditorDialog
vi.fn(), // toggleCorgiMode
@@ -311,19 +333,19 @@ describe('useSlashCommandProcessor', () => {
});
describe('Action Result Handling', () => {
it('should handle "dialog: help" action', async () => {
it('should handle "dialog: theme" action', async () => {
const command = createTestCommand({
name: 'helpcmd',
action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'help' }),
name: 'themecmd',
action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'theme' }),
});
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/helpcmd');
await result.current.handleSlashCommand('/themecmd');
});
expect(mockSetShowHelp).toHaveBeenCalledWith(true);
expect(mockOpenThemeDialog).toHaveBeenCalled();
});
it('should handle "load_history" action', async () => {
@@ -796,15 +818,15 @@ describe('useSlashCommandProcessor', () => {
mockClearItems,
mockLoadHistory,
vi.fn(), // refreshStatic
mockSetShowHelp,
vi.fn(), // onDebugMessage
vi.fn(), // openThemeDialog
mockOpenAuthDialog,
vi.fn(), // openEditorDialog,
vi.fn(), // openEditorDialog
vi.fn(), // toggleCorgiMode
mockSetQuittingMessages,
vi.fn(), // openPrivacyNotice
vi.fn(), // toggleVimEnabled
vi.fn().mockResolvedValue(false), // toggleVimEnabled
vi.fn(), // setIsProcessing
),
);
@@ -813,4 +835,83 @@ describe('useSlashCommandProcessor', () => {
expect(abortSpy).toHaveBeenCalledTimes(1);
});
});
describe('Slash Command Logging', () => {
const mockCommandAction = vi.fn().mockResolvedValue({ type: 'handled' });
const loggingTestCommands: SlashCommand[] = [
createTestCommand({
name: 'logtest',
action: mockCommandAction,
}),
createTestCommand({
name: 'logwithsub',
subCommands: [
createTestCommand({
name: 'sub',
action: mockCommandAction,
}),
],
}),
createTestCommand({
name: 'logalias',
altNames: ['la'],
action: mockCommandAction,
}),
];
beforeEach(() => {
mockCommandAction.mockClear();
vi.mocked(logSlashCommand).mockClear();
vi.mocked(SlashCommandEvent).mockClear();
});
it('should log a simple slash command', async () => {
const result = setupProcessorHook(loggingTestCommands);
await waitFor(() =>
expect(result.current.slashCommands.length).toBeGreaterThan(0),
);
await act(async () => {
await result.current.handleSlashCommand('/logtest');
});
expect(logSlashCommand).toHaveBeenCalledTimes(1);
expect(SlashCommandEvent).toHaveBeenCalledWith('logtest', undefined);
});
it('should log a slash command with a subcommand', async () => {
const result = setupProcessorHook(loggingTestCommands);
await waitFor(() =>
expect(result.current.slashCommands.length).toBeGreaterThan(0),
);
await act(async () => {
await result.current.handleSlashCommand('/logwithsub sub');
});
expect(logSlashCommand).toHaveBeenCalledTimes(1);
expect(SlashCommandEvent).toHaveBeenCalledWith('logwithsub', 'sub');
});
it('should log the command path when an alias is used', async () => {
const result = setupProcessorHook(loggingTestCommands);
await waitFor(() =>
expect(result.current.slashCommands.length).toBeGreaterThan(0),
);
await act(async () => {
await result.current.handleSlashCommand('/la');
});
expect(logSlashCommand).toHaveBeenCalledTimes(1);
expect(SlashCommandEvent).toHaveBeenCalledWith('logalias', undefined);
});
it('should not log for unknown commands', async () => {
const result = setupProcessorHook(loggingTestCommands);
await waitFor(() =>
expect(result.current.slashCommands.length).toBeGreaterThan(0),
);
await act(async () => {
await result.current.handleSlashCommand('/unknown');
});
expect(logSlashCommand).not.toHaveBeenCalled();
});
});
});

View File

@@ -13,6 +13,8 @@ import {
Config,
GitService,
Logger,
logSlashCommand,
SlashCommandEvent,
ToolConfirmationOutcome,
} from '@qwen-code/qwen-code-core';
import { useSessionStats } from '../contexts/SessionContext.js';
@@ -40,7 +42,6 @@ export const useSlashCommandProcessor = (
clearItems: UseHistoryManagerReturn['clearItems'],
loadHistory: UseHistoryManagerReturn['loadHistory'],
refreshStatic: () => void,
setShowHelp: React.Dispatch<React.SetStateAction<boolean>>,
onDebugMessage: (message: string) => void,
openThemeDialog: () => void,
openAuthDialog: () => void,
@@ -103,6 +104,11 @@ export const useSlashCommandProcessor = (
selectedAuthType: message.selectedAuthType,
gcpProject: message.gcpProject,
};
} else if (message.type === MessageType.HELP) {
historyItemContent = {
type: 'help',
timestamp: message.timestamp,
};
} else if (message.type === MessageType.STATS) {
historyItemContent = {
type: 'stats',
@@ -136,7 +142,6 @@ export const useSlashCommandProcessor = (
},
[addItem],
);
const commandContext = useMemo(
(): CommandContext => ({
services: {
@@ -185,6 +190,8 @@ export const useSlashCommandProcessor = (
],
);
const ideMode = config?.getIdeMode();
useEffect(() => {
const controller = new AbortController();
const load = async () => {
@@ -205,7 +212,7 @@ export const useSlashCommandProcessor = (
return () => {
controller.abort();
};
}, [config]);
}, [config, ideMode]);
const handleSlashCommand = useCallback(
async (
@@ -235,6 +242,7 @@ export const useSlashCommandProcessor = (
let currentCommands = commands;
let commandToExecute: SlashCommand | undefined;
let pathIndex = 0;
const canonicalPath: string[] = [];
for (const part of commandPath) {
// TODO: For better performance and architectural clarity, this two-pass
@@ -255,6 +263,7 @@ export const useSlashCommandProcessor = (
if (foundCommand) {
commandToExecute = foundCommand;
canonicalPath.push(foundCommand.name);
pathIndex++;
if (foundCommand.subCommands) {
currentCommands = foundCommand.subCommands;
@@ -270,6 +279,17 @@ export const useSlashCommandProcessor = (
const args = parts.slice(pathIndex).join(' ');
if (commandToExecute.action) {
if (config) {
const resolvedCommandPath = canonicalPath;
const event = new SlashCommandEvent(
resolvedCommandPath[0],
resolvedCommandPath.length > 1
? resolvedCommandPath.slice(1).join(' ')
: undefined,
);
logSlashCommand(config, event);
}
const fullCommandContext: CommandContext = {
...commandContext,
invocation: {
@@ -318,9 +338,6 @@ export const useSlashCommandProcessor = (
return { type: 'handled' };
case 'dialog':
switch (result.dialog) {
case 'help':
setShowHelp(true);
return { type: 'handled' };
case 'auth':
openAuthDialog();
return { type: 'handled' };
@@ -447,7 +464,6 @@ export const useSlashCommandProcessor = (
[
config,
addItem,
setShowHelp,
openAuthDialog,
commands,
commandContext,

View File

@@ -7,21 +7,22 @@
/** @vitest-environment jsdom */
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCompletion } from './useCompletion.js';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useCommandCompletion } from './useCommandCompletion.js';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config, FileDiscoveryService } from '@qwen-code/qwen-code-core';
import { useTextBuffer, TextBuffer } from '../components/shared/text-buffer.js';
import { useTextBuffer } from '../components/shared/text-buffer.js';
describe('useCompletion', () => {
describe('useCommandCompletion', () => {
let testRootDir: string;
let mockConfig: Config;
// A minimal mock is sufficient for these tests.
const mockCommandContext = {} as CommandContext;
let testDirs: string[];
async function createEmptyDir(...pathSegments: string[]) {
const fullPath = path.join(testRootDir, ...pathSegments);
@@ -37,10 +38,10 @@ describe('useCompletion', () => {
}
// Helper to create real TextBuffer objects within renderHook
function useTextBufferForTest(text: string) {
function useTextBufferForTest(text: string, cursorOffset?: number) {
return useTextBuffer({
initialText: text,
initialCursorOffset: text.length,
initialCursorOffset: cursorOffset ?? text.length,
viewport: { width: 80, height: 20 },
isValidPath: () => false,
onChange: () => {},
@@ -49,10 +50,14 @@ describe('useCompletion', () => {
beforeEach(async () => {
testRootDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'completion-unit-test-'),
path.join(os.tmpdir(), 'slash-completion-unit-test-'),
);
testDirs = [testRootDir];
mockConfig = {
getTargetDir: () => testRootDir,
getWorkspaceContext: () => ({
getDirectories: () => testDirs,
}),
getProjectRoot: () => testRootDir,
getFileFilteringOptions: vi.fn(() => ({
respectGitIgnore: true,
@@ -77,11 +82,13 @@ describe('useCompletion', () => {
{ name: 'dummy', description: 'dummy' },
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest(''),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
false,
mockConfig,
),
);
@@ -106,11 +113,13 @@ describe('useCompletion', () => {
const { result, rerender } = renderHook(
({ text }) => {
const textBuffer = useTextBufferForTest(text);
return useCompletion(
return useCommandCompletion(
textBuffer,
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
false,
mockConfig,
);
},
@@ -127,7 +136,7 @@ describe('useCompletion', () => {
expect(result.current.isLoadingSuggestions).toBe(false);
});
it('should reset all state to default values', () => {
it('should reset all state to default values', async () => {
const slashCommands = [
{
name: 'help',
@@ -136,11 +145,13 @@ describe('useCompletion', () => {
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('/help'),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
false,
mockConfig,
),
);
@@ -154,6 +165,11 @@ describe('useCompletion', () => {
result.current.resetCompletionState();
});
// Wait for async suggestions clearing
await waitFor(() => {
expect(result.current.suggestions).toEqual([]);
});
expect(result.current.suggestions).toEqual([]);
expect(result.current.activeSuggestionIndex).toBe(-1);
expect(result.current.visibleStartIndex).toBe(0);
@@ -168,11 +184,13 @@ describe('useCompletion', () => {
{ name: 'dummy', description: 'dummy' },
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest(''),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
false,
mockConfig,
),
);
@@ -189,11 +207,14 @@ describe('useCompletion', () => {
{ name: 'dummy', description: 'dummy' },
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest(''),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
false,
mockConfig,
),
);
@@ -213,11 +234,14 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('/h'),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
false,
mockConfig,
),
);
@@ -240,11 +264,14 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('/h'),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
false,
mockConfig,
),
);
@@ -268,11 +295,14 @@ describe('useCompletion', () => {
{ name: 'chat', description: 'Manage chat' },
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('/'),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
false,
mockConfig,
),
);
@@ -313,11 +343,14 @@ describe('useCompletion', () => {
})) as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('/command'),
testDirs,
testRootDir,
largeMockCommands,
mockCommandContext,
false,
mockConfig,
),
);
@@ -370,8 +403,9 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('/'),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -392,8 +426,9 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('/mem'),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -415,8 +450,9 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('/usag'), // part of the word "usage"
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -441,8 +477,9 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('/clear'), // No trailing space
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -472,8 +509,9 @@ describe('useCompletion', () => {
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest(query),
testDirs,
testRootDir,
mockSlashCommands,
mockCommandContext,
@@ -492,8 +530,9 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('/clear '),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -512,8 +551,9 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('/unknown-command'),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -545,8 +585,9 @@ describe('useCompletion', () => {
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('/memory'), // Note: no trailing space
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -582,8 +623,9 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('/memory'),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -617,8 +659,9 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('/memory a'),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -648,8 +691,9 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('/memory dothisnow'),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -690,8 +734,9 @@ describe('useCompletion', () => {
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('/chat resume my-ch'),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -733,8 +778,9 @@ describe('useCompletion', () => {
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('/chat resume '),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
@@ -767,11 +813,14 @@ describe('useCompletion', () => {
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('/chat resume '),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
false,
mockConfig,
),
);
@@ -794,11 +843,14 @@ describe('useCompletion', () => {
await createTestFile('', 'README.md');
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('@s'),
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
),
);
@@ -827,11 +879,14 @@ describe('useCompletion', () => {
await createTestFile('', 'src', 'index.ts');
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('@src/comp'),
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
),
);
@@ -852,11 +907,14 @@ describe('useCompletion', () => {
await createTestFile('', 'src', 'index.ts');
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('@.'),
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
),
);
@@ -883,11 +941,14 @@ describe('useCompletion', () => {
await createEmptyDir('dist');
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('@d'),
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfigNoRecursive,
),
);
@@ -908,8 +969,9 @@ describe('useCompletion', () => {
await createTestFile('', 'README.md');
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('@'),
testDirs,
testRootDir,
[],
mockCommandContext,
@@ -942,11 +1004,14 @@ describe('useCompletion', () => {
.mockImplementation(() => {});
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('@'),
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
),
);
@@ -972,11 +1037,14 @@ describe('useCompletion', () => {
await createEmptyDir('data');
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('@d'),
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
),
);
@@ -1005,11 +1073,14 @@ describe('useCompletion', () => {
await createTestFile('', 'README.md');
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('@'),
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
),
);
@@ -1037,11 +1108,14 @@ describe('useCompletion', () => {
await createTestFile('', 'temp', 'temp.log');
const { result } = renderHook(() =>
useCompletion(
useCommandCompletion(
useTextBufferForTest('@t'),
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
),
);
@@ -1076,21 +1150,21 @@ describe('useCompletion', () => {
],
},
] as unknown as SlashCommand[];
// Create a mock buffer that we can spy on directly
const mockBuffer = {
text: '/mem',
setText: vi.fn(),
} as unknown as TextBuffer;
const { result } = renderHook(() =>
useCompletion(
mockBuffer,
const { result } = renderHook(() => {
const textBuffer = useTextBufferForTest('/mem');
const completion = useCommandCompletion(
textBuffer,
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
false,
mockConfig,
),
);
);
return { ...completion, textBuffer };
});
expect(result.current.suggestions.map((s) => s.value)).toEqual([
'memory',
@@ -1100,14 +1174,10 @@ describe('useCompletion', () => {
result.current.handleAutocomplete(0);
});
expect(mockBuffer.setText).toHaveBeenCalledWith('/memory ');
expect(result.current.textBuffer.text).toBe('/memory ');
});
it('should append a sub-command when the parent is complete', () => {
const mockBuffer = {
text: '/memory',
setText: vi.fn(),
} as unknown as TextBuffer;
const slashCommands = [
{
name: 'memory',
@@ -1125,15 +1195,20 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
mockBuffer,
const { result } = renderHook(() => {
const textBuffer = useTextBufferForTest('/memory');
const completion = useCommandCompletion(
textBuffer,
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
false,
mockConfig,
),
);
);
return { ...completion, textBuffer };
});
// Suggestions are populated by useEffect
expect(result.current.suggestions.map((s) => s.value)).toEqual([
@@ -1145,14 +1220,10 @@ describe('useCompletion', () => {
result.current.handleAutocomplete(1); // index 1 is 'add'
});
expect(mockBuffer.setText).toHaveBeenCalledWith('/memory add ');
expect(result.current.textBuffer.text).toBe('/memory add ');
});
it('should complete a command with an alternative name', () => {
const mockBuffer = {
text: '/?',
setText: vi.fn(),
} as unknown as TextBuffer;
const slashCommands = [
{
name: 'memory',
@@ -1170,15 +1241,20 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCompletion(
mockBuffer,
const { result } = renderHook(() => {
const textBuffer = useTextBufferForTest('/?');
const completion = useCommandCompletion(
textBuffer,
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
false,
mockConfig,
),
);
);
return { ...completion, textBuffer };
});
result.current.suggestions.push({
label: 'help',
@@ -1190,43 +1266,23 @@ describe('useCompletion', () => {
result.current.handleAutocomplete(0);
});
expect(mockBuffer.setText).toHaveBeenCalledWith('/help ');
expect(result.current.textBuffer.text).toBe('/help ');
});
it('should complete a file path', async () => {
const mockBuffer = {
text: '@src/fi',
lines: ['@src/fi'],
cursor: [0, 7],
setText: vi.fn(),
replaceRangeByOffset: vi.fn(),
} as unknown as TextBuffer;
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(() =>
useCompletion(
mockBuffer,
it('should complete a file path', () => {
const { result } = renderHook(() => {
const textBuffer = useTextBufferForTest('@src/fi');
const completion = useCommandCompletion(
textBuffer,
testDirs,
testRootDir,
slashCommands,
[],
mockCommandContext,
false,
mockConfig,
),
);
);
return { ...completion, textBuffer };
});
result.current.suggestions.push({
label: 'file1.txt',
@@ -1237,11 +1293,324 @@ describe('useCompletion', () => {
result.current.handleAutocomplete(0);
});
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalledWith(
5, // after '@src/'
mockBuffer.text.length,
'file1.txt',
expect(result.current.textBuffer.text).toBe('@src/file1.txt ');
});
it('should complete a file path when cursor is not at the end of the line', () => {
const text = '@src/fi le.txt';
const cursorOffset = 7; // after "i"
const { result } = renderHook(() => {
const textBuffer = useTextBufferForTest(text, cursorOffset);
const completion = useCommandCompletion(
textBuffer,
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
);
return { ...completion, textBuffer };
});
result.current.suggestions.push({
label: 'file1.txt',
value: 'file1.txt',
});
act(() => {
result.current.handleAutocomplete(0);
});
expect(result.current.textBuffer.text).toBe('@src/file1.txt le.txt');
});
it('should complete the correct file path with multiple @-commands', () => {
const text = '@file1.txt @src/fi';
const { result } = renderHook(() => {
const textBuffer = useTextBufferForTest(text);
const completion = useCommandCompletion(
textBuffer,
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
);
return { ...completion, textBuffer };
});
result.current.suggestions.push({
label: 'file2.txt',
value: 'file2.txt',
});
act(() => {
result.current.handleAutocomplete(0);
});
expect(result.current.textBuffer.text).toBe('@file1.txt @src/file2.txt ');
});
});
describe('File Path Escaping', () => {
it('should escape special characters in file names', async () => {
await createTestFile('', 'my file.txt');
await createTestFile('', 'file(1).txt');
await createTestFile('', 'backup[old].txt');
const { result } = renderHook(() =>
useCommandCompletion(
useTextBufferForTest('@my'),
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
),
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
const suggestion = result.current.suggestions.find(
(s) => s.label === 'my file.txt',
);
expect(suggestion).toBeDefined();
expect(suggestion!.value).toBe('my\\ file.txt');
});
it('should escape parentheses in file names', async () => {
await createTestFile('', 'document(final).docx');
await createTestFile('', 'script(v2).sh');
const { result } = renderHook(() =>
useCommandCompletion(
useTextBufferForTest('@doc'),
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
),
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
const suggestion = result.current.suggestions.find(
(s) => s.label === 'document(final).docx',
);
expect(suggestion).toBeDefined();
expect(suggestion!.value).toBe('document\\(final\\).docx');
});
it('should escape square brackets in file names', async () => {
await createTestFile('', 'backup[2024-01-01].zip');
await createTestFile('', 'config[dev].json');
const { result } = renderHook(() =>
useCommandCompletion(
useTextBufferForTest('@backup'),
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
),
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
const suggestion = result.current.suggestions.find(
(s) => s.label === 'backup[2024-01-01].zip',
);
expect(suggestion).toBeDefined();
expect(suggestion!.value).toBe('backup\\[2024-01-01\\].zip');
});
it('should escape multiple special characters in file names', async () => {
await createTestFile('', 'my file (backup) [v1.2].txt');
await createTestFile('', 'data & config {prod}.json');
const { result } = renderHook(() =>
useCommandCompletion(
useTextBufferForTest('@my'),
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
),
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
const suggestion = result.current.suggestions.find(
(s) => s.label === 'my file (backup) [v1.2].txt',
);
expect(suggestion).toBeDefined();
expect(suggestion!.value).toBe(
'my\\ file\\ \\(backup\\)\\ \\[v1.2\\].txt',
);
});
it('should preserve path separators while escaping special characters', async () => {
await createTestFile(
'',
'projects',
'my project (2024)',
'file with spaces.txt',
);
const { result } = renderHook(() =>
useCommandCompletion(
useTextBufferForTest('@projects/my'),
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
),
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
const suggestion = result.current.suggestions.find((s) =>
s.label.includes('my project'),
);
expect(suggestion).toBeDefined();
// Should escape spaces and parentheses but preserve forward slashes
expect(suggestion!.value).toMatch(/my\\ project\\ \\\(2024\\\)/);
expect(suggestion!.value).toContain('/'); // Should contain forward slash for path separator
});
it('should normalize Windows path separators to forward slashes while preserving escaping', async () => {
// Create test with complex nested structure
await createTestFile(
'',
'deep',
'nested',
'special folder',
'file with (parentheses).txt',
);
const { result } = renderHook(() =>
useCommandCompletion(
useTextBufferForTest('@deep/nested/special'),
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
),
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
const suggestion = result.current.suggestions.find((s) =>
s.label.includes('special folder'),
);
expect(suggestion).toBeDefined();
// Should use forward slashes for path separators and escape spaces
expect(suggestion!.value).toContain('special\\ folder/');
expect(suggestion!.value).not.toContain('\\\\'); // Should not contain double backslashes for path separators
});
it('should handle directory names with special characters', async () => {
await createEmptyDir('my documents (personal)');
await createEmptyDir('config [production]');
await createEmptyDir('data & logs');
const { result } = renderHook(() =>
useCommandCompletion(
useTextBufferForTest('@'),
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
),
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
const suggestions = result.current.suggestions;
const docSuggestion = suggestions.find(
(s) => s.label === 'my documents (personal)/',
);
expect(docSuggestion).toBeDefined();
expect(docSuggestion!.value).toBe('my\\ documents\\ \\(personal\\)/');
const configSuggestion = suggestions.find(
(s) => s.label === 'config [production]/',
);
expect(configSuggestion).toBeDefined();
expect(configSuggestion!.value).toBe('config\\ \\[production\\]/');
const dataSuggestion = suggestions.find(
(s) => s.label === 'data & logs/',
);
expect(dataSuggestion).toBeDefined();
expect(dataSuggestion!.value).toBe('data\\ \\&\\ logs/');
});
it('should handle files with various shell metacharacters', async () => {
await createTestFile('', 'file$var.txt');
await createTestFile('', 'important!.md');
const { result } = renderHook(() =>
useCommandCompletion(
useTextBufferForTest('@'),
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
),
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
const suggestions = result.current.suggestions;
const dollarSuggestion = suggestions.find(
(s) => s.label === 'file$var.txt',
);
expect(dollarSuggestion).toBeDefined();
expect(dollarSuggestion!.value).toBe('file\\$var.txt');
const importantSuggestion = suggestions.find(
(s) => s.label === 'important!.md',
);
expect(importantSuggestion).toBeDefined();
expect(importantSuggestion!.value).toBe('important\\!.md');
});
});
});

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