Compare commits

...

280 Commits

Author SHA1 Message Date
github-actions[bot]
cac3202945 chore(release): v0.0.8-nightly.5 2025-08-20 08:32:02 +00:00
tanzhenxin
213a017539 feat: add automated release notes generation with previous tag detection 2025-08-20 16:17:49 +08:00
pomelo
8833a112a9 Merge pull request #364 from QwenLM/chore/sync-gemini-cli-v0.1.19
Sync upstream gemini-cli v0.1.19
2025-08-20 16:04:47 +08:00
pomelo
d933795d8e docs: Update security policy with Alibaba contact information (#390) 2025-08-20 15:30:31 +08:00
tanzhenxin
c8f3b15971 chore: npm run format 2025-08-20 11:28:18 +08:00
tanzhenxin
2fcacb70b9 chore: fix ide installer 2025-08-20 11:25:11 +08:00
tanzhenxin
a0a1d6e253 chore: fix build issue 2025-08-20 11:11:18 +08:00
tanzhenxin
0de3236076 Merge branch 'main' into chore/sync-gemini-cli-v0.1.19 2025-08-20 10:45:42 +08:00
tanzhenxin
303b6999f4 fix: qwen vscode extension 2025-08-19 18:20:40 +08:00
pomelo
93f5e59710 Merge pull request #171 from dowithless/patch-1
doc: Add links to translated README versions
2025-08-19 15:13:42 +08:00
Fan
7b378e826c feat: project/global save location option (#368) 2025-08-18 23:09:50 +08:00
tanzhenxin
a19db16485 chore: npm run format 2025-08-18 20:01:54 +08:00
tanzhenxin
7dbc240847 chore: sync gemini-cli v0.1.19 2025-08-18 19:55:46 +08:00
thuan1412
5e70b34041 feat: use .geminiignore in grep tool (#349)
* feat: use .geminiignore in grep tool
2025-08-18 11:37:26 +08:00
tanzhenxin
df1479f864 Chore/release 0.0.7 (#343)
* chore: pump version to 0.0.7 and add changelog.md
2025-08-15 18:49:13 +08:00
Mingholy
14e6d3c01e Update qwen-code-pr-review.yml
Trigger Qwen PR Review when a PR opens.
Fix the auto-skip issue.
2025-08-15 18:24:43 +08:00
pomelo
da0b8b5534 Merge pull request #340 from QwenLM/feat/web_fetch_tool
feat: refactor web-fetch tool to remove google genai dependency
2025-08-15 18:10:32 +08:00
tanzhenxin
e1d502991d chore: remove https restricton 2025-08-15 17:58:05 +08:00
tanzhenxin
7e01554b9c chore: fix test case failure 2025-08-15 17:27:09 +08:00
tanzhenxin
36c65658ff chore: npm run lint 2025-08-15 17:16:05 +08:00
tanzhenxin
a925ac56fa Potential fix for code scanning alert no. 24: Incomplete URL substring sanitization
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-08-15 17:10:20 +08:00
tanzhenxin
5d4a9452d8 feat: refactor web-fetch tool to remove google genai dependency 2025-08-15 17:06:00 +08:00
tanzhenxin
3e082ae89a feat: replace google web search with tavily web search (#329) 2025-08-14 21:20:23 +08:00
Fan
51207043d0 fix: custom API's trailing space and empty tool id issues (#326)
* fix: generate random tool call id when serving API does not have one

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

* fix: format issues
2025-08-14 21:17:56 +08:00
tanzhenxin
c33d162ff2 Merge pull request #325 from QwenLM/fix/max_listeners_warning
fix: qwen logger exit handler setup
2025-08-14 20:06:14 +08:00
tanzhenxin
bbfe94cfe2 chore: npm run format 2025-08-14 18:54:24 +08:00
pomelo
03c7b1836f Merge pull request #323 from QwenLM/release/0.0.6
chore: bump version to 0.0.6
2025-08-14 18:54:04 +08:00
pomelo
f2ba6dbb8a Merge pull request #322 from QwenLM/fix/concurrent_requests
feat: prevent concurrent query submissions in useGeminiStream hook
2025-08-14 18:53:19 +08:00
tanzhenxin
2d0884b04d fix: qwen logger exit handler setup 2025-08-14 18:08:14 +08:00
tanzhenxin
fc70439355 chore: bump version to 0.0.6 2025-08-14 16:52:39 +08:00
tanzhenxin
0265b67b90 chore: npm run format & lint 2025-08-14 16:48:51 +08:00
tanzhenxin
1f91b9ece1 Merge pull request #309 from QwenLM/chore/sync-gemini-cli-v0.1.18
Sync with upstream gemini-cli v0.1.18
2025-08-14 16:47:31 +08:00
tanzhenxin
c58106079e feat: prevent concurrent query submissions in useGeminiStream hook 2025-08-14 16:39:26 +08:00
tanzhenxin
290ccdbe21 chore: stick openai sdk version to 5.11.0 2025-08-13 16:10:04 +08:00
tanzhenxin
b5514fd052 chore: fix invalid package deps 2025-08-13 16:00:26 +08:00
tanzhenxin
bc92da04e9 Merge tag 'v0.1.18' of https://github.com/google-gemini/gemini-cli into chore/sync-gemini-cli-v0.1.18 2025-08-13 15:11:10 +08:00
gemini-cli-robot
5349c4d02b chore(release): v0.1.19 2025-08-12 18:21:15 +00:00
Shreya Keshive
67c6033147 Bump version number of companion extension to match next Gemini CLI version number (#6065) 2025-08-12 18:03:55 +00:00
owenofbrien
5d1d40fa2e Fix: log api response error status codes (#6015)
Co-authored-by: Gaurav <39389231+gsquared94@users.noreply.github.com>
2025-08-12 16:51:21 +00:00
Jacob Richman
804c181ac4 chore(integration-tests): refactor to typescript (#5645) 2025-08-12 16:19:09 +00:00
JAYADITYA
2d1a6af890 feat(cli): support single Ctrl+C to cancel streaming, preserving double Ctrl+C to exit (#5838) 2025-08-12 04:13:57 +00:00
Ali Al Jufairi
f9efb2e24f docs(commands): add /settings command for user-friendly settings editing (#5984)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-12 04:08:35 +00:00
Seth Vargo
d8fec54e81 feat(/setup-github): Use node to download the files (#5863) 2025-08-12 01:32:23 +00:00
Sandy Tao
26fe587b44 skip loop check if it is currently inside a loop (#6022) 2025-08-11 23:45:31 +00:00
Wanlin Du
d9fb08c9da feat: migrate tools to use parametersJsonSchema. (#5330) 2025-08-11 23:12:41 +00:00
Wanlin Du
f52d073dfb chore: migrate from responseSchema to use responseJsonSchema. (#4814) 2025-08-11 23:04:58 +00:00
Shreya Keshive
c7fd4c4a96 Start IDE connection after config initialization (#6018) 2025-08-11 22:09:47 +00:00
christine betts
94b6199943 [ide-mode] Update sandbox detection logic to support macos seatbelt (#6005)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2025-08-11 21:09:57 +00:00
christine betts
c0f5f6a5f6 [ide-mode] Update handling of workspace paths (#6014)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-11 21:06:01 +00:00
christine betts
0e98641b51 Add support for VSCode-like editors (#5699)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-11 21:01:37 +00:00
Shreya Keshive
4656f17524 Reduce noisy IDE integration error message in standalone terminal (#6006) 2025-08-11 19:57:56 +00:00
cornmander
110e00178b Add --experimental-cli to speed up prettier formatting. (#5999)
Co-authored-by: Seth Troisi <sethtroisi@google.com>
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2025-08-11 19:09:12 +00:00
Sijie Wang
72832fb889 Fix line end bugs in Vim mode (#5328)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-11 18:58:32 +00:00
Gaurav
6390b81646 update: issue triage workflows tags duplicate issues (#5868) 2025-08-11 18:48:57 +00:00
Tommaso Sciortino
239ba63d28 Make ProjectIdRequiredError error more lenient (#5693) 2025-08-11 18:04:44 +00:00
Jacob Richman
2269f8a1a8 Modify content generated describing the ide context to only include deltas after the initial update (#5880) 2025-08-11 17:15:44 +00:00
cornmander
aa5c80dec4 feat(core): add host validation to GoogleCredentialProvider (#5962)
Co-authored-by: Brian Ray <62354532+emeryray2002@users.noreply.github.com>
2025-08-11 16:40:30 +00:00
Shreya Keshive
b0b12af2ce Additional IDE integration polishes (#5985) 2025-08-11 16:27:45 +00:00
christine betts
8dd6f04199 Show IDE diff options in both panes (#5986) 2025-08-11 16:13:45 +00:00
Lee James
2548facc79 feat: add "surface" to all logs (#5862) 2025-08-11 15:11:20 +00:00
Gal Zahavi
2865a52778 docs(config): Add showLineNumbers option to documentation (#5947) 2025-08-10 19:06:35 +00:00
Dmitry Lyalin
0e44bbc85d docs(readme): Overhaul for clarity and user experience (#5732)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Srinath Padmanabhan <17151014+srithreepo@users.noreply.github.com>
2025-08-10 18:52:14 +00:00
Ali Al Jufairi
0157eae3d7 fix(settings): enable default usage statistics collection (#5909) 2025-08-10 02:56:53 +00:00
Ali Al Jufairi
8a9a927544 feat(ui): add /settings command and UI panel (#4738)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-10 00:04:52 +00:00
Lee Won Jun
c632ec8b03 [#5356] Minor fix: Remove duplicate binding and add complete navigation command (#5884)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-09 22:28:28 +00:00
fuyou
0dea7233b6 feat(cli) - enhance input UX with double ESC clear (#4453)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-09 22:26:43 +00:00
Yuki Okita
34434cd4aa feat: drop load-memory-from-include-directories option from cli args (#5866) 2025-08-09 19:50:53 +00:00
JAYADITYA
6b19c8bd55 feat: add humorous tip for new line shortcut in Gemini CLI (#5666)
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-08-09 07:24:21 +00:00
Lee Won Jun
b8084ba815 Centralize Key Binding Logic and Refactor (Reopen) (#5356)
Co-authored-by: Lee-WonJun <10369528+Lee-WonJun@users.noreply.github.com>
2025-08-09 07:03:17 +00:00
Hiroaki Mitsuyoshi
6487cc1689 feat(chat): Add overwrite confirmation dialog to /chat save (#5686)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-09 06:59:22 +00:00
Brian de Alwis
191cc01bf5 fix(core): restrict oauth_creds.json file permissions (#5245)
Co-authored-by: cornmander <shikhman@google.com>
2025-08-09 03:05:30 +00:00
N. Taylor Mullen
c184ec3224 chore(release): v0.1.18 (#5864) 2025-08-08 17:26:43 -07:00
Jacob MacDonald
f35921a771 Add MCP Roots support (#5856)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-08 23:29:06 +00:00
Gal Zahavi
c03ae43777 feat: Add option to hide line numbers in code blocks (#5857)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-08 22:11:14 +00:00
Jacob MacDonald
69322e12e4 Add a request queue to the tool scheduler (#5845) 2025-08-08 21:50:35 +00:00
Shreya Keshive
9ac62565a0 Fix excessive console logging + remove unnecessary try catch (#5860) 2025-08-08 21:48:02 +00:00
Shreya Keshive
344ee29f77 Use slash command instead of context drawer to display open files in editor to reduce flickering in the UI (#5858) 2025-08-08 21:26:11 +00:00
shishu314
60bde58f29 fix(cli) - Adding logging for response and error in LoggingContentGenerator (#5842)
Co-authored-by: Shi Shu <shii@google.com>
2025-08-08 19:58:33 +00:00
shrutip90
34b5dc7f28 Add FolderTrustDialog that shows on launch and enables folderTrust setting (#5815) 2025-08-08 18:02:27 +00:00
christine betts
3af4913ef3 [ide-mode] Close all open diffs when the CLI gets closed (#5792) 2025-08-08 15:38:30 +00:00
christine betts
5ec4ea9b4d [ide-mode] Wire up env variables to sandbox (#5804) 2025-08-08 15:35:47 +00:00
christine betts
407393b128 [ide-mode] Hide diff options when active diff is not focused (#5808)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-08 15:21:50 +00:00
Gal Zahavi
51d09e720b fix(core): Add missing mnemonist dependency (#5841) 2025-08-08 15:10:04 +00:00
Akhil Appana
f5e0f16157 fix: properly report tool errors in telemetry (#5688)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-08 11:33:42 +00:00
agarwalravikant
5ab184fcaf Fix for git issue 5657 to add lines of code added/removed telemetry (#5823)
Co-authored-by: Ravikant Agarwal <ravikantag@google.com>
2025-08-08 04:38:07 +00:00
Gal Zahavi
86eaa03f8a feat(telemetry): Prevent memory leak in ClearcutLogger (#5734) 2025-08-08 01:53:39 +00:00
Jerop Kipruto
e50d886ba8 docs: Improve local telemetry example (#5818) 2025-08-08 01:17:19 +00:00
Sandy Tao
e8815ba43c feat(quality): Reset when seeing a new type of Markdown element (#5820) 2025-08-08 00:21:42 +00:00
shishu314
bae922a632 fix(cli) - Move logging into CodeAssistServer (#5781)
Co-authored-by: Shi Shu <shii@google.com>
2025-08-07 23:58:18 +00:00
laurentsimon
60362e0329 fix: MCP servers allowed in settings do not show up in /mcp command (#5324) 2025-08-07 23:42:17 +00:00
Jerop Kipruto
494a10e7a7 Add echo tool to automated triage workflow (#5809)
Co-authored-by: Gaurav <39389231+gsquared94@users.noreply.github.com>
2025-08-07 23:14:28 +00:00
Miguel Solorio
785ee5d59a Use semantic colors in themes (#5796)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-07 23:11:35 +00:00
Gal Zahavi
4f2974dbfe feat(ui): Improve UI layout adaptation for narrow terminals (#5651)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-07 22:55:53 +00:00
Richie Foreman
65e4b941ee chore(vscode): Add recommended extensions list to vscode settings. (#5810) 2025-08-07 22:54:00 +00:00
Richie Foreman
9bc0a4aff3 chore(telemetry): Log FIREBASE_STUDIO when using Gemini CLI within Firebase Studio (#5790) 2025-08-07 22:50:48 +00:00
Allen Hutchison
0c32a4061d fix(core): Replace flaky performance tests with robust correctness tests (#5795)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-07 22:38:21 +00:00
Bryant Chandler
9fc7115b86 perf(filesearch): Use async fzf for non-blocking file search (#5771)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-07 22:24:55 +00:00
Richie Foreman
c38147a3a6 chore(vscode settings): Update VsCode settings for quality-of-life (#5806)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-07 22:16:57 +00:00
Gaurav
908ce2be33 update: google-github-actions/run-gemini-cli version in workflows (#5802) 2025-08-07 21:57:54 +00:00
Shreya Keshive
f1663d9615 README + reduce required VS Code version for companion extension (#5719) 2025-08-07 21:25:06 +00:00
Shreya Keshive
4d4eacfc40 Few IDE integration polishes (#5727) 2025-08-07 21:19:31 +00:00
Jacob MacDonald
19491b7b94 avoid loading and initializing CLI config twice in non-interactive mode (#5793) 2025-08-07 21:19:06 +00:00
shrutip90
53f8617b24 Add new folderTrust setting that the users can enable or disable (#5798) 2025-08-07 21:06:17 +00:00
Adam Weidman
3a3b138195 Include Schema Error Handling for Vertex and Google Auth methods (#5780)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-07 20:21:39 +00:00
Pyush Sinha
8e6a565adb fix: re render context usage indicator (#5102) 2025-08-07 18:16:47 +00:00
anthony bushong
a3351bc985 fix(tests): add missing deps in cli to fix sandbox runs (#5742) 2025-08-07 17:58:44 +00:00
Shehab
7596481a9d feat(cli): Allow Exiting Authentication Menu with CTRL+C (SIGINT) (#4482)
Co-authored-by: Seth Troisi <sethtroisi@google.com>
2025-08-07 17:26:55 +00:00
joshualitt
8bac9e7d04 Migrate EditTool, GrepTool, and GlobTool to DeclarativeTool (#5744) 2025-08-07 17:05:37 +00:00
Sandy Tao
0d65baf928 Fix(core): Use Flash for next speaker check (#5786) 2025-08-07 16:18:53 +00:00
Lee James
8d848dca4a feat: open repo secrets page in addition to README (#5684) 2025-08-07 16:00:46 +00:00
Jacob MacDonald
6ae75c9f32 Add a context percentage threshold setting for auto compression (#5721) 2025-08-07 14:34:40 +00:00
Abhi
36750ca49b feat(agent): Introduce Foundational Subagent Architecture (#1805)
Co-authored-by: Colt McAnlis <colton@google.com>
2025-08-07 00:34:38 +00:00
Allen Hutchison
d6a7334279 fix(logging): Ensure sandbox startup messages are routed to stderr (#5725) 2025-08-07 00:19:10 +00:00
anthony bushong
99f88851fb fix(actions): swap gha bot for cla allowlisted gemini-cli-robot (#5730) 2025-08-07 00:01:22 +00:00
N. Taylor Mullen
01f7c4b740 Fix(tests): update mcp_server_cyclic_schema test (#5733) 2025-08-06 23:59:50 +00:00
DevMassive
9ac3e8b79e feat: Improve @-command file path completion with fzf integration (#5650)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-06 23:41:04 +00:00
Richie Foreman
4782113ceb fix(core): Improve errors in situations where the command spawn does … (#5723) 2025-08-06 23:31:42 +00:00
shrutip90
626844b539 experiment: Add feature exp flag for folder trust (#5709) 2025-08-06 22:27:21 +00:00
Seth Vargo
5cd63a6abc feat(cli): get the run-gemini-cli version from the GitHub API (#5708) 2025-08-06 20:56:06 +00:00
christine betts
b55467c1dd [ide-mode] Support rendering in-IDE diffs using the edit tool (#5618)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-06 20:55:29 +00:00
joshualitt
43510ed212 bug(core): Prompt engineering for truncated read_file. (#5161) 2025-08-06 20:52:04 +00:00
Jack Wotherspoon
ad5d2af4e3 tests: fix e2e tests (#5706) 2025-08-06 20:46:50 +00:00
Jacob MacDonald
e3e7677753 Add integration test for maximum schema depth error handling (#5685) 2025-08-06 20:45:54 +00:00
Jacob MacDonald
b3cfaeb6d3 Add detection of tools with bad schemas and automatically omit them with a warning (#5694) 2025-08-06 20:19:15 +00:00
Shreya Keshive
024b8207eb Add hint to enable IDE integration for users running in VS Code (#5610) 2025-08-06 19:47:58 +00:00
Lee James
1fb680bacc bug(tests): fix test errors (#5678)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2025-08-06 19:26:46 +00:00
shishu314
1f0ad86544 fix: Restore user input when the user cancels response (#5601)
Co-authored-by: Shi Shu <shii@google.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-06 19:19:10 +00:00
joshualitt
6133bea388 feat(core): Introduce DeclarativeTool and ToolInvocation. (#5613) 2025-08-06 17:50:02 +00:00
agarwalravikant
882a97aff9 Fix to send user tool confirmation decision for yolo or non interacti… (#5677)
Co-authored-by: Ravikant Agarwal <ravikantag@google.com>
2025-08-06 17:46:42 +00:00
christine betts
fde9849d48 [ide-mode] Add support for in-IDE diff handling in the CLI (#5603)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-06 17:36:05 +00:00
Akhil Appana
487818df27 fix: improve error handling and path processing in memory discovery (#5175)
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-08-06 17:19:43 +00:00
Jack Wotherspoon
ca4c745e3b feat(mcp): add gemini mcp commands for add, remove and list (#5481) 2025-08-06 15:52:29 +00:00
Lee James
b38f377c9a feat: Enable /setup-github to always run, and error appropriately (#5653)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-06 13:06:37 +00:00
Bryant Chandler
aab850668c feat(file-search): Add support for non-recursive file search (#5648)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-06 06:33:27 +00:00
Yash Velagapudi
8b1d5a2e3c fix(core): Treat .mts files as TypeScript modules instead of video files (#5492)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-06 06:15:53 +00:00
Gaurav
a0990380b5 fix:missing coreTool in new workflow setup (#5656) 2025-08-06 05:26:27 +00:00
Jerop Kipruto
2fcaa302da docs: add GitHub Integration section to README (#5649) 2025-08-06 04:01:42 +00:00
Lee James
7fa2d7be17 doc(lint): fix docs on how to run linter in "fix" mode (#5647) 2025-08-06 03:21:36 +00:00
Lee James
be3aabaea6 docs(setup-github): Inform user of the next steps after running slash command (#5644)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-06 02:59:25 +00:00
Gaurav
b87b436ebc refactor: use google-github-actions/run-gemini-cli action (#5643) 2025-08-06 02:24:40 +00:00
Jacob MacDonald
7e5a5e2da7 Detect and warn about cyclic tool refs when schema depth errors are encountered (#5609)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-06 01:48:00 +00:00
christine betts
9db5aab498 Update a couple more witty phrases (#5641) 2025-08-06 01:13:22 +00:00
Sandy Tao
390edb5e0a Add tests for useAtCompletion reset logic (#5639) 2025-08-06 01:10:29 +00:00
github-actions[bot]
ea96293e16 chore(release): v0.1.18 2025-08-06 00:58:42 +00:00
Jerop Kipruto
cd7e60e008 switch from heads to tags in url path (#5638) 2025-08-05 17:47:28 -07:00
Sandy Tao
59bde4a612 fix(core) Fix not resetting when after first get out of completion suggestions (#5635)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-06 00:37:44 +00:00
Bryan Morgan
02f7e48c51 Removed GitHub Actions experiment files (#5627) 2025-08-06 00:01:18 +00:00
christine betts
aeb6602266 Remove a few witty loading phrases (#5631)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2025-08-05 23:59:14 +00:00
David Rees
805114aef8 fix(docs): Fix code block delimiters in commands.md (#5521)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 23:30:57 +00:00
Justin Mahood
91035ad7b0 Fix(vim): Fix shell mode in Vim mode (#5567)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-05 23:29:37 +00:00
Bryant Chandler
12a9bc3ed9 feat(core, cli): Introduce high-performance FileSearch engine (#5136)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-05 23:18:03 +00:00
Allen Hutchison
2141b39c3d feat(cli): route non-interactive output to stderr (#5624) 2025-08-05 23:11:21 +00:00
Shreya Keshive
268627469b Refactor IDE client state management, improve user-facing error messages, and add logging of connection events (#5591)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2025-08-05 22:52:58 +00:00
Jacob MacDonald
6a72cd064b check for the prompt capability before listing prompts from MCP servers (#5616)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 22:50:30 +00:00
sangwook
aebe3ace3c perf(core): implement parallel file processing for 74% performance improvement (#4763)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 22:47:18 +00:00
8bitmp3
c402784d97 Fix and improve Gemini CLI troubleshooting.md doc (#2734)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 22:43:41 +00:00
William Thurston
bed6ab1cce fix(start): use absolute path to resolve CLI package (#3196)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 22:43:15 +00:00
xyizko
1b08a6c063 fix(minor): Grammar and Typos (#5053)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-05 22:41:27 +00:00
Sandy Tao
82fa7a0660 fix(format) Fix format for .github/workflows/weekly-velocity-report.yml (#5622) 2025-08-05 22:32:06 +00:00
Bryan Morgan
2e9236fab4 Update weekly-velocity-report.yml 2025-08-05 18:11:06 -04:00
Mikhail Aksenov
dadf05809c feat: mcp - support audiences for OAuth2 (#5265) 2025-08-05 22:02:16 +00:00
Ramón Medrano Llamas
29c3825604 fix(mcp): clear prompt registry on refresh to prevent duplicates (#5385)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 21:59:31 +00:00
Hiroaki Mitsuyoshi
faf6a5497a feat(docs): Add /chat delete command in commands.md (#5408)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 21:58:09 +00:00
Jacob Richman
dd85aaa951 bug(core): Fix flaky test by using waitFor. (#5540)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2025-08-05 21:56:38 +00:00
Gal Zahavi
aacae1de43 fix(core): prevent UI shift after vim edit (#5315) 2025-08-05 21:55:54 +00:00
Sandy Tao
8d993156e7 Fix format (#5617) 2025-08-05 21:38:43 +00:00
Bryan Morgan
57003ca68c Update weekly-velocity-report.yml 2025-08-05 17:18:19 -04:00
Bryan Morgan
47de37eb0a Update weekly-velocity-report.yml 2025-08-05 17:10:37 -04:00
Bryan Morgan
dc7b4fda64 Update weekly-velocity-report.yml 2025-08-05 17:08:22 -04:00
Bryan Morgan
3dcca31796 Update weekly-velocity-report.yml 2025-08-05 17:00:44 -04:00
Bryan Morgan
c194a6ac3b GitHub Action for velocity reporting purposes (#5607) 2025-08-05 20:33:59 +00:00
Bryan Morgan
d421fa9e64 Testing basic velocity report action 2025-08-05 15:55:50 -04:00
Luccas Paroni
2778c7d851 feat(core): Parse Multimodal MCP Tool responses (#5529)
Co-authored-by: Luccas Paroni <luccasparoni@google.com>
2025-08-05 19:19:47 +00:00
Oleksandr Gotgelf
b465145229 chore(settings): clean up comments in settings.ts (#5576) 2025-08-05 19:10:16 +00:00
Alexander J
f2d6748432 fix: small typo in ROADMAP.md (#5593)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-05 19:04:10 +00:00
joshualitt
08f1431946 bug(core): fix contentRangeTruncated calculation. (#5329)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-05 18:52:39 +00:00
David East
43d5aaa798 fix(mcp): ensure authorization url is valid when containing query params (#5545)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-05 18:44:30 +00:00
Yuki Okita
5c8268b6f4 feat: Multi-Directory Workspace Support (part 3: configuration in settings.json) (#5354)
Co-authored-by: Allen Hutchison <adh@google.com>
2025-08-05 17:01:01 +00:00
Jack Wotherspoon
d0cda58f1f docs: update typo in commands.md (#5584) 2025-08-05 14:03:58 +00:00
N. Taylor Mullen
c7a1de4983 chore(release): v0.1.17 (#5561) 2025-08-04 21:37:32 -07:00
DeWitt Clinton
49001a0f83 Remove the "local modifications" string from bug and about reports. (#5552) 2025-08-05 04:01:19 +00:00
Olcan
11ecf6fc86 fix self-reference in build script (#5548) 2025-08-05 01:12:21 +00:00
Harold Mciver
99ba2f6424 Update MCP client to connect to servers with only prompts (#5290) 2025-08-04 21:38:23 +00:00
christine betts
93f8fe3671 [ide-mode] Add openDiff tool to IDE MCP server (#4519)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-04 21:36:23 +00:00
Mo Moadeli
e7b468e122 feat(cli): Prevent redundant opening of browser tabs when zero MCP servers are configured (#5367)
Co-authored-by: Allen Hutchison <adh@google.com>
2025-08-04 21:20:49 +00:00
Shreya Keshive
dca040908a ide-mode flag cleanup (#5531) 2025-08-04 21:06:50 +00:00
Shreya Keshive
2180dd13dc Improve user-facing error messages for IDE mode (#5522) 2025-08-04 21:06:17 +00:00
Richie Foreman
11808ef7ed fix(core): Allow model to be set from settings.json (#5527) 2025-08-04 20:41:58 +00:00
Sandy Tao
8da6d23688 refactor(core): Rename useSlashCompletion to useCommandCompletion (#5532) 2025-08-04 20:35:26 +00:00
Seth Vargo
37b83e05a7 Use new URLs for downloading workflows (#5524) 2025-08-04 20:10:36 +00:00
Jacob MacDonald
5caf23d627 remove unnecessary checks in WriteFileChecks.getDescription (#5526) 2025-08-04 19:12:33 +00:00
Sandy Tao
d1bfba1abb feat(core): Add trailing space when completing an at completion suggestion (#5475)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-04 18:30:59 +00:00
Sandy Tao
b9fe4fc263 feat(cli): Handle Punctuation in @ Command Parsing (#5482) 2025-08-04 17:49:15 +00:00
Pyush Sinha
e506b40c27 fix: /help remove flickering and respect clear shortcut (ctr+l) (#3611)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: Allen Hutchison <adh@google.com>
2025-08-04 16:53:50 +00:00
owenofbrien
83a04c4755 Cloud Shell surface logging fix (#5364) 2025-08-04 16:48:46 +00:00
matt korwel
94b7b402c5 feat(docs): create new documentation for automation and triage (#5363) 2025-08-04 08:49:14 -07:00
Kumbham Ajay Goud
a8984a9b30 Fix: Preserve conversation history when changing auth methods via /auth (#5216)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-03 22:03:01 +00:00
Ali Al Jufairi
acd48a1259 docs(fix): Update themes documentation to include new color keys for… (#5467) 2025-08-03 21:56:27 +00:00
N. Taylor Mullen
70478b92a9 chore(release): v0.1.16 (#5478) 2025-08-03 13:38:03 -07:00
Shreya Keshive
2cdaf912ba Generate NOTICES.TXT and surface via command (#5310) 2025-08-03 20:19:34 +00:00
Ayesha Shafique
072d8ba289 feat: Add reverse search capability for shell commands (#4793) 2025-08-03 19:53:24 +00:00
Oleksandr Gotgelf
03ed37d0dc fix: exclude DEBUG and DEBUG_MODE from project .env files by default (#5289)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-03 18:44:15 +00:00
Billy Biggs
bedcbb9feb Add a setting to disable the version update nag message (#5449) 2025-08-03 18:20:55 +00:00
Gal Zahavi
820169ba2e feat(autoupdate): Improve update check and refactor for testability (#5389) 2025-08-02 03:17:32 +00:00
TIRUMALASETTI PRANITH
15a1f1af9d fix(config): Resolve duplicate config loading from home directory (#5090)
Co-authored-by: Allen Hutchison <adh@google.com>
Co-authored-by: Allen Hutchison <allen@hutchison.org>
2025-08-01 22:22:17 +00:00
Allen Hutchison
387706607d fix(tests): refactor integration tests to be less flaky (#4890)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2025-08-01 21:33:33 +00:00
mrcabbage972
dccca91fc9 Switch utility calls to use the gemini-2.5-flash-lite model (#5193)
Co-authored-by: Anjali Sridhar <anjsridhar@gmail.com>
2025-08-01 21:11:51 +00:00
owenofbrien
a6a386f72a Propagate prompt (#5033) 2025-08-01 19:37:56 +00:00
joshualitt
67d16992cf bug(cli): Prefer IPv4 dns resolution by default. (#5338) 2025-08-01 19:30:39 +00:00
Santhosh Kumar
9382334a5e feat(github): add workflow to manage stale issues and PRs (#4871)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-01 19:26:03 +00:00
Sandy Tao
c795168e9c feat(core): Use completionStart/End for slash command auto-completion (#5374) 2025-08-01 18:51:38 +00:00
Billy Biggs
24c5a15d7a Add a setting to disable auth mode validation on startup (#5358) 2025-08-01 18:49:03 +00:00
andrea-berling
c725e258c6 feat(sandbox): Add SANDBOX_FLAGS for custom container options (#2036)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2025-08-01 16:32:44 +00:00
Brian de Alwis
d42e3f1e7f doc: use standard Google security policy for GitHub projects (#5062) 2025-08-01 16:12:32 +00:00
Silvio Junior
7748e56153 [Fix Telemetry for tool calls, PR 1/n] Propagate tool reported errors via ToolCallResponseInfo and ToolResult (#5222) 2025-08-01 15:20:08 +00:00
cornmander
e126d2fcd9 Add missing emacs entry in UI. (#5351) 2025-08-01 14:40:05 +00:00
neo
a5a3da01f6 doc: Add links to translated README versions
Added language selection links to the README for easier access to translated versions: German, Spanish, French, Japanese, Korean, Portuguese, Russian, and Chinese.
2025-08-01 15:18:26 +08:00
Brian Ray
dc9f17bb4a New browser launcher for MCP OAuth. (#5261) 2025-08-01 05:47:22 +00:00
Sandy Tao
f21ff09389 fix(core): Remove json output schema form the next speaker check prompt (#5325)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-08-01 01:17:52 +00:00
Raushan Raj
6c3fb18ef6 Update gemini-automated-issue-triage.yml (#5312)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-01 01:14:26 +00:00
Tommaso Sciortino
a3a432e3cf Fix bug executing commands in windows whose flags contain spaces (#5317) 2025-08-01 00:27:07 +00:00
Miguel Solorio
6f7beb414c Highlight slash commands in history (#5323) 2025-07-31 23:24:23 +00:00
Jacob Richman
61e382444a fix(ux) bug in replaceRange dealing with newLines that was breaking vim support (#5320) 2025-07-31 23:16:29 +00:00
Sandy Tao
32809a7be7 feat(cli): Improve @ autocompletion for mid-sentence edits (#5321) 2025-07-31 23:07:12 +00:00
Paige Bailey
37a3f1e6b6 Add emacs support, as per user requests. :) (#1633)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
Co-authored-by: matt korwel <matt.korwel@gmail.com>
Co-authored-by: matt korwel <mattkorwel@google.com>
2025-07-31 22:46:04 +00:00
JeromeJu
574015edd9 feat: Implement /setup-github command (#5069) 2025-07-31 22:14:22 +00:00
Yuki Okita
f9a05401c1 feat: Multi-Directory Workspace Support (part2: add "directory" command) (#5241) 2025-07-31 19:02:08 +00:00
Niladri Das
9a6422f331 fix: CLAUDE.md compatibility for GEMINI.md '@' file import behavior (#2978)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Allen Hutchison <adh@google.com>
2025-07-31 16:36:50 +00:00
joshualitt
ae86c7ba05 bug(core): UI reporting for truncated read_file. (#5155)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-31 16:31:14 +00:00
anj-s
65be9cab47 Fix: Ensure that non interactive mode and interactive mode are calling the same entry points (#5137) 2025-07-31 12:36:12 +00:00
Sandy Tao
23c014e29c Replace FlashDecidedToContinueEvent with NextSpeakerCheckEvent (#5257) 2025-07-31 04:47:04 +00:00
Kazunari001
3ef2c6d198 feat(docs): Add /init command in commands.md (#5187)
Co-authored-by: saucykazugmail <saucydog0922@gmail.com>
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-31 01:52:40 +00:00
Seth Troisi
c77a22d4c6 Add render counter in debug mode (#5242)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-31 00:43:11 +00:00
Gal Zahavi
d06e17fbd9 Improve error message for discoverTools function (#4157) 2025-07-31 00:16:21 +00:00
Shreya Keshive
0c6f788406 Exclude companion extension from release versioning (#5226)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-30 22:49:26 +00:00
christine betts
325bb89137 Add toggleable IDE mode setting (#5146) 2025-07-30 22:36:24 +00:00
Olcan
ac1bb5ee42 confirm save_memory tool, with ability to see diff and edit manually for advanced changes that may override past memories (#5237) 2025-07-30 22:21:31 +00:00
Allen Hutchison
498edb57ab fix(testing): make ModelStatsDisplay snapshot test deterministic (#5236)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-30 22:09:32 +00:00
christine betts
7bc8766542 Introduce IDE mode installer (#4877) 2025-07-30 21:26:31 +00:00
Yuki Okita
c1fe688956 feat: Multi-Directory Workspace Support (part1: add --include-directories option) (#4605)
Co-authored-by: Allen Hutchison <adh@google.com>
2025-07-30 20:38:20 +00:00
Srinath Padmanabhan
21965f986c Srithreepo Fixes for Scheduled triage (#5158) 2025-07-30 20:38:02 +00:00
shamso-goog
32b1ef3779 feat(ui): Update tool confirmation cancel button text (#4820)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-30 20:37:51 +00:00
Hyunsu Shin
bcce1e7b84 perf(core): parallelize bfsFileSearch for 40% faster CLI startup (#5185) 2025-07-30 17:32:03 +00:00
Olcan
bc23009f61 do not mention GEMINI.md in system prompt as it is not fixed and can confuse model as it is not mentioned by memory tool and memory file paths are generally not exposed to model (yet) (#5202) 2025-07-30 17:21:15 +00:00
yaksh gandhi
b447c329db docs: Update chat command documentation with checkpoint locations (#5027)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
Co-authored-by: F. Hinkelmann <franziska.hinkelmann@gmail.com>
2025-07-30 10:01:08 +00:00
N. Taylor Mullen
fd434626c5 chore(release): v0.1.15 (#5163) 2025-07-29 22:03:54 -07:00
Sandy Tao
8985e489a5 Skip and reset loop checking around code blocks (#5144) 2025-07-30 04:05:03 +00:00
Jenna Inouye
0ce89392b8 Docs: add documentation for .geminiignore (#5123)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-07-30 03:36:26 +00:00
Sambhav Khanna
d5a1b717c2 fix(update): correctly report new updates (#4821)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-30 00:11:15 +00:00
Allen Hutchison
091804c750 feat(docs): document GEMINI.md import syntax (#5145) 2025-07-29 23:41:31 +00:00
Ava
d64c3d6af8 Add Starcraft ref to witty loading phrases (#5120)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-29 23:22:13 +00:00
Tommaso Sciortino
327f915610 Fix typo in RFC 9728 impl (#5126) 2025-07-29 23:03:39 +00:00
Srinath Padmanabhan
008051e42d Update Triage Logic to improve issue categorization. (#5110) 2025-07-29 21:44:48 +00:00
Shreya Keshive
293bb82019 Adds centralized support to log slash commands + sub commands (#5128) 2025-07-29 20:20:37 +00:00
shamso-goog
80079cd2a5 feat(cli): introduce /init command for GEMINI.md creation (#4852)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2025-07-29 16:49:01 +00:00
Daniel Lee
7356764a48 feat(commands): add custom commands support for extensions (#4703) 2025-07-29 01:40:47 +00:00
Gal Zahavi
871e0dfab8 feat: Add auto update functionality (#4686) 2025-07-29 00:56:52 +00:00
Shreya Keshive
83c4dddb7e Only enable IDE integration if gemini-cli is running in the same path as open workspace (#5068) 2025-07-28 20:55:00 +00:00
Seth Troisi
1c1aa047ff feat: Add tests for checkpoint tag sanitization (#4882) 2025-07-28 20:43:39 +00:00
Abhi
b08679c906 Add new fallback state as prefactor for routing (#5065) 2025-07-28 19:55:50 +00:00
Danny
b6c2c64f9b Adds docs outlining keyboard shortcuts for gemini-cli (#4727)
Co-authored-by: dannyzen <dannyrosen@google.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-07-28 19:35:06 +00:00
Shreya Keshive
cfe3753d4c Refactors companion VS Code extension to import & use notification schema defined in gemini-cli (#5059) 2025-07-28 18:20:56 +00:00
N. Taylor Mullen
9aef0a8e6c Revert "feat: Add /config refresh command" (#5060) 2025-07-28 18:13:46 +00:00
Neha Prasad
a5ea113a8e fix: Clear previous thoughts when starting new prompts (#4966) 2025-07-28 17:57:33 +00:00
christine betts
379765da23 Add documentation for MCP prompts (#4897)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2025-07-28 16:01:15 +00:00
Alexander Parshakov
f7e559223d docs: Add more examples to Popular tasks (#4979)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2025-07-28 15:54:09 +00:00
Ramón Medrano Llamas
0170791800 feat: Add /config refresh command (#4993)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2025-07-28 15:46:43 +00:00
Shreya Keshive
e275441651 Updates schema, UX and prompt for IDE context (#5046) 2025-07-28 15:03:22 +00:00
James Woo
f2e006179d Fix author attribution (#5042) 2025-07-28 14:45:23 +00:00
N. Taylor Mullen
bd85070411 Revert "Propagate user_prompt_id to GenerateConentRequest for logging" (#5007) 2025-07-27 19:28:20 -07:00
Jenna Inouye
9ed351260c Update documentation for read_many_files. (#4874)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-07-27 22:25:04 +00:00
Jenna Inouye
ab0d9df658 Clarify ToS and privacy documentation FAQs. (#4899) 2025-07-27 22:24:53 +00:00
Hiroaki Mitsuyoshi
bce6eb5014 feat(chat): Implement /chat delete command (#2401) 2025-07-27 22:18:12 +00:00
Leeroy Ding
9ca48c00a6 fix: yolo mode not respected (#4972) 2025-07-27 21:42:26 +00:00
Abhi
0b5cc96362 (model) - Use Flash Lite For Next Speaker Checks (#4991) 2025-07-27 21:40:55 +00:00
owenofbrien
b497791c59 Propagate user_prompt_id to GenerateConentRequest for logging (#4741) 2025-07-27 21:34:39 +00:00
Abhi
36e1e57252 (docs) - Fix small markdown mistake for custom commands docs (#4983) 2025-07-27 21:33:58 +00:00
Hyeladi Bassi
a9f04eba2c refactor(telemetry): enhance flushToClearcut method with retry logic and early return for empty events (#1601)
Co-authored-by: Scott Densmore <scottdensmore@mac.com>
2025-07-27 18:18:27 +00:00
306 changed files with 27385 additions and 9199 deletions

View File

@@ -36,6 +36,14 @@ jobs:
- name: Run linter
run: npm run lint:ci
- name: Run linter on integration tests
run: npx eslint integration-tests --max-warnings 0
- name: Run formatter on integration tests
run: |
npx prettier --check integration-tests
git diff --exit-code
- name: Build project
run: npm run build

View File

@@ -2,7 +2,33 @@ name: Qwen Automated Issue Triage
on:
issues:
types: [opened, reopened]
types:
- 'opened'
- 'reopened'
issue_comment:
types:
- 'created'
workflow_dispatch:
inputs:
issue_number:
description: 'issue number to triage'
required: true
type: 'number'
concurrency:
group: '${{ github.workflow }}-${{ github.event.issue.number }}'
cancel-in-progress: true
defaults:
run:
shell: 'bash'
permissions:
contents: 'read'
id-token: 'write'
issues: 'write'
statuses: 'write'
packages: 'read'
jobs:
triage-issue:
@@ -24,34 +50,43 @@ jobs:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
with:
version: 0.0.5
version: 0.0.7
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
settings_json: |
{
"maxSessionTurns": 25,
"coreTools": [
"run_shell_command(echo)",
"run_shell_command(gh label list)",
"run_shell_command(gh issue edit)",
"run_shell_command(gh issue list)"
],
"sandbox": false
}
prompt: |
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:
prompt: |-
## Role
You are an issue triage assistant. Analyze the current GitHub issue and apply the most appropriate existing labels. Use the available
tools to gather information; do not ask for information to be provided. 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, body and any comments provided in the environment variables.
2. Review the issue title and body provided in the environment variables: "${ISSUE_TITLE}" and "${ISSUE_BODY}".
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:
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.
- Apply only one area/ label
- Apply only one kind/ label
- 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:
@@ -130,3 +165,163 @@ jobs:
- 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.
- name: 'Post Issue Triage Failure Comment'
if: |-
${{ failure() && steps.gemini_issue_triage.outcome == 'failure' }}
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
script: |-
github.rest.issues.createComment({
owner: '${{ github.repository }}'.split('/')[0],
repo: '${{ github.repository }}'.split('/')[1],
issue_number: '${{ github.event.issue.number }}',
body: 'There is a problem with the Gemini CLI issue triaging. Please check the [action logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.'
})
deduplicate-issues:
if: >
github.repository == 'google-gemini/gemini-cli' &&
vars.TRIAGE_DEDUPLICATE_ISSUES != '' &&
(github.event_name == 'issues' ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'issue_comment' &&
contains(github.event.comment.body, '@gemini-cli /deduplicate') &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR')))
timeout-minutes: 20
runs-on: 'ubuntu-latest'
steps:
- name: 'Checkout repository'
uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683'
- name: 'Generate GitHub App Token'
id: 'generate_token'
uses: 'actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e'
with:
app-id: '${{ secrets.APP_ID }}'
private-key: '${{ secrets.PRIVATE_KEY }}'
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: 'Run Gemini Issue Deduplication'
uses: 'google-github-actions/run-gemini-cli@20351b5ea2b4179431f1ae8918a246a0808f8747'
id: 'gemini_issue_deduplication'
env:
GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}'
ISSUE_TITLE: '${{ github.event.issue.title }}'
ISSUE_BODY: '${{ github.event.issue.body }}'
ISSUE_NUMBER: '${{ github.event.issue.number }}'
REPOSITORY: '${{ github.repository }}'
FIRESTORE_PROJECT: '${{ vars.FIRESTORE_PROJECT }}'
with:
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}'
gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}'
gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}'
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}'
use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}'
settings: |-
{
"mcpServers": {
"issue_deduplication": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"--network", "host",
"-e", "GITHUB_TOKEN",
"-e", "GEMINI_API_KEY",
"-e", "DATABASE_TYPE",
"-e", "FIRESTORE_DATABASE_ID",
"-e", "GCP_PROJECT",
"-e", "GOOGLE_APPLICATION_CREDENTIALS=/app/gcp-credentials.json",
"-v", "${GOOGLE_APPLICATION_CREDENTIALS}:/app/gcp-credentials.json",
"ghcr.io/google-gemini/gemini-cli-issue-triage@sha256:e3de1523f6c83aabb3c54b76d08940a2bf42febcb789dd2da6f95169641f94d3"
],
"env": {
"GITHUB_TOKEN": "${GITHUB_TOKEN}",
"GEMINI_API_KEY": "${{ secrets.GEMINI_API_KEY }}",
"DATABASE_TYPE":"firestore",
"GCP_PROJECT": "${FIRESTORE_PROJECT}",
"FIRESTORE_DATABASE_ID": "(default)",
"GOOGLE_APPLICATION_CREDENTIALS": "${GOOGLE_APPLICATION_CREDENTIALS}"
},
"enabled": true,
"timeout": 600000
}
},
"maxSessionTurns": 25,
"coreTools": [
"run_shell_command(echo)",
"run_shell_command(gh issue comment)",
"run_shell_command(gh issue view)",
"run_shell_command(gh issue edit)"
],
"telemetry": {
"enabled": true,
"target": "gcp"
}
}
prompt: |-
## Role
You are an issue de-duplication assistant. Your goal is to find
duplicate issues, label the current issue as a duplicate, and notify
the user by commenting on the current issue, while avoiding
duplicate comments.
## Steps
1. **Find Potential Duplicates:**
- The repository is ${{ github.repository }} and the issue number is ${{ github.event.issue.number }}.
- Use the `duplicates` tool with the `repo` and `issue_number` to find potential duplicates for the current issue. Do not use the `threshold` parameter.
- If no duplicates are found, you are done.
- Print the JSON output from the `duplicates` tool to the logs.
2. **Refine Duplicates List (if necessary):**
- If the `duplicates` tool returns between 1 and 14 results, you must refine the list.
- For each potential duplicate issue, run `gh issue view <issue-number> --json title,body,comments` to fetch its content.
- Also fetch the content of the original issue: `gh issue view "${ISSUE_NUMBER}" --json title,body,comments`.
- Carefully analyze the content (title, body, comments) of the original issue and all potential duplicates.
- It is very important if the comments on either issue mention that they are not duplicates of each other, to treat them as not duplicates.
- Based on your analysis, create a final list containing only the issues you are highly confident are actual duplicates.
- If your final list is empty, you are done.
- Print to the logs if you omitted any potential duplicates based on your analysis.
- If the `duplicates` tool returned 15+ results, use the top 15 matches (based on descending similarity score value) to perform this step.
3. **Format Final Duplicates List:**
Format the final list of duplicates into a markdown string.
The format should be:
"Found possible duplicate issues:\n\n- #${issue_number}\n\nIf you believe this is not a duplicate, please remove the `status/possible-duplicate` label."
Add an HTML comment to the end for identification: `<!-- gemini-cli-deduplication -->`
4. **Check for Existing Comment:**
- Run `gh issue view "${ISSUE_NUMBER}" --json comments` to get all
comments on the issue.
- Look for a comment made by a bot (the author's login often ends in `[bot]`) that contains `<!-- gemini-cli-deduplication -->`.
- If you find such a comment, store its `id` and `body`.
5. **Decide Action:**
- **If an existing comment is found:**
- Compare the new list of duplicate issues with the list from the existing comment's body.
- If they are the same, do nothing.
- If they are different, edit the existing comment. Use
`gh issue comment "${ISSUE_NUMBER}" --edit-comment <comment-id> --body "..."`.
The new body should be the new list of duplicates, but with the header "Found possible duplicate issues (updated):".
- **If no existing comment is found:**
- Create a new comment with the list of duplicates.
- Use `gh issue comment "${ISSUE_NUMBER}" --body "..."`.
6. **Add Duplicate Label:**
- If you created or updated a comment in the previous step, add the `duplicate` label to the current issue.
- Use `gh issue edit "${ISSUE_NUMBER}" --add-label "status/possible-duplicate"`.
## Guidelines
- Only use the `duplicates` and `run_shell_command` tools.
- The `run_shell_command` tool can be used with `gh issue view`, `gh issue comment`, and `gh issue edit`.
- Do not download or read media files like images, videos, or links. The `--json` flag for `gh issue view` will prevent this.
- Do not modify the issue content or status.
- Only comment on and label the current issue.
- Reference all shell variables as "${VAR}" (with quotes and braces).

View File

@@ -3,7 +3,22 @@ name: Qwen Scheduled Issue Triage
on:
schedule:
- cron: '0 * * * *' # Runs every hour
workflow_dispatch: {}
workflow_dispatch:
concurrency:
group: '${{ github.workflow }}'
cancel-in-progress: true
defaults:
run:
shell: 'bash'
permissions:
contents: 'read'
id-token: 'write'
issues: 'write'
statuses: 'write'
packages: 'read'
jobs:
triage-issues:
@@ -23,16 +38,19 @@ jobs:
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)
echo "🏷️ Finding issues that need triage..."
NEED_TRIAGE_ISSUES=$(gh issue list --repo ${{ github.repository }} --search "is:open is:issue label:\"status/need-triage\"" --json number,title,body)
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)"
echo "🔄 Merging and deduplicating issues..."
ISSUES=$(echo "$NO_LABEL_ISSUES" "$NEED_TRIAGE_ISSUES" | jq -c -s 'add | unique_by(.number)')
echo '🏷️ Finding issues that need triage...'
NEED_TRIAGE_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue label:"status/needs-triage"' --json number,title,body)"
echo "📝 Setting output for GitHub Actions..."
echo "issues_to_triage=$ISSUES" >> "$GITHUB_OUTPUT"
echo '🔄 Merging and deduplicating issues...'
ISSUES="$(echo "${NO_LABEL_ISSUES}" "${NEED_TRIAGE_ISSUES}" | jq -c -s 'add | unique_by(.number)')"
echo "✅ Found $(echo "$ISSUES" | jq 'length') issues to triage! 🎯"
echo '📝 Setting output for GitHub Actions...'
echo "issues_to_triage=${ISSUES}" >> "${GITHUB_OUTPUT}"
- name: Run Qwen Issue Triage
if: steps.find_issues.outputs.issues_to_triage != '[]'
@@ -42,24 +60,31 @@ jobs:
ISSUES_TO_TRIAGE: ${{ steps.find_issues.outputs.issues_to_triage }}
REPOSITORY: ${{ github.repository }}
with:
version: 0.0.5
version: 0.0.7
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
settings_json: |
{
"maxSessionTurns": 25,
"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 view)"
"run_shell_command(gh issue view)",
"run_shell_command(gh issue list)"
],
"sandbox": false
}
prompt: |
You are an issue triage assistant. Analyze the current GitHub issues apply the most appropriate existing labels.
Steps:
prompt: |-
## Role
You are an issue triage assistant. Analyze issues and apply
appropriate labels. Use the available tools to gather information;
do not ask for information to be provided.
## Steps
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. Review the issue title, body and any comments provided in the environment variables.
@@ -84,8 +109,10 @@ jobs:
- 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.
## 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.

View File

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

View File

@@ -84,6 +84,11 @@ jobs:
echo "RELEASE_TAG=$(echo $VERSION_JSON | jq -r .releaseTag)" >> $GITHUB_OUTPUT
echo "RELEASE_VERSION=$(echo $VERSION_JSON | jq -r .releaseVersion)" >> $GITHUB_OUTPUT
echo "NPM_TAG=$(echo $VERSION_JSON | jq -r .npmTag)" >> $GITHUB_OUTPUT
# Get the previous tag for release notes generation
CURRENT_TAG=$(echo $VERSION_JSON | jq -r .releaseTag)
PREVIOUS_TAG=$(node scripts/get-previous-tag.js "$CURRENT_TAG" || echo "")
echo "PREVIOUS_TAG=${PREVIOUS_TAG}" >> $GITHUB_OUTPUT
env:
IS_NIGHTLY: ${{ steps.vars.outputs.is_nightly }}
MANUAL_VERSION: ${{ inputs.version }}
@@ -158,11 +163,20 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_BRANCH: ${{ steps.release_branch.outputs.BRANCH_NAME }}
run: |
gh release create ${{ steps.version.outputs.RELEASE_TAG }} \
bundle/gemini.js \
--target "$RELEASE_BRANCH" \
--title "Release ${{ steps.version.outputs.RELEASE_TAG }}" \
--generate-notes
# Build the gh release create command with appropriate options
RELEASE_CMD="gh release create ${{ steps.version.outputs.RELEASE_TAG }} bundle/gemini.js --target \"$RELEASE_BRANCH\" --title \"Release ${{ steps.version.outputs.RELEASE_TAG }}\""
# Add previous tag for release notes if available
if [[ -n "${{ steps.version.outputs.PREVIOUS_TAG }}" ]]; then
echo "Generating release notes from previous tag: ${{ steps.version.outputs.PREVIOUS_TAG }}"
RELEASE_CMD="$RELEASE_CMD --generate-notes --notes-start-tag ${{ steps.version.outputs.PREVIOUS_TAG }}"
else
echo "No previous tag found, generating release notes from repository history"
RELEASE_CMD="$RELEASE_CMD --generate-notes"
fi
# Execute the release command
eval $RELEASE_CMD
- name: Create Issue on Failure
if: failure()

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["vitest.explorer", "esbenp.prettier-vscode"]
}

15
.vscode/settings.json vendored
View File

@@ -1,3 +1,16 @@
{
"typescript.tsserver.experimental.enableProjectDiagnostics": true
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
"editor.tabSize": 2,
"editor.rulers": [80],
"editor.detectIndentation": false,
"editor.insertSpaces": true,
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

26
CHANGELOG.md Normal file
View File

@@ -0,0 +1,26 @@
# Changelog
## 0.0.7
- Fix MCP tools
- Fix Web Fetch tool
- Fix Web Search tool, by replacing web search from Google/Gemini to Tavily API
- Fix: Compatible with occasional tool call parameters returned by LLM that are invalid JSON
- Fix: prevent concurrent query submissions on some rare cases
- Fix: incorrect qwen logger exit handler setup
- Fix: seperate static QR code and dynamic spin components
- Sync gemini-cli to v0.1.18
## 0.0.6
- Add usage statistics logging for Qwen integration
- Make `/init` command respect configured context filename and align docs with QWEN.md
- Fix EPERM error when run `qwen --sandbox` in macOS
- Fix terminal flicker when waiting for login
- Fix `glm-4.5` model request error
## 0.0.5
- Support Qwen OAuth login and provide up to 2000 free requests per day
- Sync gemini-cli to v0.1.17
- Add systemPromptMappings Configuration Feature

View File

@@ -15,6 +15,20 @@
</div>
<div align="center">
<!-- Keep these links. Translations will automatically update with the README. -->
<a href="https://readme-i18n.com/de/QwenLM/qwen-code">Deutsch</a> |
<a href="https://readme-i18n.com/es/QwenLM/qwen-code">Español</a> |
<a href="https://readme-i18n.com/fr/QwenLM/qwen-code">français</a> |
<a href="https://readme-i18n.com/ja/QwenLM/qwen-code">日本語</a> |
<a href="https://readme-i18n.com/ko/QwenLM/qwen-code">한국어</a> |
<a href="https://readme-i18n.com/pt/QwenLM/qwen-code">Português</a> |
<a href="https://readme-i18n.com/ru/QwenLM/qwen-code">Русский</a> |
<a href="https://readme-i18n.com/zh/QwenLM/qwen-code">中文</a>
</div>
Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli) ([details](./README.gemini.md)), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance.
## 💡 Free Options Available

View File

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

View File

@@ -1,8 +1,5 @@
# 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.
Please report any security issue or Higress crash report to [ASRC](https://security.alibaba.com/) (Alibaba Security Response Center) where the issue will be triaged appropriately.
[GitHub Security Advisory]: https://github.com/google-gemini/gemini-cli/security/advisories
Thank you for helping keep our project secure.

View File

@@ -27,6 +27,9 @@ Slash commands provide meta-level control over the CLI itself.
- **Usage:** `/chat resume <tag>`
- **`list`**
- **Description:** Lists available tags for chat state resumption.
- **`delete`**
- **Description:** Deletes a saved conversation checkpoint.
- **Usage:** `/chat delete <tag>`
- **`/clear`**
- **Description:** Clear the terminal screen, including the visible session history and scrollback within the CLI. The underlying session data (for history recall) might be preserved depending on the exact implementation, but the visual display is cleared.
@@ -49,6 +52,17 @@ Slash commands provide meta-level control over the CLI itself.
- **Description:** Display all directories added by `/directory add` and `--include-directories`.
- **Usage:** `/directory show`
- **`/directory`** (or **`/dir`**)
- **Description:** Manage workspace directories for multi-directory support.
- **Sub-commands:**
- **`add`**:
- **Description:** Add a directory to the workspace. The path can be absolute or relative to the current working directory. Moreover, the reference from home directory is supported as well.
- **Usage:** `/directory add <path1>,<path2>`
- **Note:** Disabled in restrictive sandbox profiles. If you're using that, use `--include-directories` when starting the session instead.
- **`show`**:
- **Description:** Display all directories added by `/directory add` and `--include-directories`.
- **Usage:** `/directory show`
- **`/editor`**
- **Description:** Open a dialog for selecting supported editors.
@@ -85,6 +99,11 @@ Slash commands provide meta-level control over the CLI itself.
- **Usage:** `/restore [tool_call_id]`
- **Note:** Only available if the CLI is invoked with the `--checkpointing` option or configured via [settings](./configuration.md). See [Checkpointing documentation](../checkpointing.md) for more details.
- **`/settings`**
- **Description:** Open the settings editor to view and modify Gemini CLI settings.
- **Details:** This command provides a user-friendly interface for changing settings that control the behavior and appearance of Gemini CLI. It is equivalent to manually editing the `.gemini/settings.json` file, but with validation and guidance to prevent errors.
- **Usage:** Simply run `/settings` and the editor will open. You can then browse or search for specific settings, view their current values, and modify them as desired. Changes to some settings are applied immediately, while others require a restart.
- **`/stats`**
- **Description:** Display detailed statistics for the current Qwen Code session, including token usage, cached token savings (when available), and session duration. Note: Cached token information is only displayed when cached tokens are being used, which occurs with API key authentication but not with OAuth authentication at this time.
@@ -253,7 +272,7 @@ Please generate a Conventional Commit message based on the following git diff:
```diff
!{git diff --staged}
````
```
"""
@@ -274,7 +293,7 @@ First, ensure the user commands directory exists, then create a `refactor` subdi
```bash
mkdir -p ~/.gemini/commands/refactor
touch ~/.gemini/commands/refactor/pure.toml
````
```
**2. Add the content to the file:**

View File

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

View File

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

View File

@@ -1,23 +1,23 @@
# Gemini CLI Extensions
# Qwen Code Extensions
Gemini CLI supports extensions that can be used to configure and extend its functionality.
Qwen Code supports extensions that can be used to configure and extend its functionality.
## How it works
On startup, Gemini CLI looks for extensions in two locations:
On startup, Qwen Code looks for extensions in two locations:
1. `<workspace>/.gemini/extensions`
2. `<home>/.gemini/extensions`
1. `<workspace>/.qwen/extensions`
2. `<home>/.qwen/extensions`
Gemini CLI loads all extensions from both locations. If an extension with the same name exists in both locations, the extension in the workspace directory takes precedence.
Qwen Code loads all extensions from both locations. If an extension with the same name exists in both locations, the extension in the workspace directory takes precedence.
Within each location, individual extensions exist as a directory that contains a `gemini-extension.json` file. For example:
Within each location, individual extensions exist as a directory that contains a `qwen-extension.json` file. For example:
`<workspace>/.gemini/extensions/my-extension/gemini-extension.json`
`<workspace>/.qwen/extensions/my-extension/qwen-extension.json`
### `gemini-extension.json`
### `qwen-extension.json`
The `gemini-extension.json` file contains the configuration for the extension. The file has the following structure:
The `qwen-extension.json` file contains the configuration for the extension. The file has the following structure:
```json
{
@@ -39,7 +39,7 @@ The `gemini-extension.json` file contains the configuration for the extension. T
- `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 `QWEN.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.
When Qwen Code starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence.
## Extension Commands
@@ -50,8 +50,8 @@ Extensions can provide [custom commands](./cli/commands.md#custom-commands) by p
An extension named `gcp` with the following structure:
```
.gemini/extensions/gcp/
├── gemini-extension.json
.qwen/extensions/gcp/
├── qwen-extension.json
└── commands/
├── deploy.toml
└── gcs/

View File

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

View File

@@ -109,10 +109,10 @@ To check for linting errors, run the following command:
npm run lint
```
You can include the `--fix` flag in the command to automatically fix any fixable linting errors:
You can include the `:fix` flag in the command to automatically fix any fixable linting errors:
```bash
npm run lint --fix
npm run lint:fix
```
## Directory structure

View File

@@ -58,7 +58,17 @@ You can export all telemetry data to a file for local inspection.
To enable file export, use the `--telemetry-outfile` flag with a path to your desired output file. This must be run using `--telemetry-target=local`.
```bash
gemini --telemetry --telemetry-target=local --telemetry-outfile=/path/to/telemetry.log "your prompt"
# Set your desired output file path
TELEMETRY_FILE=".gemini/telemetry.log"
# Run Gemini CLI with local telemetry
# NOTE: --telemetry-otlp-endpoint="" is required to override the default
# OTLP exporter and ensure telemetry is written to the local file.
gemini --telemetry \
--telemetry-target=local \
--telemetry-otlp-endpoint="" \
--telemetry-outfile="$TELEMETRY_FILE" \
--prompt "What is OpenTelemetry?"
```
## Running an OTEL Collector
@@ -173,9 +183,10 @@ Logs are timestamped records of specific events. The following events are logged
- `function_args`
- `duration_ms`
- `success` (boolean)
- `decision` (string: "accept", "reject", or "modify", if applicable)
- `decision` (string: "accept", "reject", "auto_accept", or "modify", if applicable)
- `error` (if applicable)
- `error_type` (if applicable)
- `metadata` (if applicable, dictionary of string -> any)
- `gemini_cli.api_request`: This event occurs when making a request to Gemini API.
- **Attributes**:
@@ -252,3 +263,7 @@ Metrics are numerical measurements of behavior over time. The following metrics
- `lines` (Int, if applicable): Number of lines in the file.
- `mimetype` (string, if applicable): Mimetype of the file.
- `extension` (string, if applicable): File extension of the file.
- `ai_added_lines` (Int, if applicable): Number of lines added/changed by AI.
- `ai_removed_lines` (Int, if applicable): Number of lines removed/changed by AI.
- `user_added_lines` (Int, if applicable): Number of lines added/changed by user in AI proposed changes.
- `user_removed_lines` (Int, if applicable): Number of lines removed/changed by user in AI proposed changes.

View File

@@ -169,6 +169,7 @@ Use the `/mcp auth` command to manage OAuth authentication:
- **`scopes`** (string[]): Required OAuth scopes
- **`redirectUri`** (string): Custom redirect URI (defaults to `http://localhost:7777/oauth/callback`)
- **`tokenParamName`** (string): Query parameter name for tokens in SSE URLs
- **`audiences`** (string[]): Audiences the token is valid for
#### Token Management
@@ -571,6 +572,56 @@ The MCP integration tracks several states:
This comprehensive integration makes MCP servers a powerful way to extend the Gemini CLI's capabilities while maintaining security, reliability, and ease of use.
## Returning Rich Content from Tools
MCP tools are not limited to returning simple text. You can return rich, multi-part content, including text, images, audio, and other binary data in a single tool response. This allows you to build powerful tools that can provide diverse information to the model in a single turn.
All data returned from the tool is processed and sent to the model as context for its next generation, enabling it to reason about or summarize the provided information.
### How It Works
To return rich content, your tool's response must adhere to the MCP specification for a [`CallToolResult`](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#tool-result). The `content` field of the result should be an array of `ContentBlock` objects. The Gemini CLI will correctly process this array, separating text from binary data and packaging it for the model.
You can mix and match different content block types in the `content` array. The supported block types include:
- `text`
- `image`
- `audio`
- `resource` (embedded content)
- `resource_link`
### Example: Returning Text and an Image
Here is an example of a valid JSON response from an MCP tool that returns both a text description and an image:
```json
{
"content": [
{
"type": "text",
"text": "Here is the logo you requested."
},
{
"type": "image",
"data": "BASE64_ENCODED_IMAGE_DATA_HERE",
"mimeType": "image/png"
},
{
"type": "text",
"text": "The logo was created in 2025."
}
]
}
```
When the Gemini CLI receives this response, it will:
1. Extract all the text and combine it into a single `functionResponse` part for the model.
2. Present the image data as a separate `inlineData` part.
3. Provide a clean, user-friendly summary in the CLI, indicating that both text and an image were received.
This enables you to build sophisticated tools that can provide rich, multi-modal context to the Gemini model.
## MCP Prompts as Slash Commands
In addition to tools, MCP servers can expose predefined prompts that can be executed as slash commands within the Gemini CLI. This allows you to create shortcuts for common or complex queries that can be easily invoked by name.
@@ -637,3 +688,114 @@ or, using positional arguments:
```
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.
## Managing MCP Servers with `gemini mcp`
While you can always configure MCP servers by manually editing your `settings.json` file, the Gemini CLI provides a convenient set of commands to manage your server configurations programmatically. These commands streamline the process of adding, listing, and removing MCP servers without needing to directly edit JSON files.
### Adding a Server (`gemini mcp add`)
The `add` command configures a new MCP server in your `settings.json`. Based on the scope (`-s, --scope`), it will be added to either the user config `~/.gemini/settings.json` or the project config `.gemini/settings.json` file.
**Command:**
```bash
gemini mcp add [options] <name> <commandOrUrl> [args...]
```
- `<name>`: A unique name for the server.
- `<commandOrUrl>`: The command to execute (for `stdio`) or the URL (for `http`/`sse`).
- `[args...]`: Optional arguments for a `stdio` command.
**Options (Flags):**
- `-s, --scope`: Configuration scope (user or project). [default: "project"]
- `-t, --transport`: Transport type (stdio, sse, http). [default: "stdio"]
- `-e, --env`: Set environment variables (e.g. -e KEY=value).
- `-H, --header`: Set HTTP headers for SSE and HTTP transports (e.g. -H "X-Api-Key: abc123" -H "Authorization: Bearer abc123").
- `--timeout`: Set connection timeout in milliseconds.
- `--trust`: Trust the server (bypass all tool call confirmation prompts).
- `--description`: Set the description for the server.
- `--include-tools`: A comma-separated list of tools to include.
- `--exclude-tools`: A comma-separated list of tools to exclude.
#### Adding an stdio server
This is the default transport for running local servers.
```bash
# Basic syntax
gemini mcp add <name> <command> [args...]
# Example: Adding a local server
gemini mcp add my-stdio-server -e API_KEY=123 /path/to/server arg1 arg2 arg3
# Example: Adding a local python server
gemini mcp add python-server python server.py --port 8080
```
#### Adding an HTTP server
This transport is for servers that use the streamable HTTP transport.
```bash
# Basic syntax
gemini mcp add --transport http <name> <url>
# Example: Adding an HTTP server
gemini mcp add --transport http http-server https://api.example.com/mcp/
# Example: Adding an HTTP server with an authentication header
gemini mcp add --transport http secure-http https://api.example.com/mcp/ --header "Authorization: Bearer abc123"
```
#### Adding an SSE server
This transport is for servers that use Server-Sent Events (SSE).
```bash
# Basic syntax
gemini mcp add --transport sse <name> <url>
# Example: Adding an SSE server
gemini mcp add --transport sse sse-server https://api.example.com/sse/
# Example: Adding an SSE server with an authentication header
gemini mcp add --transport sse secure-sse https://api.example.com/sse/ --header "Authorization: Bearer abc123"
```
### Listing Servers (`gemini mcp list`)
To view all MCP servers currently configured, use the `list` command. It displays each server's name, configuration details, and connection status.
**Command:**
```bash
gemini mcp list
```
**Example Output:**
```sh
✓ stdio-server: command: python3 server.py (stdio) - Connected
✓ http-server: https://api.example.com/mcp (http) - Connected
✗ sse-server: https://api.example.com/sse (sse) - Disconnected
```
### Removing a Server (`gemini mcp remove`)
To delete a server from your configuration, use the `remove` command with the server's name.
**Command:**
```bash
gemini mcp remove <name>
```
**Example:**
```bash
gemini mcp remove my-server
```
This will find and delete the "my-server" entry from the `mcpServers` object in the appropriate `settings.json` file based on the scope (`-s, --scope`).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,6 +35,7 @@ export default tseslint.config(
'packages/vscode-ide-companion/dist/**',
'bundle/**',
'package/bundle/**',
'.integration-tests/**',
],
},
eslint.configs.recommended,

View File

@@ -21,8 +21,8 @@ test('should be able to list a directory', async () => {
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');
const file1Path = join(rig.testDir!, 'file1.txt');
const subdirPath = join(rig.testDir!, 'subdir');
return existsSync(file1Path) && existsSync(subdirPath);
},
1000, // 1 second max wait

View File

@@ -0,0 +1,199 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* This test verifies we can match maximum schema depth errors from Gemini
* and then detect and warn about the potential tools that caused the error.
*/
import { test, describe, before } from 'node:test';
import { strict as assert } from 'node:assert';
import { TestRig } from './test-helper.js';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { writeFileSync } from 'fs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
// 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 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: 'cyclic-schema-server',
version: '1.0.0'
}
};
});
// Handle tools/list
rpc.on('tools/list', async () => {
debug('Handling tools/list request');
return {
tools: [{
name: 'tool_with_cyclic_schema',
inputSchema: {
type: 'object',
properties: {
data: {
type: 'array',
items: {
type: 'object',
properties: {
child: { $ref: '#/properties/data/items' },
},
},
},
},
}
}]
};
});
// Send initialization notification
rpc.send({
jsonrpc: '2.0',
method: 'initialized'
});
`;
describe('mcp server with cyclic tool schema is detected', () => {
const rig = new TestRig();
before(async () => {
// Setup test directory with MCP server configuration
await rig.setup('cyclic-schema-mcp-server', {
settings: {
mcpServers: {
'cyclic-schema-server': {
command: 'node',
args: ['mcp-server.cjs'],
},
},
},
});
// 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);
}
});
test('should error and suggest disabling the cyclic tool', async () => {
// Just run any command to trigger the schema depth error.
// If this test starts failing, check `isSchemaDepthError` from
// geminiChat.ts to see if it needs to be updated.
// Or, possibly it could mean that gemini has fixed the issue.
const output = await rig.run('hello');
assert.match(
output,
/Skipping tool 'tool_with_cyclic_schema' from MCP server 'cyclic-schema-server' because it has missing types in its parameter schema/,
);
});
});

View File

@@ -52,13 +52,13 @@ async function main() {
const testPatterns =
args.length > 0
? args.map((arg) => `integration-tests/${arg}.test.js`)
: ['integration-tests/*.test.js'];
? args.map((arg) => `integration-tests/${arg}.test.ts`)
: ['integration-tests/*.test.ts'];
const testFiles = glob.sync(testPatterns, { cwd: rootDir, absolute: true });
for (const testFile of testFiles) {
const testFileName = basename(testFile);
console.log(`\tFound test file: ${testFileName}`);
console.log(` Found test file: ${testFileName}`);
}
const MAX_RETRIES = 3;
@@ -92,7 +92,7 @@ async function main() {
}
nodeArgs.push(testFile);
const child = spawn('node', nodeArgs, {
const child = spawn('npx', ['tsx', ...nodeArgs], {
stdio: 'pipe',
env: {
...process.env,

View File

@@ -14,11 +14,8 @@ import { test, describe, before } from 'node:test';
import { strict as assert } from 'node:assert';
import { TestRig, validateModelOutput } from './test-helper.js';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { writeFileSync } from 'fs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
// 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
@@ -185,7 +182,7 @@ describe('simple-mcp-server', () => {
});
// Create server script in the test directory
const testServerPath = join(rig.testDir, 'mcp-server.cjs');
const testServerPath = join(rig.testDir!, 'mcp-server.cjs');
writeFileSync(testServerPath, serverScript);
// Make the script executable (though running with 'node' should work anyway)

View File

@@ -14,7 +14,7 @@ import { fileExists } from '../scripts/telemetry_utils.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
function sanitizeTestName(name) {
function sanitizeTestName(name: string) {
return name
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
@@ -22,7 +22,11 @@ function sanitizeTestName(name) {
}
// Helper to create detailed error messages
export function createToolCallErrorMessage(expectedTools, foundTools, result) {
export function createToolCallErrorMessage(
expectedTools: string | string[],
foundTools: string[],
result: string,
) {
const expectedStr = Array.isArray(expectedTools)
? expectedTools.join(' or ')
: expectedTools;
@@ -34,7 +38,11 @@ export function createToolCallErrorMessage(expectedTools, foundTools, result) {
}
// Helper to print debug information when tests fail
export function printDebugInfo(rig, result, context = {}) {
export function printDebugInfo(
rig: TestRig,
result: string,
context: Record<string, unknown> = {},
) {
console.error('Test failed - Debug info:');
console.error('Result length:', result.length);
console.error('Result (first 500 chars):', result.substring(0, 500));
@@ -60,8 +68,8 @@ export function printDebugInfo(rig, result, context = {}) {
// Helper to validate model output and warn about unexpected content
export function validateModelOutput(
result,
expectedContent = null,
result: string,
expectedContent: string | (string | RegExp)[] | null = null,
testName = '',
) {
// First, check if there's any output at all (this should fail the test if missing)
@@ -102,6 +110,11 @@ export function validateModelOutput(
}
export class TestRig {
bundlePath: string;
testDir: string | null;
testName?: string;
_lastRunStdout?: string;
constructor() {
this.bundlePath = join(__dirname, '..', 'bundle/gemini.js');
this.testDir = null;
@@ -114,10 +127,13 @@ export class TestRig {
return 15000; // 15s locally
}
setup(testName, options = {}) {
setup(
testName: string,
options: { settings?: Record<string, unknown> } = {},
) {
this.testName = testName;
const sanitizedName = sanitizeTestName(testName);
this.testDir = join(env.INTEGRATION_TEST_FILE_DIR, sanitizedName);
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
@@ -146,36 +162,43 @@ export class TestRig {
);
}
createFile(fileName, content) {
const filePath = join(this.testDir, fileName);
createFile(fileName: string, content: string) {
const filePath = join(this.testDir!, fileName);
writeFileSync(filePath, content);
return filePath;
}
mkdir(dir) {
mkdirSync(join(this.testDir, dir), { recursive: true });
mkdir(dir: string) {
mkdirSync(join(this.testDir!, dir), { recursive: true });
}
sync() {
// ensure file system is done before spawning
execSync('sync', { cwd: this.testDir });
execSync('sync', { cwd: this.testDir! });
}
run(promptOrOptions, ...args) {
run(
promptOrOptions: string | { prompt?: string; stdin?: string },
...args: string[]
): Promise<string> {
let command = `node ${this.bundlePath} --yolo`;
const execOptions = {
cwd: this.testDir,
const execOptions: {
cwd: string;
encoding: 'utf-8';
input?: string;
} = {
cwd: this.testDir!,
encoding: 'utf-8',
};
if (typeof promptOrOptions === 'string') {
command += ` --prompt "${promptOrOptions}"`;
command += ` --prompt ${JSON.stringify(promptOrOptions)}`;
} else if (
typeof promptOrOptions === 'object' &&
promptOrOptions !== null
) {
if (promptOrOptions.prompt) {
command += ` --prompt "${promptOrOptions.prompt}"`;
command += ` --prompt ${JSON.stringify(promptOrOptions.prompt)}`;
}
if (promptOrOptions.stdin) {
execOptions.input = promptOrOptions.stdin;
@@ -185,10 +208,10 @@ export class TestRig {
command += ` ${args.join(' ')}`;
const commandArgs = parse(command);
const node = commandArgs.shift();
const node = commandArgs.shift() as string;
const child = spawn(node, commandArgs, {
cwd: this.testDir,
const child = spawn(node, commandArgs as string[], {
cwd: this.testDir!,
stdio: 'pipe',
});
@@ -197,26 +220,26 @@ export class TestRig {
// Handle stdin if provided
if (execOptions.input) {
child.stdin.write(execOptions.input);
child.stdin.end();
child.stdin!.write(execOptions.input);
child.stdin!.end();
}
child.stdout.on('data', (data) => {
child.stdout!.on('data', (data: Buffer) => {
stdout += data;
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
process.stdout.write(data);
}
});
child.stderr.on('data', (data) => {
child.stderr!.on('data', (data: Buffer) => {
stderr += data;
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
process.stderr.write(data);
}
});
const promise = new Promise((resolve, reject) => {
child.on('close', (code) => {
const promise = new Promise<string>((resolve, reject) => {
child.on('close', (code: number) => {
if (code === 0) {
// Store the raw stdout for Podman telemetry parsing
this._lastRunStdout = stdout;
@@ -258,6 +281,11 @@ export class TestRig {
result = filteredLines.join('\n');
}
// If we have stderr output, include that also
if (stderr) {
result += `\n\nStdErr:\n${stderr}`;
}
resolve(result);
} else {
reject(new Error(`Process exited with code ${code}:\n${stderr}`));
@@ -268,13 +296,13 @@ export class TestRig {
return promise;
}
readFile(fileName) {
const content = readFileSync(join(this.testDir, fileName), 'utf-8');
readFile(fileName: string) {
const content = readFileSync(join(this.testDir!, fileName), 'utf-8');
if (env.KEEP_OUTPUT === 'true' || env.VERBOSE === 'true') {
const testId = `${env.TEST_FILE_NAME.replace(
const testId = `${env.TEST_FILE_NAME!.replace(
'.test.js',
'',
)}:${this.testName.replace(/ /g, '-')}`;
)}:${this.testName!.replace(/ /g, '-')}`;
console.log(`--- FILE: ${testId}/${fileName} ---`);
console.log(content);
console.log(`--- END FILE: ${testId}/${fileName} ---`);
@@ -290,7 +318,7 @@ export class TestRig {
} catch (error) {
// Ignore cleanup errors
if (env.VERBOSE === 'true') {
console.warn('Cleanup warning:', error.message);
console.warn('Cleanup warning:', (error as Error).message);
}
}
}
@@ -300,7 +328,7 @@ export class TestRig {
// 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')
? join(this.testDir!, 'telemetry.log')
: env.TELEMETRY_LOG_FILE;
if (!logFilePath) return;
@@ -313,7 +341,7 @@ export class TestRig {
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) {
} catch {
return false;
}
},
@@ -322,7 +350,7 @@ export class TestRig {
);
}
async waitForToolCall(toolName, timeout) {
async waitForToolCall(toolName: string, timeout?: number) {
// Use environment-specific timeout
if (!timeout) {
timeout = this.getDefaultTimeout();
@@ -341,7 +369,7 @@ export class TestRig {
);
}
async waitForAnyToolCall(toolNames, timeout) {
async waitForAnyToolCall(toolNames: string[], timeout?: number) {
// Use environment-specific timeout
if (!timeout) {
timeout = this.getDefaultTimeout();
@@ -362,7 +390,11 @@ export class TestRig {
);
}
async poll(predicate, timeout, interval) {
async poll(
predicate: () => boolean,
timeout: number,
interval: number,
): Promise<boolean> {
const startTime = Date.now();
let attempts = 0;
while (Date.now() - startTime < timeout) {
@@ -384,8 +416,16 @@ export class TestRig {
return false;
}
_parseToolLogsFromStdout(stdout) {
const logs = [];
_parseToolLogsFromStdout(stdout: string) {
const logs: {
timestamp: number;
toolRequest: {
name: string;
args: string;
success: boolean;
duration_ms: number;
};
}[] = [];
// The console output from Podman is JavaScript object notation, not JSON
// Look for tool call events in the output
@@ -488,7 +528,7 @@ export class TestRig {
},
});
}
} catch (_e) {
} catch {
// Not valid JSON
}
currentObject = '';
@@ -505,7 +545,7 @@ export class TestRig {
// 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');
const logFilePath = join(this.testDir!, 'telemetry.log');
if (fileExists(logFilePath)) {
try {
@@ -517,7 +557,7 @@ export class TestRig {
// File exists but is empty or doesn't have events, parse from stdout
return this._parseToolLogsFromStdout(this._lastRunStdout);
}
} catch (_e) {
} catch {
// Error reading file, fall back to stdout
if (this._lastRunStdout) {
return this._parseToolLogsFromStdout(this._lastRunStdout);
@@ -532,7 +572,7 @@ export class TestRig {
// 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')
? join(this.testDir!, 'telemetry.log')
: env.TELEMETRY_LOG_FILE;
if (!logFilePath) {
@@ -548,7 +588,7 @@ export class TestRig {
const content = readFileSync(logFilePath, 'utf-8');
// Split the content into individual JSON objects
// They are separated by "}\n{" pattern
// They are separated by "}\n{"
const jsonObjects = content
.split(/}\s*\n\s*{/)
.map((obj, index, array) => {
@@ -559,7 +599,14 @@ export class TestRig {
})
.filter((obj) => obj);
const logs = [];
const logs: {
toolRequest: {
name: string;
args: string;
success: boolean;
duration_ms: number;
};
}[] = [];
for (const jsonStr of jsonObjects) {
try {
@@ -579,10 +626,13 @@ export class TestRig {
},
});
}
} catch (_e) {
} catch (e) {
// Skip objects that aren't valid JSON
if (env.VERBOSE === 'true') {
console.error('Failed to parse telemetry object:', _e.message);
console.error(
'Failed to parse telemetry object:',
(e as Error).message,
);
}
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"allowJs": true
},
"include": ["**/*.ts"]
}

View File

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

2778
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.5",
"version": "0.0.8-nightly.5",
"engines": {
"node": ">=20.0.0"
},
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.5"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.8-nightly.5"
},
"scripts": {
"start": "node scripts/start.js",
@@ -28,7 +28,7 @@
"build:packages": "npm run build --workspaces",
"build:sandbox": "node scripts/build_sandbox.js --skip-npm-install-build",
"bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js",
"test": "npm run test --workspaces",
"test": "npm run test --workspaces --if-present",
"test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts",
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",
"test:e2e": "npm run test:integration:sandbox:none -- --verbose --keep-output",
@@ -39,7 +39,7 @@
"lint": "eslint . --ext .ts,.tsx && eslint integration-tests",
"lint:fix": "eslint . --fix && eslint integration-tests --fix",
"lint:ci": "eslint . --ext .ts,.tsx --max-warnings 0 && eslint integration-tests --max-warnings 0",
"format": "prettier --write .",
"format": "prettier --experimental-cli --write .",
"typecheck": "npm run typecheck --workspaces --if-present",
"preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci",
"prepare": "npm run bundle",
@@ -83,8 +83,10 @@
"mock-fs": "^5.5.0",
"prettier": "^3.5.3",
"react-devtools-core": "^4.28.5",
"tsx": "^4.20.3",
"typescript-eslint": "^8.30.1",
"vitest": "^3.2.4",
"yargs": "^17.7.2"
"yargs": "^17.7.2",
"mnemonist": "^0.40.3"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.0.5",
"version": "0.0.8-nightly.5",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -25,12 +25,13 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.5"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.0.8-nightly.5"
},
"dependencies": {
"@google/genai": "1.9.0",
"@iarna/toml": "^2.2.5",
"@qwen-code/qwen-code-core": "file:../core",
"@modelcontextprotocol/sdk": "^1.15.1",
"@types/update-notifier": "^6.0.8",
"command-exists": "^1.2.9",
"diff": "^7.0.0",
@@ -53,7 +54,7 @@
"string-width": "^7.1.0",
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
"tiktoken": "^1.0.21",
"undici": "^7.10.0",
"update-notifier": "^7.3.1",
"yargs": "^17.7.2",
"zod": "^3.23.8"
@@ -75,7 +76,8 @@
"pretty-format": "^30.0.2",
"react-dom": "^19.1.0",
"typescript": "^5.3.3",
"vitest": "^3.1.1"
"vitest": "^3.1.1",
"@qwen-code/qwen-code-test-utils": "file:../test-utils"
},
"engines": {
"node": ">=20"

View File

@@ -239,65 +239,62 @@ class GeminiAgent implements Agent {
);
}
let toolCallId;
const confirmationDetails = await tool.shouldConfirmExecute(
args,
abortSignal,
);
if (confirmationDetails) {
let content: acp.ToolCallContent | null = null;
if (confirmationDetails.type === 'edit') {
content = {
type: 'diff',
path: confirmationDetails.fileName,
oldText: confirmationDetails.originalContent,
newText: confirmationDetails.newContent,
};
}
const result = await this.client.requestToolCallConfirmation({
label: tool.getDescription(args),
icon: tool.icon,
content,
confirmation: toAcpToolCallConfirmation(confirmationDetails),
locations: tool.toolLocations(args),
});
await confirmationDetails.onConfirm(toToolCallOutcome(result.outcome));
switch (result.outcome) {
case 'reject':
return errorResponse(
new Error(`Tool "${fc.name}" not allowed to run by the user.`),
);
case 'cancel':
return errorResponse(
new Error(`Tool "${fc.name}" was canceled by the user.`),
);
case 'allow':
case 'alwaysAllow':
case 'alwaysAllowMcpServer':
case 'alwaysAllowTool':
break;
default: {
const resultOutcome: never = result.outcome;
throw new Error(`Unexpected: ${resultOutcome}`);
}
}
toolCallId = result.id;
} else {
const result = await this.client.pushToolCall({
icon: tool.icon,
label: tool.getDescription(args),
locations: tool.toolLocations(args),
});
toolCallId = result.id;
}
let toolCallId: number | undefined = undefined;
try {
const toolResult: ToolResult = await tool.execute(args, abortSignal);
const invocation = tool.build(args);
const confirmationDetails =
await invocation.shouldConfirmExecute(abortSignal);
if (confirmationDetails) {
let content: acp.ToolCallContent | null = null;
if (confirmationDetails.type === 'edit') {
content = {
type: 'diff',
path: confirmationDetails.fileName,
oldText: confirmationDetails.originalContent,
newText: confirmationDetails.newContent,
};
}
const result = await this.client.requestToolCallConfirmation({
label: invocation.getDescription(),
icon: tool.icon,
content,
confirmation: toAcpToolCallConfirmation(confirmationDetails),
locations: invocation.toolLocations(),
});
await confirmationDetails.onConfirm(toToolCallOutcome(result.outcome));
switch (result.outcome) {
case 'reject':
return errorResponse(
new Error(`Tool "${fc.name}" not allowed to run by the user.`),
);
case 'cancel':
return errorResponse(
new Error(`Tool "${fc.name}" was canceled by the user.`),
);
case 'allow':
case 'alwaysAllow':
case 'alwaysAllowMcpServer':
case 'alwaysAllowTool':
break;
default: {
const resultOutcome: never = result.outcome;
throw new Error(`Unexpected: ${resultOutcome}`);
}
}
toolCallId = result.id;
} else {
const result = await this.client.pushToolCall({
icon: tool.icon,
label: invocation.getDescription(),
locations: invocation.toolLocations(),
});
toolCallId = result.id;
}
const toolResult: ToolResult = await invocation.execute(abortSignal);
const toolCallContent = toToolCallContent(toolResult);
await this.client.updateToolCall({
@@ -320,12 +317,13 @@ class GeminiAgent implements Agent {
return convertToFunctionResponse(fc.name, callId, toolResult.llmContent);
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
await this.client.updateToolCall({
toolCallId,
status: 'error',
content: { type: 'markdown', markdown: error.message },
});
if (toolCallId) {
await this.client.updateToolCall({
toolCallId,
status: 'error',
content: { type: 'markdown', markdown: error.message },
});
}
return errorResponse(error);
}
}
@@ -408,7 +406,7 @@ class GeminiAgent implements Agent {
`Path ${pathName} not found directly, attempting glob search.`,
);
try {
const globResult = await globTool.execute(
const globResult = await globTool.buildAndExecute(
{
pattern: `**/*${pathName}*`,
path: this.config.getTargetDir(),
@@ -530,12 +528,15 @@ class GeminiAgent implements Agent {
respectGitIgnore, // Use configuration setting
};
const toolCall = await this.client.pushToolCall({
icon: readManyFilesTool.icon,
label: readManyFilesTool.getDescription(toolArgs),
});
let toolCallId: number | undefined = undefined;
try {
const result = await readManyFilesTool.execute(toolArgs, abortSignal);
const invocation = readManyFilesTool.build(toolArgs);
const toolCall = await this.client.pushToolCall({
icon: readManyFilesTool.icon,
label: invocation.getDescription(),
});
toolCallId = toolCall.id;
const result = await invocation.execute(abortSignal);
const content = toToolCallContent(result) || {
type: 'markdown',
markdown: `Successfully read: ${contentLabelsForDisplay.join(', ')}`,
@@ -578,14 +579,16 @@ class GeminiAgent implements Agent {
return processedQueryParts;
} catch (error: unknown) {
await this.client.updateToolCall({
toolCallId: toolCall.id,
status: 'error',
content: {
type: 'markdown',
markdown: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
},
});
if (toolCallId) {
await this.client.updateToolCall({
toolCallId,
status: 'error',
content: {
type: 'markdown',
markdown: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
},
});
}
throw error;
}
}

View File

@@ -0,0 +1,55 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { mcpCommand } from './mcp.js';
import { type Argv } from 'yargs';
import yargs from 'yargs';
describe('mcp command', () => {
it('should have correct command definition', () => {
expect(mcpCommand.command).toBe('mcp');
expect(mcpCommand.describe).toBe('Manage MCP servers');
expect(typeof mcpCommand.builder).toBe('function');
expect(typeof mcpCommand.handler).toBe('function');
});
it('should have exactly one option (help flag)', () => {
// Test to ensure that the global 'gemini' flags are not added to the mcp command
const yargsInstance = yargs();
const builtYargs = mcpCommand.builder(yargsInstance);
const options = builtYargs.getOptions();
// Should have exactly 1 option (help flag)
expect(Object.keys(options.key).length).toBe(1);
expect(options.key).toHaveProperty('help');
});
it('should register add, remove, and list subcommands', () => {
const mockYargs = {
command: vi.fn().mockReturnThis(),
demandCommand: vi.fn().mockReturnThis(),
version: vi.fn().mockReturnThis(),
};
mcpCommand.builder(mockYargs as unknown as Argv);
expect(mockYargs.command).toHaveBeenCalledTimes(3);
// Verify that the specific subcommands are registered
const commandCalls = mockYargs.command.mock.calls;
const commandNames = commandCalls.map((call) => call[0].command);
expect(commandNames).toContain('add <name> <commandOrUrl> [args...]');
expect(commandNames).toContain('remove <name>');
expect(commandNames).toContain('list');
expect(mockYargs.demandCommand).toHaveBeenCalledWith(
1,
'You need at least one command before continuing.',
);
});
});

View File

@@ -0,0 +1,27 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// File for 'gemini mcp' command
import type { CommandModule, Argv } from 'yargs';
import { addCommand } from './mcp/add.js';
import { removeCommand } from './mcp/remove.js';
import { listCommand } from './mcp/list.js';
export const mcpCommand: CommandModule = {
command: 'mcp',
describe: 'Manage MCP servers',
builder: (yargs: Argv) =>
yargs
.command(addCommand)
.command(removeCommand)
.command(listCommand)
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {
// yargs will automatically show help if no subcommand is provided
// thanks to demandCommand(1) in the builder.
},
};

View File

@@ -0,0 +1,88 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import yargs from 'yargs';
import { addCommand } from './add.js';
import { loadSettings, SettingScope } from '../../config/settings.js';
vi.mock('fs/promises', () => ({
readFile: vi.fn(),
writeFile: vi.fn(),
}));
vi.mock('../../config/settings.js', async () => {
const actual = await vi.importActual('../../config/settings.js');
return {
...actual,
loadSettings: vi.fn(),
};
});
const mockedLoadSettings = loadSettings as vi.Mock;
describe('mcp add command', () => {
let parser: yargs.Argv;
let mockSetValue: vi.Mock;
beforeEach(() => {
vi.resetAllMocks();
const yargsInstance = yargs([]).command(addCommand);
parser = yargsInstance;
mockSetValue = vi.fn();
mockedLoadSettings.mockReturnValue({
forScope: () => ({ settings: {} }),
setValue: mockSetValue,
});
});
it('should add a stdio server to project settings', async () => {
await parser.parseAsync(
'add my-server /path/to/server arg1 arg2 -e FOO=bar',
);
expect(mockSetValue).toHaveBeenCalledWith(
SettingScope.Workspace,
'mcpServers',
{
'my-server': {
command: '/path/to/server',
args: ['arg1', 'arg2'],
env: { FOO: 'bar' },
},
},
);
});
it('should add an sse server to user settings', async () => {
await parser.parseAsync(
'add --transport sse sse-server https://example.com/sse-endpoint --scope user -H "X-API-Key: your-key"',
);
expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', {
'sse-server': {
url: 'https://example.com/sse-endpoint',
headers: { 'X-API-Key': 'your-key' },
},
});
});
it('should add an http server to project settings', async () => {
await parser.parseAsync(
'add --transport http http-server https://example.com/mcp -H "Authorization: Bearer your-token"',
);
expect(mockSetValue).toHaveBeenCalledWith(
SettingScope.Workspace,
'mcpServers',
{
'http-server': {
httpUrl: 'https://example.com/mcp',
headers: { Authorization: 'Bearer your-token' },
},
},
);
});
});

View File

@@ -0,0 +1,211 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// File for 'gemini mcp add' command
import type { CommandModule } from 'yargs';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { MCPServerConfig } from '@qwen-code/qwen-code-core';
async function addMcpServer(
name: string,
commandOrUrl: string,
args: Array<string | number> | undefined,
options: {
scope: string;
transport: string;
env: string[] | undefined;
header: string[] | undefined;
timeout?: number;
trust?: boolean;
description?: string;
includeTools?: string[];
excludeTools?: string[];
},
) {
const {
scope,
transport,
env,
header,
timeout,
trust,
description,
includeTools,
excludeTools,
} = options;
const settingsScope =
scope === 'user' ? SettingScope.User : SettingScope.Workspace;
const settings = loadSettings(process.cwd());
let newServer: Partial<MCPServerConfig> = {};
const headers = header?.reduce(
(acc, curr) => {
const [key, ...valueParts] = curr.split(':');
const value = valueParts.join(':').trim();
if (key.trim() && value) {
acc[key.trim()] = value;
}
return acc;
},
{} as Record<string, string>,
);
switch (transport) {
case 'sse':
newServer = {
url: commandOrUrl,
headers,
timeout,
trust,
description,
includeTools,
excludeTools,
};
break;
case 'http':
newServer = {
httpUrl: commandOrUrl,
headers,
timeout,
trust,
description,
includeTools,
excludeTools,
};
break;
case 'stdio':
default:
newServer = {
command: commandOrUrl,
args: args?.map(String),
env: env?.reduce(
(acc, curr) => {
const [key, value] = curr.split('=');
if (key && value) {
acc[key] = value;
}
return acc;
},
{} as Record<string, string>,
),
timeout,
trust,
description,
includeTools,
excludeTools,
};
break;
}
const existingSettings = settings.forScope(settingsScope).settings;
const mcpServers = existingSettings.mcpServers || {};
const isExistingServer = !!mcpServers[name];
if (isExistingServer) {
console.log(
`MCP server "${name}" is already configured within ${scope} settings.`,
);
}
mcpServers[name] = newServer as MCPServerConfig;
settings.setValue(settingsScope, 'mcpServers', mcpServers);
if (isExistingServer) {
console.log(`MCP server "${name}" updated in ${scope} settings.`);
} else {
console.log(
`MCP server "${name}" added to ${scope} settings. (${transport})`,
);
}
}
export const addCommand: CommandModule = {
command: 'add <name> <commandOrUrl> [args...]',
describe: 'Add a server',
builder: (yargs) =>
yargs
.usage('Usage: gemini mcp add [options] <name> <commandOrUrl> [args...]')
.positional('name', {
describe: 'Name of the server',
type: 'string',
demandOption: true,
})
.positional('commandOrUrl', {
describe: 'Command (stdio) or URL (sse, http)',
type: 'string',
demandOption: true,
})
.option('scope', {
alias: 's',
describe: 'Configuration scope (user or project)',
type: 'string',
default: 'project',
choices: ['user', 'project'],
})
.option('transport', {
alias: 't',
describe: 'Transport type (stdio, sse, http)',
type: 'string',
default: 'stdio',
choices: ['stdio', 'sse', 'http'],
})
.option('env', {
alias: 'e',
describe: 'Set environment variables (e.g. -e KEY=value)',
type: 'array',
string: true,
})
.option('header', {
alias: 'H',
describe:
'Set HTTP headers for SSE and HTTP transports (e.g. -H "X-Api-Key: abc123" -H "Authorization: Bearer abc123")',
type: 'array',
string: true,
})
.option('timeout', {
describe: 'Set connection timeout in milliseconds',
type: 'number',
})
.option('trust', {
describe:
'Trust the server (bypass all tool call confirmation prompts)',
type: 'boolean',
})
.option('description', {
describe: 'Set the description for the server',
type: 'string',
})
.option('include-tools', {
describe: 'A comma-separated list of tools to include',
type: 'array',
string: true,
})
.option('exclude-tools', {
describe: 'A comma-separated list of tools to exclude',
type: 'array',
string: true,
}),
handler: async (argv) => {
await addMcpServer(
argv.name as string,
argv.commandOrUrl as string,
argv.args as Array<string | number>,
{
scope: argv.scope as string,
transport: argv.transport as string,
env: argv.env as string[],
header: argv.header as string[],
timeout: argv.timeout as number | undefined,
trust: argv.trust as boolean | undefined,
description: argv.description as string | undefined,
includeTools: argv.includeTools as string[] | undefined,
excludeTools: argv.excludeTools as string[] | undefined,
},
);
},
};

View File

@@ -0,0 +1,154 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { listMcpServers } from './list.js';
import { loadSettings } from '../../config/settings.js';
import { loadExtensions } from '../../config/extension.js';
import { createTransport } from '@qwen-code/qwen-code-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
vi.mock('../../config/settings.js');
vi.mock('../../config/extension.js');
vi.mock('@qwen-code/qwen-code-core');
vi.mock('@modelcontextprotocol/sdk/client/index.js');
const mockedLoadSettings = loadSettings as vi.Mock;
const mockedLoadExtensions = loadExtensions as vi.Mock;
const mockedCreateTransport = createTransport as vi.Mock;
const MockedClient = Client as vi.Mock;
interface MockClient {
connect: vi.Mock;
ping: vi.Mock;
close: vi.Mock;
}
interface MockTransport {
close: vi.Mock;
}
describe('mcp list command', () => {
let consoleSpy: vi.SpyInstance;
let mockClient: MockClient;
let mockTransport: MockTransport;
beforeEach(() => {
vi.resetAllMocks();
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockTransport = { close: vi.fn() };
mockClient = {
connect: vi.fn(),
ping: vi.fn(),
close: vi.fn(),
};
MockedClient.mockImplementation(() => mockClient);
mockedCreateTransport.mockResolvedValue(mockTransport);
mockedLoadExtensions.mockReturnValue([]);
});
afterEach(() => {
consoleSpy.mockRestore();
});
it('should display message when no servers configured', async () => {
mockedLoadSettings.mockReturnValue({ merged: { mcpServers: {} } });
await listMcpServers();
expect(consoleSpy).toHaveBeenCalledWith('No MCP servers configured.');
});
it('should display different server types with connected status', async () => {
mockedLoadSettings.mockReturnValue({
merged: {
mcpServers: {
'stdio-server': { command: '/path/to/server', args: ['arg1'] },
'sse-server': { url: 'https://example.com/sse' },
'http-server': { httpUrl: 'https://example.com/http' },
},
},
});
mockClient.connect.mockResolvedValue(undefined);
mockClient.ping.mockResolvedValue(undefined);
await listMcpServers();
expect(consoleSpy).toHaveBeenCalledWith('Configured MCP servers:\n');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'stdio-server: /path/to/server arg1 (stdio) - Connected',
),
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'sse-server: https://example.com/sse (sse) - Connected',
),
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'http-server: https://example.com/http (http) - Connected',
),
);
});
it('should display disconnected status when connection fails', async () => {
mockedLoadSettings.mockReturnValue({
merged: {
mcpServers: {
'test-server': { command: '/test/server' },
},
},
});
mockClient.connect.mockRejectedValue(new Error('Connection failed'));
await listMcpServers();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'test-server: /test/server (stdio) - Disconnected',
),
);
});
it('should merge extension servers with config servers', async () => {
mockedLoadSettings.mockReturnValue({
merged: {
mcpServers: { 'config-server': { command: '/config/server' } },
},
});
mockedLoadExtensions.mockReturnValue([
{
config: {
name: 'test-extension',
mcpServers: { 'extension-server': { command: '/ext/server' } },
},
},
]);
mockClient.connect.mockResolvedValue(undefined);
mockClient.ping.mockResolvedValue(undefined);
await listMcpServers();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'config-server: /config/server (stdio) - Connected',
),
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'extension-server: /ext/server (stdio) - Connected',
),
);
});
});

View File

@@ -0,0 +1,139 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// File for 'gemini mcp list' command
import type { CommandModule } from 'yargs';
import { loadSettings } from '../../config/settings.js';
import {
MCPServerConfig,
MCPServerStatus,
createTransport,
} from '@qwen-code/qwen-code-core';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { loadExtensions } from '../../config/extension.js';
const COLOR_GREEN = '\u001b[32m';
const COLOR_YELLOW = '\u001b[33m';
const COLOR_RED = '\u001b[31m';
const RESET_COLOR = '\u001b[0m';
async function getMcpServersFromConfig(): Promise<
Record<string, MCPServerConfig>
> {
const settings = loadSettings(process.cwd());
const extensions = loadExtensions(process.cwd());
const mcpServers = { ...(settings.merged.mcpServers || {}) };
for (const extension of extensions) {
Object.entries(extension.config.mcpServers || {}).forEach(
([key, server]) => {
if (mcpServers[key]) {
return;
}
mcpServers[key] = {
...server,
extensionName: extension.config.name,
};
},
);
}
return mcpServers;
}
async function testMCPConnection(
serverName: string,
config: MCPServerConfig,
): Promise<MCPServerStatus> {
const client = new Client({
name: 'mcp-test-client',
version: '0.0.1',
});
let transport;
try {
// Use the same transport creation logic as core
transport = await createTransport(serverName, config, false);
} catch (_error) {
await client.close();
return MCPServerStatus.DISCONNECTED;
}
try {
// Attempt actual MCP connection with short timeout
await client.connect(transport, { timeout: 5000 }); // 5s timeout
// Test basic MCP protocol by pinging the server
await client.ping();
await client.close();
return MCPServerStatus.CONNECTED;
} catch (_error) {
await transport.close();
return MCPServerStatus.DISCONNECTED;
}
}
async function getServerStatus(
serverName: string,
server: MCPServerConfig,
): Promise<MCPServerStatus> {
// Test all server types by attempting actual connection
return await testMCPConnection(serverName, server);
}
export async function listMcpServers(): Promise<void> {
const mcpServers = await getMcpServersFromConfig();
const serverNames = Object.keys(mcpServers);
if (serverNames.length === 0) {
console.log('No MCP servers configured.');
return;
}
console.log('Configured MCP servers:\n');
for (const serverName of serverNames) {
const server = mcpServers[serverName];
const status = await getServerStatus(serverName, server);
let statusIndicator = '';
let statusText = '';
switch (status) {
case MCPServerStatus.CONNECTED:
statusIndicator = COLOR_GREEN + '✓' + RESET_COLOR;
statusText = 'Connected';
break;
case MCPServerStatus.CONNECTING:
statusIndicator = COLOR_YELLOW + '…' + RESET_COLOR;
statusText = 'Connecting';
break;
case MCPServerStatus.DISCONNECTED:
default:
statusIndicator = COLOR_RED + '✗' + RESET_COLOR;
statusText = 'Disconnected';
break;
}
let serverInfo = `${serverName}: `;
if (server.httpUrl) {
serverInfo += `${server.httpUrl} (http)`;
} else if (server.url) {
serverInfo += `${server.url} (sse)`;
} else if (server.command) {
serverInfo += `${server.command} ${server.args?.join(' ') || ''} (stdio)`;
}
console.log(`${statusIndicator} ${serverInfo} - ${statusText}`);
}
}
export const listCommand: CommandModule = {
command: 'list',
describe: 'List all configured MCP servers',
handler: async () => {
await listMcpServers();
},
};

View File

@@ -0,0 +1,69 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import yargs from 'yargs';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { removeCommand } from './remove.js';
vi.mock('fs/promises', () => ({
readFile: vi.fn(),
writeFile: vi.fn(),
}));
vi.mock('../../config/settings.js', async () => {
const actual = await vi.importActual('../../config/settings.js');
return {
...actual,
loadSettings: vi.fn(),
};
});
const mockedLoadSettings = loadSettings as vi.Mock;
describe('mcp remove command', () => {
let parser: yargs.Argv;
let mockSetValue: vi.Mock;
let mockSettings: Record<string, unknown>;
beforeEach(() => {
vi.resetAllMocks();
const yargsInstance = yargs([]).command(removeCommand);
parser = yargsInstance;
mockSetValue = vi.fn();
mockSettings = {
mcpServers: {
'test-server': {
command: 'echo "hello"',
},
},
};
mockedLoadSettings.mockReturnValue({
forScope: () => ({ settings: mockSettings }),
setValue: mockSetValue,
});
});
it('should remove a server from project settings', async () => {
await parser.parseAsync('remove test-server');
expect(mockSetValue).toHaveBeenCalledWith(
SettingScope.Workspace,
'mcpServers',
{},
);
});
it('should show a message if server not found', async () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await parser.parseAsync('remove non-existent-server');
expect(mockSetValue).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(
'Server "non-existent-server" not found in project settings.',
);
});
});

View File

@@ -0,0 +1,60 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// File for 'gemini mcp remove' command
import type { CommandModule } from 'yargs';
import { loadSettings, SettingScope } from '../../config/settings.js';
async function removeMcpServer(
name: string,
options: {
scope: string;
},
) {
const { scope } = options;
const settingsScope =
scope === 'user' ? SettingScope.User : SettingScope.Workspace;
const settings = loadSettings(process.cwd());
const existingSettings = settings.forScope(settingsScope).settings;
const mcpServers = existingSettings.mcpServers || {};
if (!mcpServers[name]) {
console.log(`Server "${name}" not found in ${scope} settings.`);
return;
}
delete mcpServers[name];
settings.setValue(settingsScope, 'mcpServers', mcpServers);
console.log(`Server "${name}" removed from ${scope} settings.`);
}
export const removeCommand: CommandModule = {
command: 'remove <name>',
describe: 'Remove a server',
builder: (yargs) =>
yargs
.usage('Usage: gemini mcp remove [options] <name>')
.positional('name', {
describe: 'Name of the server',
type: 'string',
demandOption: true,
})
.option('scope', {
alias: 's',
describe: 'Configuration scope (user or project)',
type: 'string',
default: 'project',
choices: ['user', 'project'],
}),
handler: async (argv) => {
await removeMcpServer(argv.name as string, {
scope: argv.scope as string,
});
},
};

View File

@@ -6,7 +6,10 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as os from 'os';
import { loadCliConfig, parseArguments, CliArgs } from './config.js';
import * as fs from 'fs';
import * as path from 'path';
import { ShellTool, EditTool, WriteFileTool } from '@qwen-code/qwen-code-core';
import { loadCliConfig, parseArguments } from './config.js';
import { Settings } from './settings.js';
import { Extension } from './extension.js';
import * as ServerConfig from '@qwen-code/qwen-code-core';
@@ -44,7 +47,7 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
},
loadEnvironment: vi.fn(),
loadServerHierarchicalMemory: vi.fn(
(cwd, debug, fileService, extensionPaths, _maxDirs) =>
(cwd, dirs, debug, fileService, extensionPaths, _maxDirs) =>
Promise.resolve({
memoryContent: extensionPaths?.join(',') || '',
fileCount: extensionPaths?.length || 0,
@@ -499,6 +502,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
await loadCliConfig(settings, extensions, 'session-id', argv);
expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith(
expect.any(String),
[],
false,
expect.any(Object),
[
@@ -632,6 +636,17 @@ describe('loadCliConfig systemPromptMappings', () => {
});
describe('mergeExcludeTools', () => {
const defaultExcludes = [ShellTool.Name, EditTool.Name, WriteFileTool.Name];
const originalIsTTY = process.stdin.isTTY;
beforeEach(() => {
process.stdin.isTTY = true;
});
afterEach(() => {
process.stdin.isTTY = originalIsTTY;
});
it('should merge excludeTools from settings and extensions', async () => {
const settings: Settings = { excludeTools: ['tool1', 'tool2'] };
const extensions: Extension[] = [
@@ -726,7 +741,8 @@ describe('mergeExcludeTools', () => {
expect(config.getExcludeTools()).toHaveLength(4);
});
it('should return an empty array when no excludeTools are specified', async () => {
it('should return an empty array when no excludeTools are specified and it is interactive', async () => {
process.stdin.isTTY = true;
const settings: Settings = {};
const extensions: Extension[] = [];
process.argv = ['node', 'script.js'];
@@ -740,6 +756,21 @@ describe('mergeExcludeTools', () => {
expect(config.getExcludeTools()).toEqual([]);
});
it('should return default excludes when no excludeTools are specified and it is not interactive', async () => {
process.stdin.isTTY = false;
const settings: Settings = {};
const extensions: Extension[] = [];
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments();
const config = await loadCliConfig(
settings,
extensions,
'test-session',
argv,
);
expect(config.getExcludeTools()).toEqual(defaultExcludes);
});
it('should handle settings with excludeTools but no extensions', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
@@ -1062,7 +1093,7 @@ describe('loadCliConfig ideModeFeature', () => {
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
process.env.GEMINI_API_KEY = 'test-api-key';
delete process.env.SANDBOX;
delete process.env.GEMINI_CLI_IDE_SERVER_PORT;
delete process.env.QWEN_CODE_IDE_SERVER_PORT;
});
afterEach(() => {
@@ -1078,14 +1109,322 @@ describe('loadCliConfig ideModeFeature', () => {
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeModeFeature()).toBe(false);
});
});
it('should be false when settings.ideModeFeature is true, but SANDBOX is set', async () => {
describe('loadCliConfig folderTrustFeature', () => {
const originalArgv = process.argv;
const originalEnv = { ...process.env };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
process.env.GEMINI_API_KEY = 'test-api-key';
});
afterEach(() => {
process.argv = originalArgv;
process.env = originalEnv;
vi.restoreAllMocks();
});
it('should be false by default', async () => {
process.argv = ['node', 'script.js'];
const settings: Settings = {};
const argv = await parseArguments();
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getFolderTrustFeature()).toBe(false);
});
it('should be true when settings.folderTrustFeature is true', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
process.env.TERM_PROGRAM = 'vscode';
process.env.SANDBOX = 'true';
const settings: Settings = { ideModeFeature: true };
const settings: Settings = { folderTrustFeature: true };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeModeFeature()).toBe(false);
expect(config.getFolderTrustFeature()).toBe(true);
});
});
describe('loadCliConfig folderTrust', () => {
const originalArgv = process.argv;
const originalEnv = { ...process.env };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
process.env.GEMINI_API_KEY = 'test-api-key';
});
afterEach(() => {
process.argv = originalArgv;
process.env = originalEnv;
vi.restoreAllMocks();
});
it('should be false if folderTrustFeature is false and folderTrust is false', async () => {
process.argv = ['node', 'script.js'];
const settings: Settings = {
folderTrustFeature: false,
folderTrust: false,
};
const argv = await parseArguments();
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getFolderTrust()).toBe(false);
});
it('should be false if folderTrustFeature is true and folderTrust is false', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = { folderTrustFeature: true, folderTrust: false };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getFolderTrust()).toBe(false);
});
it('should be false if folderTrustFeature is false and folderTrust is true', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = { folderTrustFeature: false, folderTrust: true };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getFolderTrust()).toBe(false);
});
it('should be true when folderTrustFeature is true and folderTrust is true', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = { folderTrustFeature: true, folderTrust: true };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getFolderTrust()).toBe(true);
});
});
vi.mock('fs', async () => {
const actualFs = await vi.importActual<typeof fs>('fs');
const MOCK_CWD1 = process.cwd();
const MOCK_CWD2 = path.resolve(path.sep, 'home', 'user', 'project');
const mockPaths = new Set([
MOCK_CWD1,
MOCK_CWD2,
path.resolve(path.sep, 'cli', 'path1'),
path.resolve(path.sep, 'settings', 'path1'),
path.join(os.homedir(), 'settings', 'path2'),
path.join(MOCK_CWD2, 'cli', 'path2'),
path.join(MOCK_CWD2, 'settings', 'path3'),
]);
return {
...actualFs,
existsSync: vi.fn((p) => mockPaths.has(p.toString())),
statSync: vi.fn((p) => {
if (mockPaths.has(p.toString())) {
return { isDirectory: () => true };
}
// Fallback for other paths if needed, though the test should be specific.
return actualFs.statSync(p);
}),
realpathSync: vi.fn((p) => p),
};
});
describe('loadCliConfig with includeDirectories', () => {
const originalArgv = process.argv;
const originalEnv = { ...process.env };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
process.env.GEMINI_API_KEY = 'test-api-key';
vi.spyOn(process, 'cwd').mockReturnValue(
path.resolve(path.sep, 'home', 'user', 'project'),
);
});
afterEach(() => {
process.argv = originalArgv;
process.env = originalEnv;
vi.restoreAllMocks();
});
it('should combine and resolve paths from settings and CLI arguments', async () => {
const mockCwd = path.resolve(path.sep, 'home', 'user', 'project');
process.argv = [
'node',
'script.js',
'--include-directories',
`${path.resolve(path.sep, 'cli', 'path1')},${path.join(mockCwd, 'cli', 'path2')}`,
];
const argv = await parseArguments();
const settings: Settings = {
includeDirectories: [
path.resolve(path.sep, 'settings', 'path1'),
path.join(os.homedir(), 'settings', 'path2'),
path.join(mockCwd, 'settings', 'path3'),
],
};
const config = await loadCliConfig(settings, [], 'test-session', argv);
const expected = [
mockCwd,
path.resolve(path.sep, 'cli', 'path1'),
path.join(mockCwd, 'cli', 'path2'),
path.resolve(path.sep, 'settings', 'path1'),
path.join(os.homedir(), 'settings', 'path2'),
path.join(mockCwd, 'settings', 'path3'),
];
expect(config.getWorkspaceContext().getDirectories()).toEqual(
expect.arrayContaining(expected),
);
expect(config.getWorkspaceContext().getDirectories()).toHaveLength(
expected.length,
);
});
});
describe('loadCliConfig chatCompression', () => {
const originalArgv = process.argv;
const originalEnv = { ...process.env };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
process.env.GEMINI_API_KEY = 'test-api-key';
});
afterEach(() => {
process.argv = originalArgv;
process.env = originalEnv;
vi.restoreAllMocks();
});
it('should pass chatCompression settings to the core config', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = {
chatCompression: {
contextPercentageThreshold: 0.5,
},
};
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getChatCompression()).toEqual({
contextPercentageThreshold: 0.5,
});
});
it('should have undefined chatCompression if not in settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = {};
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getChatCompression()).toBeUndefined();
});
});
describe('loadCliConfig tool exclusions', () => {
const originalArgv = process.argv;
const originalEnv = { ...process.env };
const originalIsTTY = process.stdin.isTTY;
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
process.env.GEMINI_API_KEY = 'test-api-key';
process.stdin.isTTY = true;
});
afterEach(() => {
process.argv = originalArgv;
process.env = originalEnv;
process.stdin.isTTY = originalIsTTY;
vi.restoreAllMocks();
});
it('should not exclude interactive tools in interactive mode without YOLO', async () => {
process.stdin.isTTY = true;
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.getExcludeTools()).not.toContain('run_shell_command');
expect(config.getExcludeTools()).not.toContain('replace');
expect(config.getExcludeTools()).not.toContain('write_file');
});
it('should not exclude interactive tools in interactive mode with YOLO', async () => {
process.stdin.isTTY = true;
process.argv = ['node', 'script.js', '--yolo'];
const argv = await parseArguments();
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.getExcludeTools()).not.toContain('run_shell_command');
expect(config.getExcludeTools()).not.toContain('replace');
expect(config.getExcludeTools()).not.toContain('write_file');
});
it('should exclude interactive tools in non-interactive mode without YOLO', async () => {
process.stdin.isTTY = false;
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments();
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.getExcludeTools()).toContain('run_shell_command');
expect(config.getExcludeTools()).toContain('replace');
expect(config.getExcludeTools()).toContain('write_file');
});
it('should not exclude interactive tools in non-interactive mode with YOLO', async () => {
process.stdin.isTTY = false;
process.argv = ['node', 'script.js', '-p', 'test', '--yolo'];
const argv = await parseArguments();
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.getExcludeTools()).not.toContain('run_shell_command');
expect(config.getExcludeTools()).not.toContain('replace');
expect(config.getExcludeTools()).not.toContain('write_file');
});
});
describe('loadCliConfig interactive', () => {
const originalArgv = process.argv;
const originalEnv = { ...process.env };
const originalIsTTY = process.stdin.isTTY;
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
process.env.GEMINI_API_KEY = 'test-api-key';
process.stdin.isTTY = true;
});
afterEach(() => {
process.argv = originalArgv;
process.env = originalEnv;
process.stdin.isTTY = originalIsTTY;
vi.restoreAllMocks();
});
it('should be interactive if isTTY and no prompt', async () => {
process.stdin.isTTY = true;
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.isInteractive()).toBe(true);
});
it('should be interactive if prompt-interactive is set', async () => {
process.stdin.isTTY = false;
process.argv = ['node', 'script.js', '--prompt-interactive', 'test'];
const argv = await parseArguments();
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.isInteractive()).toBe(true);
});
it('should not be interactive if not isTTY and no prompt', async () => {
process.stdin.isTTY = false;
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.isInteractive()).toBe(false);
});
it('should not be interactive if prompt is set', async () => {
process.stdin.isTTY = true;
process.argv = ['node', 'script.js', '--prompt', 'test'];
const argv = await parseArguments();
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.isInteractive()).toBe(false);
});
});

View File

@@ -10,6 +10,7 @@ import { homedir } from 'node:os';
import yargs from 'yargs/yargs';
import { hideBin } from 'yargs/helpers';
import process from 'node:process';
import { mcpCommand } from '../commands/mcp.js';
import {
Config,
loadServerHierarchicalMemory,
@@ -22,13 +23,18 @@ import {
FileDiscoveryService,
TelemetryTarget,
FileFilteringOptions,
IdeClient,
ShellTool,
EditTool,
WriteFileTool,
MCPServerConfig,
ConfigParameters,
} from '@qwen-code/qwen-code-core';
import { Settings } from './settings.js';
import { Extension, annotateActiveExtensions } from './extension.js';
import { getCliVersion } from '../utils/version.js';
import { loadSandboxConfig } from './sandboxConfig.js';
import { resolvePath } from '../utils/resolvePath.js';
// Simple console logger for now - replace with actual logger if available
const logger = {
@@ -68,186 +74,203 @@ export interface CliArgs {
openaiBaseUrl: string | undefined;
proxy: string | undefined;
includeDirectories: string[] | undefined;
tavilyApiKey: string | undefined;
}
export async function parseArguments(): Promise<CliArgs> {
const yargsInstance = yargs(hideBin(process.argv))
.scriptName('qwen')
.usage(
'$0 [options]',
'Qwen Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode',
'Usage: qwen [options] [command]\n\nQwen Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode',
)
.option('model', {
alias: 'm',
type: 'string',
description: `Model`,
default: process.env.GEMINI_MODEL,
})
.option('prompt', {
alias: 'p',
type: 'string',
description: 'Prompt. Appended to input on stdin (if any).',
})
.option('prompt-interactive', {
alias: 'i',
type: 'string',
description:
'Execute the provided prompt and continue in interactive mode',
})
.option('sandbox', {
alias: 's',
type: 'boolean',
description: 'Run in sandbox?',
})
.option('sandbox-image', {
type: 'string',
description: 'Sandbox image URI.',
})
.option('debug', {
alias: 'd',
type: 'boolean',
description: 'Run in debug mode?',
default: false,
})
.option('all-files', {
alias: ['a'],
type: 'boolean',
description: 'Include ALL files in context?',
default: false,
})
.option('all_files', {
type: 'boolean',
description: 'Include ALL files in context?',
default: false,
})
.deprecateOption(
'all_files',
'Use --all-files instead. We will be removing --all_files in the coming weeks.',
.command('$0', 'Launch Qwen Code', (yargsInstance) =>
yargsInstance
.option('model', {
alias: 'm',
type: 'string',
description: `Model`,
default: process.env.GEMINI_MODEL,
})
.option('prompt', {
alias: 'p',
type: 'string',
description: 'Prompt. Appended to input on stdin (if any).',
})
.option('prompt-interactive', {
alias: 'i',
type: 'string',
description:
'Execute the provided prompt and continue in interactive mode',
})
.option('sandbox', {
alias: 's',
type: 'boolean',
description: 'Run in sandbox?',
})
.option('sandbox-image', {
type: 'string',
description: 'Sandbox image URI.',
})
.option('debug', {
alias: 'd',
type: 'boolean',
description: 'Run in debug mode?',
default: false,
})
.option('all-files', {
alias: ['a'],
type: 'boolean',
description: 'Include ALL files in context?',
default: false,
})
.option('all_files', {
type: 'boolean',
description: 'Include ALL files in context?',
default: false,
})
.deprecateOption(
'all_files',
'Use --all-files instead. We will be removing --all_files in the coming weeks.',
)
.option('show-memory-usage', {
type: 'boolean',
description: 'Show memory usage in status bar',
default: false,
})
.option('show_memory_usage', {
type: 'boolean',
description: 'Show memory usage in status bar',
default: false,
})
.deprecateOption(
'show_memory_usage',
'Use --show-memory-usage instead. We will be removing --show_memory_usage in the coming weeks.',
)
.option('yolo', {
alias: 'y',
type: 'boolean',
description:
'Automatically accept all actions (aka YOLO mode, see https://www.youtube.com/watch?v=xvFZjo5PgG0 for more details)?',
default: false,
})
.option('telemetry', {
type: 'boolean',
description:
'Enable telemetry? This flag specifically controls if telemetry is sent. Other --telemetry-* flags set specific values but do not enable telemetry on their own.',
})
.option('telemetry-target', {
type: 'string',
choices: ['local', 'gcp'],
description:
'Set the telemetry target (local or gcp). Overrides settings files.',
})
.option('telemetry-otlp-endpoint', {
type: 'string',
description:
'Set the OTLP endpoint for telemetry. Overrides environment variables and settings files.',
})
.option('telemetry-log-prompts', {
type: 'boolean',
description:
'Enable or disable logging of user prompts for telemetry. Overrides settings files.',
})
.option('telemetry-outfile', {
type: 'string',
description: 'Redirect all telemetry output to the specified file.',
})
.option('checkpointing', {
alias: 'c',
type: 'boolean',
description: 'Enables checkpointing of file edits',
default: false,
})
.option('experimental-acp', {
type: 'boolean',
description: 'Starts the agent in ACP mode',
})
.option('allowed-mcp-server-names', {
type: 'array',
string: true,
description: 'Allowed MCP server names',
})
.option('extensions', {
alias: 'e',
type: 'array',
string: true,
description:
'A list of extensions to use. If not provided, all extensions are used.',
})
.option('list-extensions', {
alias: 'l',
type: 'boolean',
description: 'List all available extensions and exit.',
})
.option('ide-mode-feature', {
type: 'boolean',
description: 'Run in IDE mode?',
})
.option('proxy', {
type: 'string',
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())),
})
.option('openai-logging', {
type: 'boolean',
description:
'Enable logging of OpenAI API calls for debugging and analysis',
})
.option('openai-api-key', {
type: 'string',
description: 'OpenAI API key to use for authentication',
})
.option('openai-base-url', {
type: 'string',
description: 'OpenAI base URL (for custom endpoints)',
})
.option('tavily-api-key', {
type: 'string',
description: 'Tavily API key for web search functionality',
})
.check((argv) => {
if (argv.prompt && argv.promptInteractive) {
throw new Error(
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
);
}
return true;
}),
)
.option('show-memory-usage', {
type: 'boolean',
description: 'Show memory usage in status bar',
default: false,
})
.option('show_memory_usage', {
type: 'boolean',
description: 'Show memory usage in status bar',
default: false,
})
.deprecateOption(
'show_memory_usage',
'Use --show-memory-usage instead. We will be removing --show_memory_usage in the coming weeks.',
)
.option('yolo', {
alias: 'y',
type: 'boolean',
description:
'Automatically accept all actions (aka YOLO mode, see https://www.youtube.com/watch?v=xvFZjo5PgG0 for more details)?',
default: false,
})
.option('telemetry', {
type: 'boolean',
description:
'Enable telemetry? This flag specifically controls if telemetry is sent. Other --telemetry-* flags set specific values but do not enable telemetry on their own.',
})
.option('telemetry-target', {
type: 'string',
choices: ['local', 'gcp'],
description:
'Set the telemetry target (local or gcp). Overrides settings files.',
})
.option('telemetry-otlp-endpoint', {
type: 'string',
description:
'Set the OTLP endpoint for telemetry. Overrides environment variables and settings files.',
})
.option('telemetry-log-prompts', {
type: 'boolean',
description:
'Enable or disable logging of user prompts for telemetry. Overrides settings files.',
})
.option('telemetry-outfile', {
type: 'string',
description: 'Redirect all telemetry output to the specified file.',
})
.option('checkpointing', {
alias: 'c',
type: 'boolean',
description: 'Enables checkpointing of file edits',
default: false,
})
.option('experimental-acp', {
type: 'boolean',
description: 'Starts the agent in ACP mode',
})
.option('allowed-mcp-server-names', {
type: 'array',
string: true,
description: 'Allowed MCP server names',
})
.option('extensions', {
alias: 'e',
type: 'array',
string: true,
description:
'A list of extensions to use. If not provided, all extensions are used.',
})
.option('list-extensions', {
alias: 'l',
type: 'boolean',
description: 'List all available extensions and exit.',
})
.option('ide-mode-feature', {
type: 'boolean',
description: 'Run in IDE mode?',
})
.option('openai-logging', {
type: 'boolean',
description:
'Enable logging of OpenAI API calls for debugging and analysis',
})
.option('openai-api-key', {
type: 'string',
description: 'OpenAI API key to use for authentication',
})
.option('openai-base-url', {
type: 'string',
description: 'OpenAI base URL (for custom endpoints)',
})
.option('proxy', {
type: 'string',
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())),
})
// Register MCP subcommands
.command(mcpCommand)
.version(await getCliVersion()) // This will enable the --version flag based on package.json
.alias('v', 'version')
.help()
.alias('h', 'help')
.strict()
.check((argv) => {
if (argv.prompt && argv.promptInteractive) {
throw new Error(
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
);
}
return true;
});
.demandCommand(0, 0); // Allow base command to run with no subcommands
yargsInstance.wrap(yargsInstance.terminalWidth());
const result = yargsInstance.parseSync();
const result = await yargsInstance.parse();
// Handle case where MCP subcommands are executed - they should exit the process
// and not return to main CLI logic
if (result._.length > 0 && result._[0] === 'mcp') {
// MCP commands handle their own execution and process exit
process.exit(0);
}
// The import format is now only controlled by settings.memoryImportFormat
// We no longer accept it as a CLI argument
return result as CliArgs;
return result as unknown as CliArgs;
}
// This function is now a thin wrapper around the server's implementation.
@@ -255,6 +278,7 @@ export async function parseArguments(): Promise<CliArgs> {
// TODO: Consider if App.tsx should get memory via a server call or if Config should refresh itself.
export async function loadHierarchicalGeminiMemory(
currentWorkingDirectory: string,
includeDirectoriesToReadGemini: readonly string[] = [],
debugMode: boolean,
fileService: FileDiscoveryService,
settings: Settings,
@@ -280,6 +304,7 @@ export async function loadHierarchicalGeminiMemory(
// Directly call the server function with the corrected path.
return loadServerHierarchicalMemory(
effectiveCwd,
includeDirectoriesToReadGemini,
debugMode,
fileService,
extensionContextFilePaths,
@@ -302,13 +327,14 @@ export async function loadCliConfig(
) ||
false;
const memoryImportFormat = settings.memoryImportFormat || 'tree';
const ideMode = settings.ideMode ?? false;
const ideModeFeature =
(argv.ideModeFeature ?? settings.ideModeFeature ?? false) &&
!process.env.SANDBOX;
argv.ideModeFeature ?? settings.ideModeFeature ?? false;
const ideClient = IdeClient.getInstance(ideMode && ideModeFeature);
const folderTrustFeature = settings.folderTrustFeature ?? false;
const folderTrustSetting = settings.folderTrust ?? false;
const folderTrust = folderTrustFeature && folderTrustSetting;
const allExtensions = annotateActiveExtensions(
extensions,
@@ -328,6 +354,11 @@ export async function loadCliConfig(
process.env.OPENAI_BASE_URL = argv.openaiBaseUrl;
}
// Handle Tavily API key from command line
if (argv.tavilyApiKey) {
process.env.TAVILY_API_KEY = argv.tavilyApiKey;
}
// Set the context filename in the server's memoryTool module BEFORE loading memory
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
// directly to the Config constructor in core, and have core handle setGeminiMdFilename.
@@ -350,9 +381,14 @@ export async function loadCliConfig(
...settings.fileFiltering,
};
const includeDirectories = (settings.includeDirectories || [])
.map(resolvePath)
.concat((argv.includeDirectories || []).map(resolvePath));
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
process.cwd(),
settings.loadMemoryFromIncludeDirectories ? includeDirectories : [],
debugMode,
fileService,
settings,
@@ -362,17 +398,31 @@ export async function loadCliConfig(
);
let mcpServers = mergeMcpServers(settings, activeExtensions);
const excludeTools = mergeExcludeTools(settings, activeExtensions);
const question = argv.promptInteractive || argv.prompt || '';
const approvalMode =
argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT;
const interactive =
!!argv.promptInteractive || (process.stdin.isTTY && question.length === 0);
// In non-interactive and non-yolo mode, exclude interactive built in tools.
const extraExcludes =
!interactive && approvalMode !== ApprovalMode.YOLO
? [ShellTool.Name, EditTool.Name, WriteFileTool.Name]
: undefined;
const excludeTools = mergeExcludeTools(
settings,
activeExtensions,
extraExcludes,
);
const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];
if (!argv.allowedMcpServerNames) {
if (settings.allowMCPServers) {
const allowedNames = new Set(settings.allowMCPServers.filter(Boolean));
if (allowedNames.size > 0) {
mcpServers = Object.fromEntries(
Object.entries(mcpServers).filter(([key]) => allowedNames.has(key)),
);
}
mcpServers = allowedMcpServers(
mcpServers,
settings.allowMCPServers,
blockedMcpServers,
);
}
if (settings.excludeMCPServers) {
@@ -386,29 +436,11 @@ export async function loadCliConfig(
}
if (argv.allowedMcpServerNames) {
const allowedNames = new Set(argv.allowedMcpServerNames.filter(Boolean));
if (allowedNames.size > 0) {
mcpServers = Object.fromEntries(
Object.entries(mcpServers).filter(([key, server]) => {
const isAllowed = allowedNames.has(key);
if (!isAllowed) {
blockedMcpServers.push({
name: key,
extensionName: server.extensionName || '',
});
}
return isAllowed;
}),
);
} else {
blockedMcpServers.push(
...Object.entries(mcpServers).map(([key, server]) => ({
name: key,
extensionName: server.extensionName || '',
})),
);
mcpServers = {};
}
mcpServers = allowedMcpServers(
mcpServers,
argv.allowedMcpServerNames,
blockedMcpServers,
);
}
const sandboxConfig = await loadSandboxConfig(settings, argv);
@@ -419,9 +451,11 @@ export async function loadCliConfig(
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: sandboxConfig,
targetDir: process.cwd(),
includeDirectories: argv.includeDirectories,
includeDirectories,
loadMemoryFromIncludeDirectories:
settings.loadMemoryFromIncludeDirectories || false,
debugMode,
question: argv.promptInteractive || argv.prompt || '',
question,
fullContext: argv.allFiles || argv.all_files || false,
coreTools: settings.coreTools || undefined,
excludeTools,
@@ -431,7 +465,7 @@ export async function loadCliConfig(
mcpServers,
userMemory: memoryContent,
geminiMdFileCount: fileCount,
approvalMode: argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT,
approvalMode,
showMemoryUsage:
argv.showMemoryUsage ||
argv.show_memory_usage ||
@@ -471,7 +505,6 @@ export async function loadCliConfig(
extensionContextFilePaths,
maxSessionTurns: settings.maxSessionTurns ?? -1,
sessionTokenLimit: settings.sessionTokenLimit ?? -1,
maxFolderItems: settings.maxFolderItems ?? 20,
experimentalAcp: argv.experimentalAcp || false,
listExtensions: argv.listExtensions || false,
extensions: allExtensions,
@@ -480,13 +513,12 @@ export async function loadCliConfig(
summarizeToolOutput: settings.summarizeToolOutput,
ideMode,
ideModeFeature,
ideClient,
enableOpenAILogging:
(typeof argv.openaiLogging === 'undefined'
? settings.enableOpenAILogging
: argv.openaiLogging) ?? false,
sampling_params: settings.sampling_params,
systemPromptMappings: settings.systemPromptMappings ?? [
systemPromptMappings: (settings.systemPromptMappings ?? [
{
baseUrls: [
'https://dashscope.aliyuncs.com/compatible-mode/v1/',
@@ -496,12 +528,49 @@ export async function loadCliConfig(
template:
'SYSTEM_TEMPLATE:{"name":"qwen3_coder","params":{"is_git_repository":{RUNTIME_VARS_IS_GIT_REPO},"sandbox":"{RUNTIME_VARS_SANDBOX}"}}',
},
],
]) as ConfigParameters['systemPromptMappings'],
contentGenerator: settings.contentGenerator,
cliVersion,
tavilyApiKey:
argv.tavilyApiKey || settings.tavilyApiKey || process.env.TAVILY_API_KEY,
chatCompression: settings.chatCompression,
folderTrustFeature,
folderTrust,
interactive,
});
}
function allowedMcpServers(
mcpServers: { [x: string]: MCPServerConfig },
allowMCPServers: string[],
blockedMcpServers: Array<{ name: string; extensionName: string }>,
) {
const allowedNames = new Set(allowMCPServers.filter(Boolean));
if (allowedNames.size > 0) {
mcpServers = Object.fromEntries(
Object.entries(mcpServers).filter(([key, server]) => {
const isAllowed = allowedNames.has(key);
if (!isAllowed) {
blockedMcpServers.push({
name: key,
extensionName: server.extensionName || '',
});
}
return isAllowed;
}),
);
} else {
blockedMcpServers.push(
...Object.entries(mcpServers).map(([key, server]) => ({
name: key,
extensionName: server.extensionName || '',
})),
);
mcpServers = {};
}
return mcpServers;
}
function mergeMcpServers(settings: Settings, extensions: Extension[]) {
const mcpServers = { ...(settings.mcpServers || {}) };
for (const extension of extensions) {
@@ -526,8 +595,12 @@ function mergeMcpServers(settings: Settings, extensions: Extension[]) {
function mergeExcludeTools(
settings: Settings,
extensions: Extension[],
extraExcludes?: string[] | undefined,
): string[] {
const allExcludeTools = new Set(settings.excludeTools || []);
const allExcludeTools = new Set([
...(settings.excludeTools || []),
...(extraExcludes || []),
]);
for (const extension of extensions) {
for (const tool of extension.config.excludeTools || []) {
allExcludeTools.add(tool);

View File

@@ -10,7 +10,8 @@ import * as path from 'path';
import * as os from 'os';
export const EXTENSIONS_DIRECTORY_NAME = path.join('.qwen', 'extensions');
export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
export const EXTENSIONS_CONFIG_FILENAME = 'qwen-extension.json';
export const EXTENSIONS_CONFIG_FILENAME_OLD = 'gemini-extension.json';
export interface Extension {
path: string;
@@ -68,12 +69,19 @@ function loadExtension(extensionDir: string): Extension | null {
return null;
}
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
let configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
if (!fs.existsSync(configFilePath)) {
console.error(
`Warning: extension directory ${extensionDir} does not contain a config file ${configFilePath}.`,
const oldConfigFilePath = path.join(
extensionDir,
EXTENSIONS_CONFIG_FILENAME_OLD,
);
return null;
if (!fs.existsSync(oldConfigFilePath)) {
console.error(
`Warning: extension directory ${extensionDir} does not contain a config file ${configFilePath}.`,
);
return null;
}
configFilePath = oldConfigFilePath;
}
try {

View File

@@ -0,0 +1,62 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
Command,
KeyBindingConfig,
defaultKeyBindings,
} from './keyBindings.js';
describe('keyBindings config', () => {
describe('defaultKeyBindings', () => {
it('should have bindings for all commands', () => {
const commands = Object.values(Command);
for (const command of commands) {
expect(defaultKeyBindings[command]).toBeDefined();
expect(Array.isArray(defaultKeyBindings[command])).toBe(true);
}
});
it('should have valid key binding structures', () => {
for (const [_, bindings] of Object.entries(defaultKeyBindings)) {
for (const binding of bindings) {
// Each binding should have either key or sequence, but not both
const hasKey = binding.key !== undefined;
const hasSequence = binding.sequence !== undefined;
expect(hasKey || hasSequence).toBe(true);
expect(hasKey && hasSequence).toBe(false);
// Modifier properties should be boolean or undefined
if (binding.ctrl !== undefined) {
expect(typeof binding.ctrl).toBe('boolean');
}
if (binding.shift !== undefined) {
expect(typeof binding.shift).toBe('boolean');
}
if (binding.command !== undefined) {
expect(typeof binding.command).toBe('boolean');
}
if (binding.paste !== undefined) {
expect(typeof binding.paste).toBe('boolean');
}
}
}
});
it('should export all required types', () => {
// Basic type checks
expect(typeof Command.HOME).toBe('string');
expect(typeof Command.END).toBe('string');
// Config should be readonly
const config: KeyBindingConfig = defaultKeyBindings;
expect(config[Command.HOME]).toBeDefined();
});
});
});

View File

@@ -0,0 +1,179 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Command enum for all available keyboard shortcuts
*/
export enum Command {
// Basic bindings
RETURN = 'return',
ESCAPE = 'escape',
// Cursor movement
HOME = 'home',
END = 'end',
// Text deletion
KILL_LINE_RIGHT = 'killLineRight',
KILL_LINE_LEFT = 'killLineLeft',
CLEAR_INPUT = 'clearInput',
// Screen control
CLEAR_SCREEN = 'clearScreen',
// History navigation
HISTORY_UP = 'historyUp',
HISTORY_DOWN = 'historyDown',
NAVIGATION_UP = 'navigationUp',
NAVIGATION_DOWN = 'navigationDown',
// Auto-completion
ACCEPT_SUGGESTION = 'acceptSuggestion',
COMPLETION_UP = 'completionUp',
COMPLETION_DOWN = 'completionDown',
// Text input
SUBMIT = 'submit',
NEWLINE = 'newline',
// External tools
OPEN_EXTERNAL_EDITOR = 'openExternalEditor',
PASTE_CLIPBOARD_IMAGE = 'pasteClipboardImage',
// App level bindings
SHOW_ERROR_DETAILS = 'showErrorDetails',
TOGGLE_TOOL_DESCRIPTIONS = 'toggleToolDescriptions',
TOGGLE_IDE_CONTEXT_DETAIL = 'toggleIDEContextDetail',
QUIT = 'quit',
EXIT = 'exit',
SHOW_MORE_LINES = 'showMoreLines',
// Shell commands
REVERSE_SEARCH = 'reverseSearch',
SUBMIT_REVERSE_SEARCH = 'submitReverseSearch',
ACCEPT_SUGGESTION_REVERSE_SEARCH = 'acceptSuggestionReverseSearch',
}
/**
* Data-driven key binding structure for user configuration
*/
export interface KeyBinding {
/** The key name (e.g., 'a', 'return', 'tab', 'escape') */
key?: string;
/** The key sequence (e.g., '\x18' for Ctrl+X) - alternative to key name */
sequence?: string;
/** Control key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
ctrl?: boolean;
/** Shift key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
shift?: boolean;
/** Command/meta key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
command?: boolean;
/** Paste operation requirement: true=must be paste, false=must not be paste, undefined=ignore */
paste?: boolean;
}
/**
* Configuration type mapping commands to their key bindings
*/
export type KeyBindingConfig = {
readonly [C in Command]: readonly KeyBinding[];
};
/**
* Default key binding configuration
* Matches the original hard-coded logic exactly
*/
export const defaultKeyBindings: KeyBindingConfig = {
// Basic bindings
[Command.RETURN]: [{ key: 'return' }],
// Original: key.name === 'escape'
[Command.ESCAPE]: [{ key: 'escape' }],
// Cursor movement
// Original: key.ctrl && key.name === 'a'
[Command.HOME]: [{ key: 'a', ctrl: true }],
// Original: key.ctrl && key.name === 'e'
[Command.END]: [{ key: 'e', ctrl: true }],
// Text deletion
// Original: key.ctrl && key.name === 'k'
[Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }],
// Original: key.ctrl && key.name === 'u'
[Command.KILL_LINE_LEFT]: [{ key: 'u', ctrl: true }],
// Original: key.ctrl && key.name === 'c'
[Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true }],
// Screen control
// Original: key.ctrl && key.name === 'l'
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
// History navigation
// Original: key.ctrl && key.name === 'p'
[Command.HISTORY_UP]: [{ key: 'p', ctrl: true }],
// Original: key.ctrl && key.name === 'n'
[Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true }],
// Original: key.name === 'up'
[Command.NAVIGATION_UP]: [{ key: 'up' }],
// Original: key.name === 'down'
[Command.NAVIGATION_DOWN]: [{ key: 'down' }],
// Auto-completion
// Original: key.name === 'tab' || (key.name === 'return' && !key.ctrl)
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
// Completion navigation (arrow or Ctrl+P/N)
[Command.COMPLETION_UP]: [{ key: 'up' }, { key: 'p', ctrl: true }],
[Command.COMPLETION_DOWN]: [{ key: 'down' }, { key: 'n', ctrl: true }],
// Text input
// Original: key.name === 'return' && !key.ctrl && !key.meta && !key.paste
[Command.SUBMIT]: [
{
key: 'return',
ctrl: false,
command: false,
paste: false,
},
],
// Original: key.name === 'return' && (key.ctrl || key.meta || key.paste)
// Split into multiple data-driven bindings
[Command.NEWLINE]: [
{ key: 'return', ctrl: true },
{ key: 'return', command: true },
{ key: 'return', paste: true },
],
// External tools
// Original: key.ctrl && (key.name === 'x' || key.sequence === '\x18')
[Command.OPEN_EXTERNAL_EDITOR]: [
{ key: 'x', ctrl: true },
{ sequence: '\x18', ctrl: true },
],
// Original: key.ctrl && key.name === 'v'
[Command.PASTE_CLIPBOARD_IMAGE]: [{ key: 'v', ctrl: true }],
// App level bindings
// Original: key.ctrl && key.name === 'o'
[Command.SHOW_ERROR_DETAILS]: [{ key: 'o', ctrl: true }],
// Original: key.ctrl && key.name === 't'
[Command.TOGGLE_TOOL_DESCRIPTIONS]: [{ key: 't', ctrl: true }],
// Original: key.ctrl && key.name === 'e'
[Command.TOGGLE_IDE_CONTEXT_DETAIL]: [{ key: 'e', ctrl: true }],
// Original: key.ctrl && (key.name === 'c' || key.name === 'C')
[Command.QUIT]: [{ key: 'c', ctrl: true }],
// Original: key.ctrl && (key.name === 'd' || key.name === 'D')
[Command.EXIT]: [{ key: 'd', ctrl: true }],
// Original: key.ctrl && key.name === 's'
[Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }],
// Shell commands
// Original: key.ctrl && key.name === 'r'
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
// Original: key.name === 'return' && !key.ctrl
// Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
// Original: key.name === 'tab'
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
};

View File

@@ -112,6 +112,8 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged).toEqual({
customThemes: {},
mcpServers: {},
includeDirectories: [],
chatCompression: {},
});
expect(settings.errors.length).toBe(0);
});
@@ -145,6 +147,8 @@ describe('Settings Loading and Merging', () => {
...systemSettingsContent,
customThemes: {},
mcpServers: {},
includeDirectories: [],
chatCompression: {},
});
});
@@ -178,6 +182,8 @@ describe('Settings Loading and Merging', () => {
...userSettingsContent,
customThemes: {},
mcpServers: {},
includeDirectories: [],
chatCompression: {},
});
});
@@ -209,6 +215,8 @@ describe('Settings Loading and Merging', () => {
...workspaceSettingsContent,
customThemes: {},
mcpServers: {},
includeDirectories: [],
chatCompression: {},
});
});
@@ -246,6 +254,8 @@ describe('Settings Loading and Merging', () => {
contextFileName: 'WORKSPACE_CONTEXT.md',
customThemes: {},
mcpServers: {},
includeDirectories: [],
chatCompression: {},
});
});
@@ -295,9 +305,67 @@ describe('Settings Loading and Merging', () => {
allowMCPServers: ['server1', 'server2'],
customThemes: {},
mcpServers: {},
includeDirectories: [],
chatCompression: {},
});
});
it('should ignore folderTrust from workspace settings', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
folderTrust: true,
};
const workspaceSettingsContent = {
folderTrust: false, // This should be ignored
};
const systemSettingsContent = {
// No folderTrust here
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath())
return JSON.stringify(systemSettingsContent);
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.folderTrust).toBe(true); // User setting should be used
});
it('should use system folderTrust over user setting', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
folderTrust: false,
};
const workspaceSettingsContent = {
folderTrust: true, // This should be ignored
};
const systemSettingsContent = {
folderTrust: true,
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath())
return JSON.stringify(systemSettingsContent);
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.folderTrust).toBe(true); // System setting should be used
});
it('should handle contextFileName correctly when only in user settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
@@ -616,6 +684,150 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged.mcpServers).toEqual({});
});
it('should merge chatCompression settings, with workspace taking precedence', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
chatCompression: { contextPercentageThreshold: 0.5 },
};
const workspaceSettingsContent = {
chatCompression: { contextPercentageThreshold: 0.8 },
};
(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.chatCompression).toEqual({
contextPercentageThreshold: 0.5,
});
expect(settings.workspace.settings.chatCompression).toEqual({
contextPercentageThreshold: 0.8,
});
expect(settings.merged.chatCompression).toEqual({
contextPercentageThreshold: 0.8,
});
});
it('should handle chatCompression when only in user settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
chatCompression: { contextPercentageThreshold: 0.5 },
};
(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.chatCompression).toEqual({
contextPercentageThreshold: 0.5,
});
});
it('should have chatCompression as an empty object if not in any settings file', () => {
(mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist
(fs.readFileSync as Mock).mockReturnValue('{}');
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.chatCompression).toEqual({});
});
it('should ignore chatCompression if contextPercentageThreshold is invalid', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
chatCompression: { contextPercentageThreshold: 1.5 },
};
(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.chatCompression).toBeUndefined();
expect(warnSpy).toHaveBeenCalledWith(
'Invalid value for chatCompression.contextPercentageThreshold: "1.5". Please use a value between 0 and 1. Using default compression settings.',
);
warnSpy.mockRestore();
});
it('should deep merge chatCompression settings', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
chatCompression: { contextPercentageThreshold: 0.5 },
};
const workspaceSettingsContent = {
chatCompression: {},
};
(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.chatCompression).toEqual({
contextPercentageThreshold: 0.5,
});
});
it('should merge includeDirectories from all scopes', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const systemSettingsContent = {
includeDirectories: ['/system/dir'],
};
const userSettingsContent = {
includeDirectories: ['/user/dir1', '/user/dir2'],
};
const workspaceSettingsContent = {
includeDirectories: ['/workspace/dir'],
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath())
return JSON.stringify(systemSettingsContent);
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.includeDirectories).toEqual([
'/system/dir',
'/user/dir1',
'/user/dir2',
'/workspace/dir',
]);
});
it('should handle JSON parsing errors gracefully', () => {
(mockFsExistsSync as Mock).mockReturnValue(true); // Both files "exist"
const invalidJsonContent = 'invalid json';
@@ -654,6 +866,8 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged).toEqual({
customThemes: {},
mcpServers: {},
includeDirectories: [],
chatCompression: {},
});
// Check that error objects are populated in settings.errors
@@ -1090,6 +1304,8 @@ describe('Settings Loading and Merging', () => {
...systemSettingsContent,
customThemes: {},
mcpServers: {},
includeDirectories: [],
chatCompression: {},
});
});
});

View File

@@ -9,17 +9,15 @@ import * as path from 'path';
import { homedir, platform } from 'os';
import * as dotenv from 'dotenv';
import {
MCPServerConfig,
GEMINI_CONFIG_DIR as GEMINI_DIR,
getErrorMessage,
BugCommandSettings,
TelemetrySettings,
AuthType,
} from '@qwen-code/qwen-code-core';
import stripJsonComments from 'strip-json-comments';
import { DefaultLight } from '../ui/themes/default-light.js';
import { DefaultDark } from '../ui/themes/default.js';
import { CustomTheme } from '../ui/themes/theme.js';
import { Settings, MemoryImportFormat } from './settingsSchema.js';
export type { Settings, MemoryImportFormat };
export const SETTINGS_DIRECTORY_NAME = '.qwen';
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
@@ -43,7 +41,7 @@ export function getWorkspaceSettingsPath(workspaceDir: string): string {
return path.join(workspaceDir, SETTINGS_DIRECTORY_NAME, 'settings.json');
}
export type DnsResolutionOrder = 'ipv4first' | 'verbatim';
export type { DnsResolutionOrder } from './settingsSchema.js';
export enum SettingScope {
User = 'User',
@@ -63,87 +61,6 @@ export interface AccessibilitySettings {
disableLoadingPhrases?: boolean;
}
export interface Settings {
theme?: string;
customThemes?: Record<string, CustomTheme>;
selectedAuthType?: AuthType;
useExternalAuth?: boolean;
sandbox?: boolean | string;
coreTools?: string[];
excludeTools?: string[];
toolDiscoveryCommand?: string;
toolCallCommand?: string;
mcpServerCommand?: string;
mcpServers?: Record<string, MCPServerConfig>;
allowMCPServers?: string[];
excludeMCPServers?: string[];
showMemoryUsage?: boolean;
contextFileName?: string | string[];
accessibility?: AccessibilitySettings;
telemetry?: TelemetrySettings;
usageStatisticsEnabled?: boolean;
preferredEditor?: string;
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
fileFiltering?: {
respectGitIgnore?: boolean;
respectGeminiIgnore?: boolean;
enableRecursiveFileSearch?: boolean;
};
hideWindowTitle?: boolean;
hideTips?: boolean;
hideBanner?: boolean;
// Setting for setting maximum number of user/model/tool turns in a session.
maxSessionTurns?: number;
// Setting for maximum token limit for conversation history before blocking requests
sessionTokenLimit?: number;
// Setting for maximum number of files and folders to show in folder structure
maxFolderItems?: number;
// A map of tool names to their summarization settings.
summarizeToolOutput?: Record<string, SummarizeToolOutputSettings>;
vimMode?: boolean;
memoryImportFormat?: 'tree' | 'flat';
// 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[];
modelNames: string[];
template: string;
}>;
contentGenerator?: {
timeout?: number;
maxRetries?: number;
};
}
export interface SettingsError {
message: string;
path: string;
@@ -183,9 +100,13 @@ export class LoadedSettings {
const user = this.user.settings;
const workspace = this.workspace.settings;
// folderTrust is not supported at workspace level.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { folderTrust, ...workspaceWithoutFolderTrust } = workspace;
return {
...user,
...workspace,
...workspaceWithoutFolderTrust,
...system,
customThemes: {
...(user.customThemes || {}),
@@ -197,6 +118,16 @@ export class LoadedSettings {
...(workspace.mcpServers || {}),
...(system.mcpServers || {}),
},
includeDirectories: [
...(system.includeDirectories || []),
...(user.includeDirectories || []),
...(workspace.includeDirectories || []),
],
chatCompression: {
...(system.chatCompression || {}),
...(user.chatCompression || {}),
...(workspace.chatCompression || {}),
},
};
}
@@ -387,7 +318,7 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
const settingsErrors: SettingsError[] = [];
const systemSettingsPath = getSystemSettingsPath();
// FIX: Resolve paths to their canonical representation to handle symlinks
// Resolve paths to their canonical representation to handle symlinks
const resolvedWorkspaceDir = path.resolve(workspaceDir);
const resolvedHomeDir = path.resolve(homedir());
@@ -442,7 +373,6 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
});
}
// This comparison is now much more reliable.
if (realWorkspaceDir !== realHomeDir) {
// Load workspace settings
try {
@@ -486,6 +416,19 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
settingsErrors,
);
// Validate chatCompression settings
const chatCompression = loadedSettings.merged.chatCompression;
const threshold = chatCompression?.contextPercentageThreshold;
if (
threshold != null &&
(typeof threshold !== 'number' || threshold < 0 || threshold > 1)
) {
console.warn(
`Invalid value for chatCompression.contextPercentageThreshold: "${threshold}". Please use a value between 0 and 1. Using default compression settings.`,
);
delete loadedSettings.merged.chatCompression;
}
// Load environment with merged settings
loadEnvironment(loadedSettings.merged);

View File

@@ -0,0 +1,253 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { SETTINGS_SCHEMA, Settings } from './settingsSchema.js';
describe('SettingsSchema', () => {
describe('SETTINGS_SCHEMA', () => {
it('should contain all expected top-level settings', () => {
const expectedSettings = [
'theme',
'customThemes',
'showMemoryUsage',
'usageStatisticsEnabled',
'autoConfigureMaxOldSpaceSize',
'preferredEditor',
'maxSessionTurns',
'memoryImportFormat',
'memoryDiscoveryMaxDirs',
'contextFileName',
'vimMode',
'ideMode',
'accessibility',
'checkpointing',
'fileFiltering',
'disableAutoUpdate',
'hideWindowTitle',
'hideTips',
'hideBanner',
'selectedAuthType',
'useExternalAuth',
'sandbox',
'coreTools',
'excludeTools',
'toolDiscoveryCommand',
'toolCallCommand',
'mcpServerCommand',
'mcpServers',
'allowMCPServers',
'excludeMCPServers',
'telemetry',
'bugCommand',
'summarizeToolOutput',
'ideModeFeature',
'dnsResolutionOrder',
'excludedProjectEnvVars',
'disableUpdateNag',
'includeDirectories',
'loadMemoryFromIncludeDirectories',
'model',
'hasSeenIdeIntegrationNudge',
'folderTrustFeature',
];
expectedSettings.forEach((setting) => {
expect(
SETTINGS_SCHEMA[setting as keyof typeof SETTINGS_SCHEMA],
).toBeDefined();
});
});
it('should have correct structure for each setting', () => {
Object.entries(SETTINGS_SCHEMA).forEach(([_key, definition]) => {
expect(definition).toHaveProperty('type');
expect(definition).toHaveProperty('label');
expect(definition).toHaveProperty('category');
expect(definition).toHaveProperty('requiresRestart');
expect(definition).toHaveProperty('default');
expect(typeof definition.type).toBe('string');
expect(typeof definition.label).toBe('string');
expect(typeof definition.category).toBe('string');
expect(typeof definition.requiresRestart).toBe('boolean');
});
});
it('should have correct nested setting structure', () => {
const nestedSettings = [
'accessibility',
'checkpointing',
'fileFiltering',
];
nestedSettings.forEach((setting) => {
const definition = SETTINGS_SCHEMA[
setting as keyof typeof SETTINGS_SCHEMA
] as (typeof SETTINGS_SCHEMA)[keyof typeof SETTINGS_SCHEMA] & {
properties: unknown;
};
expect(definition.type).toBe('object');
expect(definition.properties).toBeDefined();
expect(typeof definition.properties).toBe('object');
});
});
it('should have accessibility nested properties', () => {
expect(
SETTINGS_SCHEMA.accessibility.properties?.disableLoadingPhrases,
).toBeDefined();
expect(
SETTINGS_SCHEMA.accessibility.properties?.disableLoadingPhrases.type,
).toBe('boolean');
});
it('should have checkpointing nested properties', () => {
expect(SETTINGS_SCHEMA.checkpointing.properties?.enabled).toBeDefined();
expect(SETTINGS_SCHEMA.checkpointing.properties?.enabled.type).toBe(
'boolean',
);
});
it('should have fileFiltering nested properties', () => {
expect(
SETTINGS_SCHEMA.fileFiltering.properties?.respectGitIgnore,
).toBeDefined();
expect(
SETTINGS_SCHEMA.fileFiltering.properties?.respectGeminiIgnore,
).toBeDefined();
expect(
SETTINGS_SCHEMA.fileFiltering.properties?.enableRecursiveFileSearch,
).toBeDefined();
});
it('should have unique categories', () => {
const categories = new Set();
// Collect categories from top-level settings
Object.values(SETTINGS_SCHEMA).forEach((definition) => {
categories.add(definition.category);
// Also collect from nested properties
const defWithProps = definition as typeof definition & {
properties?: Record<string, unknown>;
};
if (defWithProps.properties) {
Object.values(defWithProps.properties).forEach(
(nestedDef: unknown) => {
const nestedDefTyped = nestedDef as { category?: string };
if (nestedDefTyped.category) {
categories.add(nestedDefTyped.category);
}
},
);
}
});
expect(categories.size).toBeGreaterThan(0);
expect(categories).toContain('General');
expect(categories).toContain('UI');
expect(categories).toContain('Mode');
expect(categories).toContain('Updates');
expect(categories).toContain('Accessibility');
expect(categories).toContain('Checkpointing');
expect(categories).toContain('File Filtering');
expect(categories).toContain('Advanced');
});
it('should have consistent default values for boolean settings', () => {
const checkBooleanDefaults = (schema: Record<string, unknown>) => {
Object.entries(schema).forEach(
([_key, definition]: [string, unknown]) => {
const def = definition as {
type?: string;
default?: unknown;
properties?: Record<string, unknown>;
};
if (def.type === 'boolean') {
// Boolean settings can have boolean or undefined defaults (for optional settings)
expect(['boolean', 'undefined']).toContain(typeof def.default);
}
if (def.properties) {
checkBooleanDefaults(def.properties);
}
},
);
};
checkBooleanDefaults(SETTINGS_SCHEMA as Record<string, unknown>);
});
it('should have showInDialog property configured', () => {
// Check that user-facing settings are marked for dialog display
expect(SETTINGS_SCHEMA.showMemoryUsage.showInDialog).toBe(true);
expect(SETTINGS_SCHEMA.vimMode.showInDialog).toBe(true);
expect(SETTINGS_SCHEMA.ideMode.showInDialog).toBe(true);
expect(SETTINGS_SCHEMA.disableAutoUpdate.showInDialog).toBe(true);
expect(SETTINGS_SCHEMA.hideWindowTitle.showInDialog).toBe(true);
expect(SETTINGS_SCHEMA.hideTips.showInDialog).toBe(true);
expect(SETTINGS_SCHEMA.hideBanner.showInDialog).toBe(true);
expect(SETTINGS_SCHEMA.usageStatisticsEnabled.showInDialog).toBe(true);
// Check that advanced settings are hidden from dialog
expect(SETTINGS_SCHEMA.selectedAuthType.showInDialog).toBe(false);
expect(SETTINGS_SCHEMA.coreTools.showInDialog).toBe(false);
expect(SETTINGS_SCHEMA.mcpServers.showInDialog).toBe(false);
expect(SETTINGS_SCHEMA.telemetry.showInDialog).toBe(false);
// Check that some settings are appropriately hidden
expect(SETTINGS_SCHEMA.theme.showInDialog).toBe(false); // Changed to false
expect(SETTINGS_SCHEMA.customThemes.showInDialog).toBe(false); // Managed via theme editor
expect(SETTINGS_SCHEMA.checkpointing.showInDialog).toBe(false); // Experimental feature
expect(SETTINGS_SCHEMA.accessibility.showInDialog).toBe(false); // Changed to false
expect(SETTINGS_SCHEMA.fileFiltering.showInDialog).toBe(false); // Changed to false
expect(SETTINGS_SCHEMA.preferredEditor.showInDialog).toBe(false); // Changed to false
expect(SETTINGS_SCHEMA.autoConfigureMaxOldSpaceSize.showInDialog).toBe(
true,
);
});
it('should infer Settings type correctly', () => {
// This test ensures that the Settings type is properly inferred from the schema
const settings: Settings = {
theme: 'dark',
includeDirectories: ['/path/to/dir'],
loadMemoryFromIncludeDirectories: true,
};
// TypeScript should not complain about these properties
expect(settings.theme).toBe('dark');
expect(settings.includeDirectories).toEqual(['/path/to/dir']);
expect(settings.loadMemoryFromIncludeDirectories).toBe(true);
});
it('should have includeDirectories setting in schema', () => {
expect(SETTINGS_SCHEMA.includeDirectories).toBeDefined();
expect(SETTINGS_SCHEMA.includeDirectories.type).toBe('array');
expect(SETTINGS_SCHEMA.includeDirectories.category).toBe('General');
expect(SETTINGS_SCHEMA.includeDirectories.default).toEqual([]);
});
it('should have loadMemoryFromIncludeDirectories setting in schema', () => {
expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories).toBeDefined();
expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories.type).toBe(
'boolean',
);
expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories.category).toBe(
'General',
);
expect(SETTINGS_SCHEMA.loadMemoryFromIncludeDirectories.default).toBe(
false,
);
});
it('should have folderTrustFeature setting in schema', () => {
expect(SETTINGS_SCHEMA.folderTrustFeature).toBeDefined();
expect(SETTINGS_SCHEMA.folderTrustFeature.type).toBe('boolean');
expect(SETTINGS_SCHEMA.folderTrustFeature.category).toBe('General');
expect(SETTINGS_SCHEMA.folderTrustFeature.default).toBe(false);
expect(SETTINGS_SCHEMA.folderTrustFeature.showInDialog).toBe(true);
});
});
});

View File

@@ -0,0 +1,571 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
MCPServerConfig,
BugCommandSettings,
TelemetrySettings,
AuthType,
ChatCompressionSettings,
} from '@qwen-code/qwen-code-core';
import { CustomTheme } from '../ui/themes/theme.js';
export interface SettingDefinition {
type: 'boolean' | 'string' | 'number' | 'array' | 'object';
label: string;
category: string;
requiresRestart: boolean;
default: boolean | string | number | string[] | object | undefined;
description?: string;
parentKey?: string;
childKey?: string;
key?: string;
properties?: SettingsSchema;
showInDialog?: boolean;
}
export interface SettingsSchema {
[key: string]: SettingDefinition;
}
export type MemoryImportFormat = 'tree' | 'flat';
export type DnsResolutionOrder = 'ipv4first' | 'verbatim';
/**
* The canonical schema for all settings.
* The structure of this object defines the structure of the `Settings` type.
* `as const` is crucial for TypeScript to infer the most specific types possible.
*/
export const SETTINGS_SCHEMA = {
// UI Settings
theme: {
type: 'string',
label: 'Theme',
category: 'UI',
requiresRestart: false,
default: undefined as string | undefined,
description: 'The color theme for the UI.',
showInDialog: false,
},
customThemes: {
type: 'object',
label: 'Custom Themes',
category: 'UI',
requiresRestart: false,
default: {} as Record<string, CustomTheme>,
description: 'Custom theme definitions.',
showInDialog: false,
},
hideWindowTitle: {
type: 'boolean',
label: 'Hide Window Title',
category: 'UI',
requiresRestart: true,
default: false,
description: 'Hide the window title bar',
showInDialog: true,
},
hideTips: {
type: 'boolean',
label: 'Hide Tips',
category: 'UI',
requiresRestart: false,
default: false,
description: 'Hide helpful tips in the UI',
showInDialog: true,
},
hideBanner: {
type: 'boolean',
label: 'Hide Banner',
category: 'UI',
requiresRestart: false,
default: false,
description: 'Hide the application banner',
showInDialog: true,
},
showMemoryUsage: {
type: 'boolean',
label: 'Show Memory Usage',
category: 'UI',
requiresRestart: false,
default: false,
description: 'Display memory usage information in the UI',
showInDialog: true,
},
usageStatisticsEnabled: {
type: 'boolean',
label: 'Enable Usage Statistics',
category: 'General',
requiresRestart: true,
default: true,
description: 'Enable collection of usage statistics',
showInDialog: true,
},
autoConfigureMaxOldSpaceSize: {
type: 'boolean',
label: 'Auto Configure Max Old Space Size',
category: 'General',
requiresRestart: true,
default: false,
description: 'Automatically configure Node.js memory limits',
showInDialog: true,
},
preferredEditor: {
type: 'string',
label: 'Preferred Editor',
category: 'General',
requiresRestart: false,
default: undefined as string | undefined,
description: 'The preferred editor to open files in.',
showInDialog: false,
},
maxSessionTurns: {
type: 'number',
label: 'Max Session Turns',
category: 'General',
requiresRestart: false,
default: undefined as number | undefined,
description:
'Maximum number of user/model/tool turns to keep in a session.',
showInDialog: false,
},
memoryImportFormat: {
type: 'string',
label: 'Memory Import Format',
category: 'General',
requiresRestart: false,
default: undefined as MemoryImportFormat | undefined,
description: 'The format to use when importing memory.',
showInDialog: false,
},
memoryDiscoveryMaxDirs: {
type: 'number',
label: 'Memory Discovery Max Dirs',
category: 'General',
requiresRestart: false,
default: undefined as number | undefined,
description: 'Maximum number of directories to search for memory.',
showInDialog: false,
},
contextFileName: {
type: 'object',
label: 'Context File Name',
category: 'General',
requiresRestart: false,
default: undefined as string | string[] | undefined,
description: 'The name of the context file.',
showInDialog: false,
},
vimMode: {
type: 'boolean',
label: 'Vim Mode',
category: 'Mode',
requiresRestart: false,
default: false,
description: 'Enable Vim keybindings',
showInDialog: true,
},
ideMode: {
type: 'boolean',
label: 'IDE Mode',
category: 'Mode',
requiresRestart: true,
default: false,
description: 'Enable IDE integration mode',
showInDialog: true,
},
accessibility: {
type: 'object',
label: 'Accessibility',
category: 'Accessibility',
requiresRestart: true,
default: {},
description: 'Accessibility settings.',
showInDialog: false,
properties: {
disableLoadingPhrases: {
type: 'boolean',
label: 'Disable Loading Phrases',
category: 'Accessibility',
requiresRestart: true,
default: false,
description: 'Disable loading phrases for accessibility',
showInDialog: true,
},
},
},
checkpointing: {
type: 'object',
label: 'Checkpointing',
category: 'Checkpointing',
requiresRestart: true,
default: {},
description: 'Session checkpointing settings.',
showInDialog: false,
properties: {
enabled: {
type: 'boolean',
label: 'Enable Checkpointing',
category: 'Checkpointing',
requiresRestart: true,
default: false,
description: 'Enable session checkpointing for recovery',
showInDialog: false,
},
},
},
fileFiltering: {
type: 'object',
label: 'File Filtering',
category: 'File Filtering',
requiresRestart: true,
default: {},
description: 'Settings for git-aware file filtering.',
showInDialog: false,
properties: {
respectGitIgnore: {
type: 'boolean',
label: 'Respect .gitignore',
category: 'File Filtering',
requiresRestart: true,
default: true,
description: 'Respect .gitignore files when searching',
showInDialog: true,
},
respectGeminiIgnore: {
type: 'boolean',
label: 'Respect .geminiignore',
category: 'File Filtering',
requiresRestart: true,
default: true,
description: 'Respect .geminiignore files when searching',
showInDialog: true,
},
enableRecursiveFileSearch: {
type: 'boolean',
label: 'Enable Recursive File Search',
category: 'File Filtering',
requiresRestart: true,
default: true,
description: 'Enable recursive file search functionality',
showInDialog: true,
},
},
},
disableAutoUpdate: {
type: 'boolean',
label: 'Disable Auto Update',
category: 'Updates',
requiresRestart: false,
default: false,
description: 'Disable automatic updates',
showInDialog: true,
},
selectedAuthType: {
type: 'string',
label: 'Selected Auth Type',
category: 'Advanced',
requiresRestart: true,
default: undefined as AuthType | undefined,
description: 'The currently selected authentication type.',
showInDialog: false,
},
useExternalAuth: {
type: 'boolean',
label: 'Use External Auth',
category: 'Advanced',
requiresRestart: true,
default: undefined as boolean | undefined,
description: 'Whether to use an external authentication flow.',
showInDialog: false,
},
sandbox: {
type: 'object',
label: 'Sandbox',
category: 'Advanced',
requiresRestart: true,
default: undefined as boolean | string | undefined,
description:
'Sandbox execution environment (can be a boolean or a path string).',
showInDialog: false,
},
coreTools: {
type: 'array',
label: 'Core Tools',
category: 'Advanced',
requiresRestart: true,
default: undefined as string[] | undefined,
description: 'Paths to core tool definitions.',
showInDialog: false,
},
excludeTools: {
type: 'array',
label: 'Exclude Tools',
category: 'Advanced',
requiresRestart: true,
default: undefined as string[] | undefined,
description: 'Tool names to exclude from discovery.',
showInDialog: false,
},
toolDiscoveryCommand: {
type: 'string',
label: 'Tool Discovery Command',
category: 'Advanced',
requiresRestart: true,
default: undefined as string | undefined,
description: 'Command to run for tool discovery.',
showInDialog: false,
},
toolCallCommand: {
type: 'string',
label: 'Tool Call Command',
category: 'Advanced',
requiresRestart: true,
default: undefined as string | undefined,
description: 'Command to run for tool calls.',
showInDialog: false,
},
mcpServerCommand: {
type: 'string',
label: 'MCP Server Command',
category: 'Advanced',
requiresRestart: true,
default: undefined as string | undefined,
description: 'Command to start an MCP server.',
showInDialog: false,
},
mcpServers: {
type: 'object',
label: 'MCP Servers',
category: 'Advanced',
requiresRestart: true,
default: {} as Record<string, MCPServerConfig>,
description: 'Configuration for MCP servers.',
showInDialog: false,
},
allowMCPServers: {
type: 'array',
label: 'Allow MCP Servers',
category: 'Advanced',
requiresRestart: true,
default: undefined as string[] | undefined,
description: 'A whitelist of MCP servers to allow.',
showInDialog: false,
},
excludeMCPServers: {
type: 'array',
label: 'Exclude MCP Servers',
category: 'Advanced',
requiresRestart: true,
default: undefined as string[] | undefined,
description: 'A blacklist of MCP servers to exclude.',
showInDialog: false,
},
telemetry: {
type: 'object',
label: 'Telemetry',
category: 'Advanced',
requiresRestart: true,
default: undefined as TelemetrySettings | undefined,
description: 'Telemetry configuration.',
showInDialog: false,
},
bugCommand: {
type: 'object',
label: 'Bug Command',
category: 'Advanced',
requiresRestart: false,
default: undefined as BugCommandSettings | undefined,
description: 'Configuration for the bug report command.',
showInDialog: false,
},
summarizeToolOutput: {
type: 'object',
label: 'Summarize Tool Output',
category: 'Advanced',
requiresRestart: false,
default: undefined as Record<string, { tokenBudget?: number }> | undefined,
description: 'Settings for summarizing tool output.',
showInDialog: false,
},
ideModeFeature: {
type: 'boolean',
label: 'IDE Mode Feature Flag',
category: 'Advanced',
requiresRestart: true,
default: undefined as boolean | undefined,
description: 'Internal feature flag for IDE mode.',
showInDialog: false,
},
dnsResolutionOrder: {
type: 'string',
label: 'DNS Resolution Order',
category: 'Advanced',
requiresRestart: true,
default: undefined as DnsResolutionOrder | undefined,
description: 'The DNS resolution order.',
showInDialog: false,
},
excludedProjectEnvVars: {
type: 'array',
label: 'Excluded Project Environment Variables',
category: 'Advanced',
requiresRestart: false,
default: ['DEBUG', 'DEBUG_MODE'] as string[],
description: 'Environment variables to exclude from project context.',
showInDialog: false,
},
disableUpdateNag: {
type: 'boolean',
label: 'Disable Update Nag',
category: 'Updates',
requiresRestart: false,
default: false,
description: 'Disable update notification prompts.',
showInDialog: false,
},
includeDirectories: {
type: 'array',
label: 'Include Directories',
category: 'General',
requiresRestart: false,
default: [] as string[],
description: 'Additional directories to include in the workspace context.',
showInDialog: false,
},
loadMemoryFromIncludeDirectories: {
type: 'boolean',
label: 'Load Memory From Include Directories',
category: 'General',
requiresRestart: false,
default: false,
description: 'Whether to load memory files from include directories.',
showInDialog: true,
},
model: {
type: 'string',
label: 'Model',
category: 'General',
requiresRestart: false,
default: undefined as string | undefined,
description: 'The Gemini model to use for conversations.',
showInDialog: false,
},
hasSeenIdeIntegrationNudge: {
type: 'boolean',
label: 'Has Seen IDE Integration Nudge',
category: 'General',
requiresRestart: false,
default: false,
description: 'Whether the user has seen the IDE integration nudge.',
showInDialog: false,
},
folderTrustFeature: {
type: 'boolean',
label: 'Folder Trust Feature',
category: 'General',
requiresRestart: false,
default: false,
description: 'Enable folder trust feature for enhanced security.',
showInDialog: true,
},
folderTrust: {
type: 'boolean',
label: 'Folder Trust',
category: 'General',
requiresRestart: false,
default: false,
description: 'Setting to track whether Folder trust is enabled.',
showInDialog: true,
},
chatCompression: {
type: 'object',
label: 'Chat Compression',
category: 'General',
requiresRestart: false,
default: undefined as ChatCompressionSettings | undefined,
description: 'Chat compression settings.',
showInDialog: false,
},
showLineNumbers: {
type: 'boolean',
label: 'Show Line Numbers',
category: 'General',
requiresRestart: false,
default: false,
description: 'Show line numbers in the chat.',
showInDialog: true,
},
contentGenerator: {
type: 'object',
label: 'Content Generator',
category: 'General',
requiresRestart: false,
default: undefined as Record<string, unknown> | undefined,
description: 'Content generator settings.',
showInDialog: false,
},
sampling_params: {
type: 'object',
label: 'Sampling Params',
category: 'General',
requiresRestart: false,
default: undefined as Record<string, unknown> | undefined,
description: 'Sampling parameters for the model.',
showInDialog: false,
},
enableOpenAILogging: {
type: 'boolean',
label: 'Enable OpenAI Logging',
category: 'General',
requiresRestart: false,
default: false,
description: 'Enable OpenAI logging.',
showInDialog: true,
},
sessionTokenLimit: {
type: 'number',
label: 'Session Token Limit',
category: 'General',
requiresRestart: false,
default: undefined as number | undefined,
description: 'The maximum number of tokens allowed in a session.',
showInDialog: false,
},
systemPromptMappings: {
type: 'object',
label: 'System Prompt Mappings',
category: 'General',
requiresRestart: false,
default: undefined as Record<string, string> | undefined,
description: 'Mappings of system prompts to model names.',
showInDialog: false,
},
tavilyApiKey: {
type: 'string',
label: 'Tavily API Key',
category: 'General',
requiresRestart: false,
default: undefined as string | undefined,
description: 'The API key for the Tavily API.',
showInDialog: false,
},
} as const;
type InferSettings<T extends SettingsSchema> = {
-readonly [K in keyof T]?: T[K] extends { properties: SettingsSchema }
? InferSettings<T[K]['properties']>
: T[K]['default'] extends boolean
? boolean
: T[K]['default'];
};
export type Settings = InferSettings<typeof SETTINGS_SCHEMA>;

View File

@@ -7,7 +7,7 @@
import React from 'react';
import { render } from 'ink';
import { AppWrapper } from './ui/App.js';
import { loadCliConfig, parseArguments, CliArgs } from './config/config.js';
import { loadCliConfig, parseArguments } from './config/config.js';
import { readStdin } from './utils/readStdin.js';
import { basename } from 'node:path';
import v8 from 'node:v8';
@@ -25,19 +25,18 @@ import { themeManager } from './ui/themes/theme-manager.js';
import { getStartupWarnings } from './utils/startupWarnings.js';
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
import { runNonInteractive } from './nonInteractiveCli.js';
import { loadExtensions, Extension } from './config/extension.js';
import { loadExtensions } from './config/extension.js';
import { cleanupCheckpoints, registerCleanup } from './utils/cleanup.js';
import { getCliVersion } from './utils/version.js';
import {
ApprovalMode,
Config,
EditTool,
ShellTool,
WriteFileTool,
sessionId,
logUserPrompt,
AuthType,
getOauthClient,
logIdeConnection,
IdeConnectionEvent,
IdeConnectionType,
} from '@qwen-code/qwen-code-core';
import { validateAuthMethod } from './config/auth.js';
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
@@ -45,6 +44,7 @@ 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';
import { SettingsContext } from './ui/contexts/SettingsContext.js';
export function validateDnsResolutionOrder(
order: string | undefined,
@@ -191,6 +191,11 @@ export async function main() {
await config.initialize();
if (config.getIdeMode() && config.getIdeModeFeature()) {
await config.getIdeClient().connect();
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
}
// Load custom themes from settings
themeManager.loadCustomThemes(settings.merged.customThemes);
@@ -255,21 +260,20 @@ export async function main() {
...(await getUserStartupWarnings(workspaceRoot)),
];
const shouldBeInteractive =
!!argv.promptInteractive || (process.stdin.isTTY && input?.length === 0);
// Render UI, passing necessary config values. Check that there is no command line question.
if (shouldBeInteractive) {
if (config.isInteractive()) {
const version = await getCliVersion();
setWindowTitle(basename(workspaceRoot), settings);
const instance = render(
<React.StrictMode>
<AppWrapper
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
/>
<SettingsContext.Provider value={settings}>
<AppWrapper
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
/>
</SettingsContext.Provider>
</React.StrictMode>,
{ exitOnCtrlC: false },
);
@@ -308,12 +312,10 @@ export async function main() {
prompt_length: input.length,
});
// Non-interactive mode handled by runNonInteractive
const nonInteractiveConfig = await loadNonInteractiveConfig(
const nonInteractiveConfig = await validateNonInteractiveAuth(
settings.merged.selectedAuthType,
settings.merged.useExternalAuth,
config,
extensions,
settings,
argv,
);
await runNonInteractive(nonInteractiveConfig, input, prompt_id);
@@ -334,43 +336,3 @@ function setWindowTitle(title: string, settings: LoadedSettings) {
});
}
}
async function loadNonInteractiveConfig(
config: Config,
extensions: Extension[],
settings: LoadedSettings,
argv: CliArgs,
) {
let finalConfig = config;
if (config.getApprovalMode() !== ApprovalMode.YOLO) {
// Everything is not allowed, ensure that only read-only tools are configured.
const existingExcludeTools = settings.merged.excludeTools || [];
const interactiveTools = [
ShellTool.Name,
EditTool.Name,
WriteFileTool.Name,
];
const newExcludeTools = [
...new Set([...existingExcludeTools, ...interactiveTools]),
];
const nonInteractiveSettings = {
...settings.merged,
excludeTools: newExcludeTools,
};
finalConfig = await loadCliConfig(
nonInteractiveSettings,
extensions,
config.getSessionId(),
argv,
);
await finalConfig.initialize();
}
return await validateNonInteractiveAuth(
settings.merged.selectedAuthType,
settings.merged.useExternalAuth,
finalConfig,
);
}

View File

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

View File

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

View File

@@ -30,9 +30,9 @@ import { restoreCommand } from '../ui/commands/restoreCommand.js';
import { statsCommand } from '../ui/commands/statsCommand.js';
import { themeCommand } from '../ui/commands/themeCommand.js';
import { toolsCommand } from '../ui/commands/toolsCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.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
@@ -73,8 +73,9 @@ export class BuiltinCommandLoader implements ICommandLoader {
statsCommand,
themeCommand,
toolsCommand,
settingsCommand,
vimCommand,
...(isGitHubRepository() ? [setupGithubCommand] : []),
setupGithubCommand,
];
return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);

View File

@@ -16,6 +16,7 @@ import {
SandboxConfig,
GeminiClient,
ideContext,
type AuthType,
} from '@qwen-code/qwen-code-core';
import { LoadedSettings, SettingsFile, Settings } from '../config/settings.js';
import process from 'node:process';
@@ -27,6 +28,7 @@ import { checkForUpdates, UpdateObject } from './utils/updateCheck.js';
import { EventEmitter } from 'events';
import { updateEventEmitter } from '../utils/updateEventEmitter.js';
import * as auth from '../config/auth.js';
import * as useTerminalSize from './hooks/useTerminalSize.js';
// Define a more complete mock server config based on actual Config
interface MockServerConfig {
@@ -84,6 +86,7 @@ interface MockServerConfig {
getAllGeminiMdFilenames: Mock<() => string[]>;
getGeminiClient: Mock<() => GeminiClient | undefined>;
getUserTier: Mock<() => Promise<string | undefined>>;
getIdeClient: Mock<() => { getCurrentIde: Mock<() => string | undefined> }>;
}
// Mock @qwen-code/qwen-code-core and its Config class
@@ -157,6 +160,9 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
getWorkspaceContext: vi.fn(() => ({
getDirectories: vi.fn(() => []),
})),
getIdeClient: vi.fn(() => ({
getCurrentIde: vi.fn(() => 'vscode'),
})),
};
});
@@ -182,6 +188,7 @@ vi.mock('./hooks/useGeminiStream', () => ({
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
})),
}));
@@ -196,6 +203,13 @@ vi.mock('./hooks/useAuthCommand', () => ({
})),
}));
vi.mock('./hooks/useFolderTrust', () => ({
useFolderTrust: vi.fn(() => ({
isFolderTrustDialogOpen: false,
handleFolderTrustSelect: vi.fn(),
})),
}));
vi.mock('./hooks/useLogger', () => ({
useLogger: vi.fn(() => ({
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
@@ -233,10 +247,14 @@ vi.mock('./utils/updateCheck.js', () => ({
checkForUpdates: vi.fn(),
}));
vi.mock('./config/auth.js', () => ({
vi.mock('../config/auth.js', () => ({
validateAuthMethod: vi.fn(),
}));
vi.mock('../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(),
}));
const mockedCheckForUpdates = vi.mocked(checkForUpdates);
const { isGitRepository: mockedIsGitRepository } = vi.mocked(
await import('@qwen-code/qwen-code-core'),
@@ -278,6 +296,11 @@ describe('App UI', () => {
};
beforeEach(() => {
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
columns: 120,
rows: 24,
});
const ServerConfigMocked = vi.mocked(ServerConfig, true);
mockConfig = new ServerConfigMocked({
embeddingModel: 'test-embedding-model',
@@ -1050,4 +1073,44 @@ describe('App UI', () => {
expect(validateAuthMethodSpy).not.toHaveBeenCalled();
});
});
describe('when in a narrow terminal', () => {
it('should render with a column layout', () => {
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
columns: 60,
rows: 24,
});
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
expect(lastFrame()).toMatchSnapshot();
});
});
describe('FolderTrustDialog', () => {
it('should display the folder trust dialog when isFolderTrustDialogOpen is true', async () => {
const { useFolderTrust } = await import('./hooks/useFolderTrust.js');
vi.mocked(useFolderTrust).mockReturnValue({
isFolderTrustDialogOpen: true,
handleFolderTrustSelect: vi.fn(),
});
const { lastFrame, unmount } = render(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
await Promise.resolve();
expect(lastFrame()).toContain('Do you trust this folder?');
});
});
});

View File

@@ -13,8 +13,6 @@ import {
Text,
useStdin,
useStdout,
useInput,
type Key as InkKeyType,
} from 'ink';
import { StreamingState, type HistoryItem, MessageType } from './types.js';
import { useTerminalSize } from './hooks/useTerminalSize.js';
@@ -23,6 +21,7 @@ import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useAuthCommand } from './hooks/useAuthCommand.js';
import { useQwenAuth } from './hooks/useQwenAuth.js';
import { useFolderTrust } from './hooks/useFolderTrust.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
@@ -38,17 +37,18 @@ import { AuthDialog } from './components/AuthDialog.js';
import { AuthInProgress } from './components/AuthInProgress.js';
import { QwenOAuthProgress } from './components/QwenOAuthProgress.js';
import { EditorSettingsDialog } from './components/EditorSettingsDialog.js';
import { FolderTrustDialog } from './components/FolderTrustDialog.js';
import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js';
import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js';
import { Colors } from './colors.js';
import { loadHierarchicalGeminiMemory } from '../config/config.js';
import { LoadedSettings } from '../config/settings.js';
import { LoadedSettings, SettingScope } from '../config/settings.js';
import { Tips } from './components/Tips.js';
import { ConsolePatcher } from './utils/ConsolePatcher.js';
import { registerCleanup } from '../utils/cleanup.js';
import { DetailedMessagesDisplay } from './components/DetailedMessagesDisplay.js';
import { HistoryItemDisplay } from './components/HistoryItemDisplay.js';
import { ContextSummaryDisplay } from './components/ContextSummaryDisplay.js';
import { IDEContextDetailDisplay } from './components/IDEContextDetailDisplay.js';
import { useHistory } from './hooks/useHistoryManager.js';
import process from 'node:process';
import {
@@ -64,6 +64,10 @@ import {
type IdeContext,
ideContext,
} from '@qwen-code/qwen-code-core';
import {
IdeIntegrationNudge,
IdeIntegrationNudgeResult,
} from './IdeIntegrationNudge.js';
import { validateAuthMethod } from '../config/auth.js';
import { useLogger } from './hooks/useLogger.js';
import { StreamingContext } from './contexts/StreamingContext.js';
@@ -77,6 +81,8 @@ import { useBracketedPaste } from './hooks/useBracketedPaste.js';
import { useTextBuffer } from './components/shared/text-buffer.js';
import { useVimMode, VimModeProvider } from './contexts/VimModeContext.js';
import { useVim } from './hooks/vim.js';
import { useKeypress, Key } from './hooks/useKeypress.js';
import { keyMatchers, Command } from './keyMatchers.js';
import * as fs from 'fs';
import { UpdateNotification } from './components/UpdateNotification.js';
import {
@@ -89,8 +95,11 @@ import ansiEscapes from 'ansi-escapes';
import { OverflowProvider } from './contexts/OverflowContext.js';
import { ShowMoreLines } from './components/ShowMoreLines.js';
import { PrivacyNotice } from './privacy/PrivacyNotice.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
import { SettingsDialog } from './components/SettingsDialog.js';
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
import { appEvents, AppEvent } from '../utils/events.js';
import { isNarrowWidth } from './utils/isNarrowWidth.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
@@ -117,6 +126,18 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const nightly = version.includes('nightly');
const { history, addItem, clearItems, loadHistory } = useHistory();
const [idePromptAnswered, setIdePromptAnswered] = useState(false);
const currentIDE = config.getIdeClient().getCurrentIde();
useEffect(() => {
registerCleanup(() => config.getIdeClient().disconnect());
}, [config]);
const shouldShowIdePrompt =
config.getIdeModeFeature() &&
currentIDE &&
!config.getIdeMode() &&
!settings.merged.hasSeenIdeIntegrationNudge &&
!idePromptAnswered;
useEffect(() => {
const cleanup = setUpdateHandler(addItem, setUpdateInfo);
return cleanup;
@@ -157,8 +178,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const [showErrorDetails, setShowErrorDetails] = useState<boolean>(false);
const [showToolDescriptions, setShowToolDescriptions] =
useState<boolean>(false);
const [showIDEContextDetail, setShowIDEContextDetail] =
useState<boolean>(false);
const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false);
const [quittingMessages, setQuittingMessages] = useState<
HistoryItem[] | null
@@ -174,6 +194,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const [ideContextState, setIdeContextState] = useState<
IdeContext | undefined
>();
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
useEffect(() => {
@@ -208,6 +229,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const openPrivacyNotice = useCallback(() => {
setShowPrivacyNotice(true);
}, []);
const handleEscapePromptChange = useCallback((showPrompt: boolean) => {
setShowEscapePrompt(showPrompt);
}, []);
const initialPromptSubmitted = useRef(false);
const errorCount = useMemo(
@@ -225,6 +251,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
handleThemeHighlight,
} = useThemeCommand(settings, setThemeError, addItem);
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
useSettingsCommand();
const { isFolderTrustDialogOpen, handleFolderTrustSelect } =
useFolderTrust(settings);
const {
isAuthDialogOpen,
openAuthDialog,
@@ -308,6 +340,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
try {
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
process.cwd(),
settings.merged.loadMemoryFromIncludeDirectories
? config.getWorkspaceContext().getDirectories()
: [],
config.getDebugMode(),
config.getFileService(),
settings.merged,
@@ -449,6 +484,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
// Terminal and UI setup
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
const { stdin, setRawMode } = useStdin();
const isInitialMount = useRef(true);
@@ -457,7 +493,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
20,
Math.floor(terminalWidth * widthFraction) - 3,
);
const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8));
const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 0.8));
// Utility callbacks
const isValidPath = useCallback((filePath: string): boolean => {
@@ -496,6 +532,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
pendingHistoryItems: pendingSlashCommandHistoryItems,
commandContext,
shellConfirmationRequest,
confirmationRequest,
} = useSlashCommandProcessor(
config,
settings,
@@ -510,16 +547,37 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
toggleCorgiMode,
setQuittingMessages,
openPrivacyNotice,
openSettingsDialog,
toggleVimEnabled,
setIsProcessing,
setGeminiMdFileCount,
);
const buffer = useTextBuffer({
initialText: '',
viewport: { height: 10, width: inputWidth },
stdin,
setRawMode,
isValidPath,
shellModeActive,
});
const [userMessages, setUserMessages] = useState<string[]>([]);
const handleUserCancel = useCallback(() => {
const lastUserMessage = userMessages.at(-1);
if (lastUserMessage) {
buffer.setText(lastUserMessage);
}
}, [buffer, userMessages]);
const {
streamingState,
submitQuery,
initError,
pendingHistoryItems: pendingGeminiHistoryItems,
thought,
cancelOngoingRequest,
} = useGeminiStream(
config.getGeminiClient(),
history,
@@ -533,6 +591,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
performMemoryRefresh,
modelSwitchedFromQuotaError,
setModelSwitchedFromQuotaError,
refreshStatic,
handleUserCancel,
);
// Input handling
@@ -546,14 +606,26 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
[submitQuery],
);
const buffer = useTextBuffer({
initialText: '',
viewport: { height: 10, width: inputWidth },
stdin,
setRawMode,
isValidPath,
shellModeActive,
});
const handleIdePromptComplete = useCallback(
(result: IdeIntegrationNudgeResult) => {
if (result === 'yes') {
handleSlashCommand('/ide install');
settings.setValue(
SettingScope.User,
'hasSeenIdeIntegrationNudge',
true,
);
} else if (result === 'dismiss') {
settings.setValue(
SettingScope.User,
'hasSeenIdeIntegrationNudge',
true,
);
}
setIdePromptAnswered(true);
},
[handleSlashCommand, settings],
);
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
const pendingHistoryItems = [...pendingSlashCommandHistoryItems];
@@ -586,55 +658,83 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
[handleSlashCommand],
);
useInput((input: string, key: InkKeyType) => {
let enteringConstrainHeightMode = false;
if (!constrainHeight) {
// Automatically re-enter constrain height mode if the user types
// anything. When constrainHeight==false, the user will experience
// significant flickering so it is best to disable it immediately when
// the user starts interacting with the app.
enteringConstrainHeightMode = true;
setConstrainHeight(true);
}
if (key.ctrl && input === 'o') {
setShowErrorDetails((prev) => !prev);
} else if (key.ctrl && input === 't') {
const newValue = !showToolDescriptions;
setShowToolDescriptions(newValue);
const mcpServers = config.getMcpServers();
if (Object.keys(mcpServers || {}).length > 0) {
handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc');
const handleGlobalKeypress = useCallback(
(key: Key) => {
let enteringConstrainHeightMode = false;
if (!constrainHeight) {
enteringConstrainHeightMode = true;
setConstrainHeight(true);
}
} else if (
key.ctrl &&
input === 'e' &&
config.getIdeMode() &&
ideContextState
) {
setShowIDEContextDetail((prev) => !prev);
} else if (key.ctrl && (input === 'c' || input === 'C')) {
handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef);
} else if (key.ctrl && (input === 'd' || input === 'D')) {
if (buffer.text.length > 0) {
// Do nothing if there is text in the input.
return;
if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) {
setShowErrorDetails((prev) => !prev);
} else if (keyMatchers[Command.TOGGLE_TOOL_DESCRIPTIONS](key)) {
const newValue = !showToolDescriptions;
setShowToolDescriptions(newValue);
const mcpServers = config.getMcpServers();
if (Object.keys(mcpServers || {}).length > 0) {
handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc');
}
} else if (
keyMatchers[Command.TOGGLE_IDE_CONTEXT_DETAIL](key) &&
config.getIdeMode() &&
ideContextState
) {
// Show IDE status when in IDE mode and context is available.
handleSlashCommand('/ide status');
} else if (keyMatchers[Command.QUIT](key)) {
// When authenticating, let AuthInProgress component handle Ctrl+C.
if (isAuthenticating) {
return;
}
if (!ctrlCPressedOnce) {
cancelOngoingRequest?.();
}
handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef);
} else if (keyMatchers[Command.EXIT](key)) {
if (buffer.text.length > 0) {
return;
}
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
} else if (
keyMatchers[Command.SHOW_MORE_LINES](key) &&
!enteringConstrainHeightMode
) {
setConstrainHeight(false);
}
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
} else if (key.ctrl && input === 's' && !enteringConstrainHeightMode) {
setConstrainHeight(false);
}
});
},
[
constrainHeight,
setConstrainHeight,
setShowErrorDetails,
showToolDescriptions,
setShowToolDescriptions,
config,
ideContextState,
handleExit,
ctrlCPressedOnce,
setCtrlCPressedOnce,
ctrlCTimerRef,
buffer.text.length,
ctrlDPressedOnce,
setCtrlDPressedOnce,
ctrlDTimerRef,
handleSlashCommand,
isAuthenticating,
cancelOngoingRequest,
],
);
useKeypress(handleGlobalKeypress, { isActive: true });
useEffect(() => {
if (config) {
setGeminiMdFileCount(config.getGeminiMdFileCount());
}
}, [config]);
}, [config, config.getGeminiMdFileCount]);
const logger = useLogger();
const [userMessages, setUserMessages] = useState<string[]>([]);
useEffect(() => {
const fetchUserMessages = async () => {
@@ -786,6 +886,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
</Box>
);
}
const mainAreaWidth = Math.floor(terminalWidth * 0.9);
const debugConsoleMaxHeight = Math.floor(Math.max(terminalHeight * 0.2, 5));
// Arbitrary threshold to ensure that items in the static area are large
@@ -814,11 +915,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
items={[
<Box flexDirection="column" key="header">
{!settings.merged.hideBanner && (
<Header
terminalWidth={terminalWidth}
version={version}
nightly={nightly}
/>
<Header version={version} nightly={nightly} />
)}
{!settings.merged.hideTips && <Tips config={config} />}
</Box>,
@@ -877,8 +974,30 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
</Box>
)}
{shellConfirmationRequest ? (
{shouldShowIdePrompt ? (
<IdeIntegrationNudge
ideName={config.getIdeClient().getDetectedIdeDisplayName()}
onComplete={handleIdePromptComplete}
/>
) : isFolderTrustDialogOpen ? (
<FolderTrustDialog onSelect={handleFolderTrustSelect} />
) : shellConfirmationRequest ? (
<ShellConfirmationDialog request={shellConfirmationRequest} />
) : confirmationRequest ? (
<Box flexDirection="column">
{confirmationRequest.prompt}
<Box paddingY={1}>
<RadioButtonSelect
items={[
{ label: 'Yes', value: true },
{ label: 'No', value: false },
]}
onSelect={(value: boolean) => {
confirmationRequest.onConfirm(value);
}}
/>
</Box>
</Box>
) : isThemeDialogOpen ? (
<Box flexDirection="column">
{themeError && (
@@ -898,6 +1017,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
terminalWidth={mainAreaWidth}
/>
</Box>
) : isSettingsDialogOpen ? (
<Box flexDirection="column">
<SettingsDialog
settings={settings}
onSelect={() => closeSettingsDialog()}
onRestartRequest={() => process.exit(0)}
/>
</Box>
) : isAuthenticating ? (
<>
{isQwenAuth && isQwenAuthenticating ? (
@@ -989,9 +1116,10 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
<Box
marginTop={1}
display="flex"
justifyContent="space-between"
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box>
{process.env.GEMINI_SYSTEM_MD && (
@@ -1005,6 +1133,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
<Text color={Colors.AccentYellow}>
Press Ctrl+D again to exit.
</Text>
) : showEscapePrompt ? (
<Text color={Colors.Gray}>Press Esc again to clear.</Text>
) : (
<ContextSummaryDisplay
ideContext={ideContextState}
@@ -1016,7 +1146,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
/>
)}
</Box>
<Box>
<Box paddingTop={isNarrow ? 1 : 0}>
{showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
!shellModeActive && (
<AutoAcceptIndicator
@@ -1026,14 +1156,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
{shellModeActive && <ShellModeIndicator />}
</Box>
</Box>
{showIDEContextDetail && (
<IDEContextDetailDisplay
ideContext={ideContextState}
detectedIdeDisplay={config
.getIdeClient()
.getDetectedIdeDisplayName()}
/>
)}
{showErrorDetails && (
<OverflowProvider>
<Box flexDirection="column">
@@ -1062,6 +1185,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
commandContext={commandContext}
shellModeActive={shellModeActive}
setShellModeActive={setShellModeActive}
onEscapePromptChange={handleEscapePromptChange}
focus={isFocused}
vimHandleInput={vimHandleInput}
placeholder={placeholder}
@@ -1112,7 +1236,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
errorCount={errorCount}
showErrorDetails={showErrorDetails}
showMemoryUsage={
config.getDebugMode() || config.getShowMemoryUsage()
config.getDebugMode() || settings.merged.showMemoryUsage || false
}
promptTokenCount={sessionStats.lastPromptTokenCount}
nightly={nightly}

View File

@@ -0,0 +1,70 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text, useInput } from 'ink';
import {
RadioButtonSelect,
RadioSelectItem,
} from './components/shared/RadioButtonSelect.js';
export type IdeIntegrationNudgeResult = 'yes' | 'no' | 'dismiss';
interface IdeIntegrationNudgeProps {
ideName?: string;
onComplete: (result: IdeIntegrationNudgeResult) => void;
}
export function IdeIntegrationNudge({
ideName,
onComplete,
}: IdeIntegrationNudgeProps) {
useInput((_input, key) => {
if (key.escape) {
onComplete('no');
}
});
const OPTIONS: Array<RadioSelectItem<IdeIntegrationNudgeResult>> = [
{
label: 'Yes',
value: 'yes',
},
{
label: 'No (esc)',
value: 'no',
},
{
label: "No, don't ask again",
value: 'dismiss',
},
];
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="yellow"
padding={1}
width="100%"
marginLeft={1}
>
<Box marginBottom={1} flexDirection="column">
<Text>
<Text color="yellow">{'> '}</Text>
{`Do you want to connect your ${ideName ?? 'your'} editor to Gemini CLI?`}
</Text>
<Text
dimColor
>{`If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${ideName ?? 'your editor'}.`}</Text>
</Box>
<RadioButtonSelect
items={OPTIONS}
onSelect={onComplete}
isFocused={true}
/>
</Box>
);
}

View File

@@ -10,9 +10,22 @@ exports[`App UI > should render correctly with the prompt input box 1`] = `
`;
exports[`App UI > should render the initial UI correctly 1`] = `
"
I'm Feeling Lucky (esc to cancel, 0s)
" I'm Feeling Lucky (esc to cancel, 0s)
/test/dir no sandbox (see /docs) model (100% context left)"
`;
exports[`App UI > when in a narrow terminal > should render with a column layout 1`] = `
"
╭────────────────────────────────────────────────────────────────────────────────────────╮
│ > Type your message or @path/to/file │
╰────────────────────────────────────────────────────────────────────────────────────────╯
dir
no sandbox (see /docs)
model (100% context left)| ✖ 5 errors (ctrl+o for details)"
`;

View File

@@ -168,8 +168,12 @@ describe('chatCommand', () => {
describe('save subcommand', () => {
let saveCommand: SlashCommand;
const tag = 'my-tag';
let mockCheckpointExists: ReturnType<typeof vi.fn>;
beforeEach(() => {
saveCommand = getSubCommand('save');
mockCheckpointExists = vi.fn().mockResolvedValue(false);
mockContext.services.logger.checkpointExists = mockCheckpointExists;
});
it('should return an error if tag is missing', async () => {
@@ -191,7 +195,7 @@ describe('chatCommand', () => {
});
});
it('should save the conversation', async () => {
it('should save the conversation if checkpoint does not exist', async () => {
const history: HistoryItemWithoutId[] = [
{
type: 'user',
@@ -199,8 +203,52 @@ describe('chatCommand', () => {
},
];
mockGetHistory.mockReturnValue(history);
mockCheckpointExists.mockResolvedValue(false);
const result = await saveCommand?.action?.(mockContext, tag);
expect(mockCheckpointExists).toHaveBeenCalledWith(tag);
expect(mockSaveCheckpoint).toHaveBeenCalledWith(history, tag);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation checkpoint saved with tag: ${tag}.`,
});
});
it('should return confirm_action if checkpoint already exists', async () => {
mockCheckpointExists.mockResolvedValue(true);
mockContext.invocation = {
raw: `/chat save ${tag}`,
name: 'save',
args: tag,
};
const result = await saveCommand?.action?.(mockContext, tag);
expect(mockCheckpointExists).toHaveBeenCalledWith(tag);
expect(mockSaveCheckpoint).not.toHaveBeenCalled();
expect(result).toMatchObject({
type: 'confirm_action',
originalInvocation: { raw: `/chat save ${tag}` },
});
// Check that prompt is a React element
expect(result).toHaveProperty('prompt');
});
it('should save the conversation if overwrite is confirmed', async () => {
const history: HistoryItemWithoutId[] = [
{
type: 'user',
text: 'hello',
},
];
mockGetHistory.mockReturnValue(history);
mockContext.overwriteConfirmed = true;
const result = await saveCommand?.action?.(mockContext, tag);
expect(mockCheckpointExists).not.toHaveBeenCalled(); // Should skip existence check
expect(mockSaveCheckpoint).toHaveBeenCalledWith(history, tag);
expect(result).toEqual({
type: 'message',

View File

@@ -5,11 +5,15 @@
*/
import * as fsPromises from 'fs/promises';
import React from 'react';
import { Text } from 'ink';
import { Colors } from '../colors.js';
import {
CommandContext,
SlashCommand,
MessageActionReturn,
CommandKind,
SlashCommandActionReturn,
} from './types.js';
import path from 'path';
import { HistoryItemWithoutId, MessageType } from '../types.js';
@@ -96,7 +100,7 @@ const saveCommand: SlashCommand = {
description:
'Save the current conversation as a checkpoint. Usage: /chat save <tag>',
kind: CommandKind.BUILT_IN,
action: async (context, args): Promise<MessageActionReturn> => {
action: async (context, args): Promise<SlashCommandActionReturn | void> => {
const tag = args.trim();
if (!tag) {
return {
@@ -108,6 +112,26 @@ const saveCommand: SlashCommand = {
const { logger, config } = context.services;
await logger.initialize();
if (!context.overwriteConfirmed) {
const exists = await logger.checkpointExists(tag);
if (exists) {
return {
type: 'confirm_action',
prompt: React.createElement(
Text,
null,
'A checkpoint with the tag ',
React.createElement(Text, { color: Colors.AccentPurple }, tag),
' already exists. Do you want to overwrite it?',
),
originalInvocation: {
raw: context.invocation?.raw || `/chat save ${tag}`,
},
};
}
}
const chat = await config?.getGeminiClient()?.getChat();
if (!chat) {
return {

View File

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

View File

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

View File

@@ -42,9 +42,15 @@ describe('ideCommand', () => {
mockConfig = {
getIdeModeFeature: vi.fn(),
getIdeMode: vi.fn(),
getIdeClient: vi.fn(),
getIdeClient: vi.fn(() => ({
reconnect: vi.fn(),
disconnect: vi.fn(),
getCurrentIde: vi.fn(),
getDetectedIdeDisplayName: vi.fn(),
getConnectionStatus: vi.fn(),
})),
setIdeModeAndSyncConnection: vi.fn(),
setIdeMode: vi.fn(),
setIdeClientDisconnected: vi.fn(),
} as unknown as Config;
platformSpy = vi.spyOn(process, 'platform', 'get');
@@ -87,13 +93,14 @@ describe('ideCommand', () => {
} as unknown as ReturnType<Config['getIdeClient']>);
});
it('should show connected status', () => {
it('should show connected status', async () => {
mockGetConnectionStatus.mockReturnValue({
status: core.IDEConnectionStatus.Connected,
});
const command = ideCommand(mockConfig);
const result = command!.subCommands!.find((c) => c.name === 'status')!
.action!(mockContext, '');
const result = await command!.subCommands!.find(
(c) => c.name === 'status',
)!.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
@@ -102,13 +109,14 @@ describe('ideCommand', () => {
});
});
it('should show connecting status', () => {
it('should show connecting status', async () => {
mockGetConnectionStatus.mockReturnValue({
status: core.IDEConnectionStatus.Connecting,
});
const command = ideCommand(mockConfig);
const result = command!.subCommands!.find((c) => c.name === 'status')!
.action!(mockContext, '');
const result = await command!.subCommands!.find(
(c) => c.name === 'status',
)!.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
@@ -116,13 +124,14 @@ describe('ideCommand', () => {
content: `🟡 Connecting...`,
});
});
it('should show disconnected status', () => {
it('should show disconnected status', async () => {
mockGetConnectionStatus.mockReturnValue({
status: core.IDEConnectionStatus.Disconnected,
});
const command = ideCommand(mockConfig);
const result = command!.subCommands!.find((c) => c.name === 'status')!
.action!(mockContext, '');
const result = await command!.subCommands!.find(
(c) => c.name === 'status',
)!.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
@@ -131,15 +140,16 @@ describe('ideCommand', () => {
});
});
it('should show disconnected status with details', () => {
it('should show disconnected status with details', async () => {
const details = 'Something went wrong';
mockGetConnectionStatus.mockReturnValue({
status: core.IDEConnectionStatus.Disconnected,
details,
});
const command = ideCommand(mockConfig);
const result = command!.subCommands!.find((c) => c.name === 'status')!
.action!(mockContext, '');
const result = await command!.subCommands!.find(
(c) => c.name === 'status',
)!.action!(mockContext, '');
expect(mockGetConnectionStatus).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',

View File

@@ -7,10 +7,15 @@
import {
Config,
DetectedIde,
QWEN_CODE_COMPANION_EXTENSION_NAME,
IDEConnectionStatus,
getIdeDisplayName,
getIdeInstaller,
IdeClient,
type File,
ideContext,
} from '@qwen-code/qwen-code-core';
import path from 'node:path';
import {
CommandContext,
SlashCommand,
@@ -19,6 +24,97 @@ import {
} from './types.js';
import { SettingScope } from '../../config/settings.js';
function getIdeStatusMessage(ideClient: IdeClient): {
messageType: 'info' | 'error';
content: string;
} {
const connection = ideClient.getConnectionStatus();
switch (connection.status) {
case IDEConnectionStatus.Connected:
return {
messageType: 'info',
content: `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`,
};
case IDEConnectionStatus.Connecting:
return {
messageType: 'info',
content: `🟡 Connecting...`,
};
default: {
let content = `🔴 Disconnected`;
if (connection?.details) {
content += `: ${connection.details}`;
}
return {
messageType: 'error',
content,
};
}
}
}
function formatFileList(openFiles: File[]): string {
const basenameCounts = new Map<string, number>();
for (const file of openFiles) {
const basename = path.basename(file.path);
basenameCounts.set(basename, (basenameCounts.get(basename) || 0) + 1);
}
const fileList = openFiles
.map((file: File) => {
const basename = path.basename(file.path);
const isDuplicate = (basenameCounts.get(basename) || 0) > 1;
const parentDir = path.basename(path.dirname(file.path));
const displayName = isDuplicate
? `${basename} (/${parentDir})`
: basename;
return ` - ${displayName}${file.isActive ? ' (active)' : ''}`;
})
.join('\n');
const infoMessage = `
(Note: The file list is limited to a number of recently accessed files within your workspace and only includes local files on disk)`;
return `\n\nOpen files:\n${fileList}\n${infoMessage}`;
}
async function getIdeStatusMessageWithFiles(ideClient: IdeClient): Promise<{
messageType: 'info' | 'error';
content: string;
}> {
const connection = ideClient.getConnectionStatus();
switch (connection.status) {
case IDEConnectionStatus.Connected: {
let content = `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`;
const context = ideContext.getIdeContext();
const openFiles = context?.workspaceState?.openFiles;
if (openFiles && openFiles.length > 0) {
content += formatFileList(openFiles);
}
return {
messageType: 'info',
content,
};
}
case IDEConnectionStatus.Connecting:
return {
messageType: 'info',
content: `🟡 Connecting...`,
};
default: {
let content = `🔴 Disconnected`;
if (connection?.details) {
content += `: ${connection.details}`;
}
return {
messageType: 'error',
content,
};
}
}
}
export const ideCommand = (config: Config | null): SlashCommand | null => {
if (!config || !config.getIdeModeFeature()) {
return null;
@@ -54,33 +150,14 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
name: 'status',
description: 'check status of IDE integration',
kind: CommandKind.BUILT_IN,
action: (_context: CommandContext): SlashCommandActionReturn => {
const connection = ideClient.getConnectionStatus();
switch (connection.status) {
case IDEConnectionStatus.Connected:
return {
type: 'message',
messageType: 'info',
content: `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`,
} as const;
case IDEConnectionStatus.Connecting:
return {
type: 'message',
messageType: 'info',
content: `🟡 Connecting...`,
} as const;
default: {
let content = `🔴 Disconnected`;
if (connection?.details) {
content += `: ${connection.details}`;
}
return {
type: 'message',
messageType: 'error',
content,
} as const;
}
}
action: async (): Promise<SlashCommandActionReturn> => {
const { messageType, content } =
await getIdeStatusMessageWithFiles(ideClient);
return {
type: 'message',
messageType,
content,
} as const;
},
};
@@ -94,7 +171,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
context.ui.addItem(
{
type: 'error',
text: `No installer is available for ${ideClient.getDetectedIdeDisplayName()}. Please install the IDE companion manually from its marketplace.`,
text: `No installer is available for ${ideClient.getDetectedIdeDisplayName()}. Please install the '${QWEN_CODE_COMPANION_EXTENSION_NAME}' extension manually from the marketplace.`,
},
Date.now(),
);
@@ -110,6 +187,10 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
);
const result = await installer.install();
if (result.success) {
config.setIdeMode(true);
context.services.settings.setValue(SettingScope.User, 'ideMode', true);
}
context.ui.addItem(
{
type: result.success ? 'info' : 'error',
@@ -126,8 +207,15 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
context.services.settings.setValue(SettingScope.User, 'ideMode', true);
config.setIdeMode(true);
config.setIdeClientConnected();
await config.setIdeModeAndSyncConnection(true);
const { messageType, content } = getIdeStatusMessage(ideClient);
context.ui.addItem(
{
type: messageType,
text: content,
},
Date.now(),
);
},
};
@@ -137,8 +225,15 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
context.services.settings.setValue(SettingScope.User, 'ideMode', false);
config.setIdeMode(false);
config.setIdeClientDisconnected();
await config.setIdeModeAndSyncConnection(false);
const { messageType, content } = getIdeStatusMessage(ideClient);
context.ui.addItem(
{
type: messageType,
text: content,
},
Date.now(),
);
},
};

View File

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

View File

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

View File

@@ -0,0 +1,36 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { settingsCommand } from './settingsCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('settingsCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
});
it('should return a dialog action to open the settings dialog', () => {
if (!settingsCommand.action) {
throw new Error('The settings command must have an action.');
}
const result = settingsCommand.action(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'settings',
});
});
it('should have the correct name and description', () => {
expect(settingsCommand.name).toBe('settings');
expect(settingsCommand.description).toBe(
'View and edit Gemini CLI settings',
);
});
});

View File

@@ -0,0 +1,17 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
export const settingsCommand: SlashCommand = {
name: 'settings',
description: 'View and edit Gemini CLI settings',
kind: CommandKind.BUILT_IN,
action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'settings',
}),
};

View File

@@ -4,63 +4,103 @@
* SPDX-License-Identifier: Apache-2.0
*/
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs/promises';
import { vi, describe, expect, it, afterEach, beforeEach } from 'vitest';
import * as child_process from 'child_process';
import * as gitUtils from '../../utils/gitUtils.js';
import { setupGithubCommand } from './setupGithubCommand.js';
import { CommandContext, ToolActionReturn } from './types.js';
import * as commandUtils from '../utils/commandUtils.js';
vi.mock('child_process');
describe('setupGithubCommand', () => {
beforeEach(() => {
// Mock fetch globally
global.fetch = vi.fn();
vi.mock('../../utils/gitUtils.js', () => ({
isGitHubRepository: vi.fn(),
getGitRepoRoot: vi.fn(),
getLatestGitHubRelease: vi.fn(),
getGitHubRepoInfo: vi.fn(),
}));
vi.mock('../utils/commandUtils.js', () => ({
getUrlOpenCommand: vi.fn(),
}));
describe('setupGithubCommand', async () => {
let scratchDir = '';
beforeEach(async () => {
vi.resetAllMocks();
scratchDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'setup-github-command-'),
);
});
afterEach(() => {
afterEach(async () => {
vi.restoreAllMocks();
if (scratchDir) await fs.rm(scratchDir, { recursive: true });
});
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);
it('returns a tool action to download github workflows and handles paths', async () => {
const fakeRepoOwner = 'fake';
const fakeRepoName = 'repo';
const fakeRepoRoot = scratchDir;
const fakeReleaseVersion = 'v1.2.3';
const result = setupGithubCommand.action?.(
const workflows = [
'gemini-cli.yml',
'gemini-issue-automated-triage.yml',
'gemini-issue-scheduled-triage.yml',
'gemini-pr-review.yml',
];
for (const workflow of workflows) {
vi.mocked(global.fetch).mockReturnValueOnce(
Promise.resolve(new Response(workflow)),
);
}
vi.mocked(gitUtils.isGitHubRepository).mockReturnValueOnce(true);
vi.mocked(gitUtils.getGitRepoRoot).mockReturnValueOnce(fakeRepoRoot);
vi.mocked(gitUtils.getLatestGitHubRelease).mockResolvedValueOnce(
fakeReleaseVersion,
);
vi.mocked(gitUtils.getGitHubRepoInfo).mockReturnValue({
owner: fakeRepoOwner,
repo: fakeRepoName,
});
vi.mocked(commandUtils.getUrlOpenCommand).mockReturnValueOnce(
'fakeOpenCommand',
);
const result = (await 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',
});
)) as ToolActionReturn;
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/',
`set -eEuo pipefail`,
`fakeOpenCommand "https://github.com/google-github-actions/run-gemini-cli`,
];
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.');
for (const workflow of workflows) {
const workflowFile = path.join(
scratchDir,
'.github',
'workflows',
workflow,
);
const contents = await fs.readFile(workflowFile, 'utf8');
expect(contents).toContain(workflow);
}
});
});

View File

@@ -4,32 +4,93 @@
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'path';
import { execSync } from 'child_process';
import { isGitHubRepository } from '../../utils/gitUtils.js';
import path from 'node:path';
import * as fs from 'node:fs';
import { Writable } from 'node:stream';
import { ProxyAgent } from 'undici';
import { CommandContext } from '../../ui/commands/types.js';
import {
getGitRepoRoot,
getLatestGitHubRelease,
isGitHubRepository,
getGitHubRepoInfo,
} from '../../utils/gitUtils.js';
import {
CommandKind,
SlashCommand,
SlashCommandActionReturn,
} from './types.js';
import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
// Generate OS-specific commands to open the GitHub pages needed for setup.
function getOpenUrlsCommands(readmeUrl: string): string[] {
// Determine the OS-specific command to open URLs, ex: 'open', 'xdg-open', etc
const openCmd = getUrlOpenCommand();
// Build a list of URLs to open
const urlsToOpen = [readmeUrl];
const repoInfo = getGitHubRepoInfo();
if (repoInfo) {
urlsToOpen.push(
`https://github.com/${repoInfo.owner}/${repoInfo.repo}/settings/secrets/actions`,
);
}
// Create and join the individual commands
const commands = urlsToOpen.map((url) => `${openCmd} "${url}"`);
return commands;
}
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();
action: async (
context: CommandContext,
): Promise<SlashCommandActionReturn> => {
const abortController = new AbortController();
if (!isGitHubRepository()) {
throw new Error('Unable to determine the Git root directory.');
throw new Error(
'Unable to determine the GitHub repository. /setup-github must be run from a git repository.',
);
}
const version = 'v0';
const workflowBaseUrl = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/heads/${version}/examples/workflows/`;
// Find the root directory of the repo
let gitRepoRoot: string;
try {
gitRepoRoot = getGitRepoRoot();
} catch (_error) {
console.debug(`Failed to get git repo root:`, _error);
throw new Error(
'Unable to determine the GitHub repository. /setup-github must be run from a git repository.',
);
}
// Get the latest release tag from GitHub
const proxy = context?.services?.config?.getProxy();
const releaseTag = await getLatestGitHubRelease(proxy);
const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`;
// Create the .github/workflows directory to download the files into
const githubWorkflowsDir = path.join(gitRepoRoot, '.github', 'workflows');
try {
await fs.promises.mkdir(githubWorkflowsDir, { recursive: true });
} catch (_error) {
console.debug(
`Failed to create ${githubWorkflowsDir} directory:`,
_error,
);
throw new Error(
`Unable to create ${githubWorkflowsDir} directory. Do you have file permissions in the current directory?`,
);
}
// Download each workflow in parallel - there aren't enough files to warrant
// a full workerpool model here.
const workflows = [
'gemini-cli/gemini-cli.yml',
'issue-triage/gemini-issue-automated-triage.yml',
@@ -37,15 +98,63 @@ export const setupGithubCommand: SlashCommand = {
'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(' && ');
const downloads = [];
for (const workflow of workflows) {
downloads.push(
(async () => {
const endpoint = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${releaseTag}/examples/workflows/${workflow}`;
const response = await fetch(endpoint, {
method: 'GET',
dispatcher: proxy ? new ProxyAgent(proxy) : undefined,
signal: AbortSignal.any([
AbortSignal.timeout(30_000),
abortController.signal,
]),
} as RequestInit);
if (!response.ok) {
throw new Error(
`Invalid response code downloading ${endpoint}: ${response.status} - ${response.statusText}`,
);
}
const body = response.body;
if (!body) {
throw new Error(
`Empty body while downloading ${endpoint}: ${response.status} - ${response.statusText}`,
);
}
const destination = path.resolve(
githubWorkflowsDir,
path.basename(workflow),
);
const fileStream = fs.createWriteStream(destination, {
mode: 0o644, // -rw-r--r--, user(rw), group(r), other(r)
flags: 'w', // write and overwrite
flush: true,
});
await body.pipeTo(Writable.toWeb(fileStream));
})(),
);
}
// Wait for all downloads to complete
await Promise.all(downloads).finally(() => {
// Stop existing downloads
abortController.abort();
});
// Print out a message
const commands = [];
commands.push('set -eEuo pipefail');
commands.push(
`echo "Successfully downloaded ${workflows.length} workflows. Follow the steps in ${readmeUrl} (skipping the /setup-github step) to complete setup."`,
);
commands.push(...getOpenUrlsCommands(readmeUrl));
const command = `(${commands.join(' && ')})`;
return {
type: 'tool',
toolName: 'run_shell_command',

View File

@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { type ReactNode } from 'react';
import { Content } from '@google/genai';
import { HistoryItemWithoutId } from '../types.js';
import { Config, GitService, Logger } from '@qwen-code/qwen-code-core';
@@ -59,6 +60,7 @@ export interface CommandContext {
/** Toggles a special display mode. */
toggleCorgiMode: () => void;
toggleVimEnabled: () => Promise<boolean>;
setGeminiMdFileCount: (count: number) => void;
};
// Session-specific data
session: {
@@ -67,6 +69,8 @@ export interface CommandContext {
/** A transient list of shell commands the user has approved for this session. */
sessionShellAllowlist: Set<string>;
};
// Flag to indicate if an overwrite has been confirmed
overwriteConfirmed?: boolean;
}
/**
@@ -99,7 +103,8 @@ export interface MessageActionReturn {
*/
export interface OpenDialogActionReturn {
type: 'dialog';
dialog: 'auth' | 'theme' | 'editor' | 'privacy';
dialog: 'help' | 'auth' | 'theme' | 'editor' | 'privacy' | 'settings';
}
/**
@@ -135,6 +140,16 @@ export interface ConfirmShellCommandsActionReturn {
};
}
export interface ConfirmActionReturn {
type: 'confirm_action';
/** The React node to display as the confirmation prompt. */
prompt: ReactNode;
/** The original invocation context to be re-run after confirmation. */
originalInvocation: {
raw: string;
};
}
export type SlashCommandActionReturn =
| ToolActionReturn
| MessageActionReturn
@@ -142,7 +157,8 @@ export type SlashCommandActionReturn =
| OpenDialogActionReturn
| LoadHistoryActionReturn
| SubmitPromptActionReturn
| ConfirmShellCommandsActionReturn;
| ConfirmShellCommandsActionReturn
| ConfirmActionReturn;
export enum CommandKind {
BUILT_IN = 'built-in',

View File

@@ -20,3 +20,14 @@ export const longAsciiLogo = `
██╔╝ ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║
╚═╝ ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
`;
export const tinyAsciiLogo = `
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
`;

View File

@@ -18,8 +18,8 @@ export function AuthInProgress({
}: AuthInProgressProps): React.JSX.Element {
const [timedOut, setTimedOut] = useState(false);
useInput((_, key) => {
if (key.escape) {
useInput((input, key) => {
if (key.escape || (key.ctrl && (input === 'c' || input === 'C'))) {
onTimeout();
}
});
@@ -48,7 +48,8 @@ export function AuthInProgress({
) : (
<Box>
<Text>
<Spinner type="dots" /> Waiting for auth... (Press ESC to cancel)
<Spinner type="dots" /> Waiting for auth... (Press ESC or CTRL+C to
cancel)
</Text>
</Box>
)}

View File

@@ -0,0 +1,85 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
vi.mock('../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(),
}));
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
const renderWithWidth = (
width: number,
props: React.ComponentProps<typeof ContextSummaryDisplay>,
) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
return render(<ContextSummaryDisplay {...props} />);
};
describe('<ContextSummaryDisplay />', () => {
const baseProps = {
geminiMdFileCount: 1,
contextFileNames: ['GEMINI.md'],
mcpServers: { 'test-server': { command: 'test' } },
showToolDescriptions: false,
ideContext: {
workspaceState: {
openFiles: [{ path: '/a/b/c' }],
},
},
};
it('should render on a single line on a wide screen', () => {
const { lastFrame } = renderWithWidth(120, baseProps);
const output = lastFrame();
expect(output).toContain(
'Using: 1 open file (ctrl+e to view) | 1 GEMINI.md file | 1 MCP server (ctrl+t to view)',
);
// Check for absence of newlines
expect(output.includes('\n')).toBe(false);
});
it('should render on multiple lines on a narrow screen', () => {
const { lastFrame } = renderWithWidth(60, baseProps);
const output = lastFrame();
const expectedLines = [
'Using:',
' - 1 open file (ctrl+e to view)',
' - 1 GEMINI.md file',
' - 1 MCP server (ctrl+t to view)',
];
const actualLines = output.split('\n');
expect(actualLines).toEqual(expectedLines);
});
it('should switch layout at the 80-column breakpoint', () => {
// At 80 columns, should be on one line
const { lastFrame: wideFrame } = renderWithWidth(80, baseProps);
expect(wideFrame().includes('\n')).toBe(false);
// At 79 columns, should be on multiple lines
const { lastFrame: narrowFrame } = renderWithWidth(79, baseProps);
expect(narrowFrame().includes('\n')).toBe(true);
expect(narrowFrame().split('\n').length).toBe(4);
});
it('should not render empty parts', () => {
const props = {
...baseProps,
geminiMdFileCount: 0,
mcpServers: {},
};
const { lastFrame } = renderWithWidth(60, props);
const expectedLines = ['Using:', ' - 1 open file (ctrl+e to view)'];
const actualLines = lastFrame().split('\n');
expect(actualLines).toEqual(expectedLines);
});
});

View File

@@ -5,12 +5,14 @@
*/
import React from 'react';
import { Text } from 'ink';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import {
type IdeContext,
type MCPServerConfig,
} from '@qwen-code/qwen-code-core';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
interface ContextSummaryDisplayProps {
geminiMdFileCount: number;
@@ -29,6 +31,8 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
showToolDescriptions,
ideContext,
}) => {
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
const mcpServerCount = Object.keys(mcpServers || {}).length;
const blockedMcpServerCount = blockedMcpServers?.length || 0;
const openFileCount = ideContext?.workspaceState?.openFiles?.length ?? 0;
@@ -81,30 +85,36 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
}
parts.push(blockedText);
}
return parts.join(', ');
let text = parts.join(', ');
// Add ctrl+t hint when MCP servers are available
if (mcpServers && Object.keys(mcpServers).length > 0) {
if (showToolDescriptions) {
text += ' (ctrl+t to toggle)';
} else {
text += ' (ctrl+t to view)';
}
}
return text;
})();
let summaryText = 'Using: ';
const summaryParts = [];
if (openFilesText) {
summaryParts.push(openFilesText);
}
if (geminiMdText) {
summaryParts.push(geminiMdText);
}
if (mcpText) {
summaryParts.push(mcpText);
}
summaryText += summaryParts.join(' | ');
const summaryParts = [openFilesText, geminiMdText, mcpText].filter(Boolean);
// Add ctrl+t hint when MCP servers are available
if (mcpServers && Object.keys(mcpServers).length > 0) {
if (showToolDescriptions) {
summaryText += ' (ctrl+t to toggle)';
} else {
summaryText += ' (ctrl+t to view)';
}
if (isNarrow) {
return (
<Box flexDirection="column">
<Text color={Colors.Gray}>Using:</Text>
{summaryParts.map((part, index) => (
<Text key={index} color={Colors.Gray}>
{' '}- {part}
</Text>
))}
</Box>
);
}
return <Text color={Colors.Gray}>{summaryText}</Text>;
return (
<Box>
<Text color={Colors.Gray}>Using: {summaryParts.join(' | ')}</Text>
</Box>
);
};

View File

@@ -0,0 +1,25 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Text } from 'ink';
import { Colors } from '../colors.js';
import { tokenLimit } from '@qwen-code/qwen-code-core';
export const ContextUsageDisplay = ({
promptTokenCount,
model,
}: {
promptTokenCount: number;
model: string;
}) => {
const percentage = promptTokenCount / tokenLimit(model);
return (
<Text color={Colors.Gray}>
({((1 - percentage) * 100).toFixed(0)}% context left)
</Text>
);
};

View File

@@ -0,0 +1,29 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { vi } from 'vitest';
import { FolderTrustDialog, FolderTrustChoice } from './FolderTrustDialog.js';
describe('FolderTrustDialog', () => {
it('should render the dialog with title and description', () => {
const { lastFrame } = render(<FolderTrustDialog onSelect={vi.fn()} />);
expect(lastFrame()).toContain('Do you trust this folder?');
expect(lastFrame()).toContain(
'Trusting a folder allows Gemini to execute commands it suggests.',
);
});
it('should call onSelect with DO_NOT_TRUST when escape is pressed', () => {
const onSelect = vi.fn();
const { stdin } = render(<FolderTrustDialog onSelect={onSelect} />);
stdin.write('\u001B'); // Simulate escape key
expect(onSelect).toHaveBeenCalledWith(FolderTrustChoice.DO_NOT_TRUST);
});
});

View File

@@ -0,0 +1,70 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text, useInput } from 'ink';
import React from 'react';
import { Colors } from '../colors.js';
import {
RadioButtonSelect,
RadioSelectItem,
} from './shared/RadioButtonSelect.js';
export enum FolderTrustChoice {
TRUST_FOLDER = 'trust_folder',
TRUST_PARENT = 'trust_parent',
DO_NOT_TRUST = 'do_not_trust',
}
interface FolderTrustDialogProps {
onSelect: (choice: FolderTrustChoice) => void;
}
export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
onSelect,
}) => {
useInput((_, key) => {
if (key.escape) {
onSelect(FolderTrustChoice.DO_NOT_TRUST);
}
});
const options: Array<RadioSelectItem<FolderTrustChoice>> = [
{
label: 'Trust folder',
value: FolderTrustChoice.TRUST_FOLDER,
},
{
label: 'Trust parent folder',
value: FolderTrustChoice.TRUST_PARENT,
},
{
label: "Don't trust (esc)",
value: FolderTrustChoice.DO_NOT_TRUST,
},
];
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={Colors.AccentYellow}
padding={1}
width="100%"
marginLeft={1}
>
<Box flexDirection="column" marginBottom={1}>
<Text bold>Do you trust this folder?</Text>
<Text>
Trusting a folder allows Gemini to execute commands it suggests. This
is a security feature to prevent accidental execution in untrusted
directories.
</Text>
</Box>
<RadioButtonSelect items={options} onSelect={onSelect} isFocused />
</Box>
);
};

View File

@@ -0,0 +1,106 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { Footer } from './Footer.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
import { tildeifyPath } from '@qwen-code/qwen-code-core';
import path from 'node:path';
vi.mock('../hooks/useTerminalSize.js');
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...original,
shortenPath: (p: string, len: number) => {
if (p.length > len) {
return '...' + p.slice(p.length - len + 3);
}
return p;
},
};
});
const defaultProps = {
model: 'gemini-pro',
targetDir:
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long',
branchName: 'main',
debugMode: false,
debugMessage: '',
corgiMode: false,
errorCount: 0,
showErrorDetails: false,
showMemoryUsage: false,
promptTokenCount: 100,
nightly: false,
};
const renderWithWidth = (width: number, props = defaultProps) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
return render(<Footer {...props} />);
};
describe('<Footer />', () => {
it('renders the component', () => {
const { lastFrame } = renderWithWidth(120);
expect(lastFrame()).toBeDefined();
});
describe('path display', () => {
it('should display shortened path on a wide terminal', () => {
const { lastFrame } = renderWithWidth(120);
const tildePath = tildeifyPath(defaultProps.targetDir);
const expectedPath = '...' + tildePath.slice(tildePath.length - 48 + 3);
expect(lastFrame()).toContain(expectedPath);
});
it('should display only the base directory name on a narrow terminal', () => {
const { lastFrame } = renderWithWidth(79);
const expectedPath = path.basename(defaultProps.targetDir);
expect(lastFrame()).toContain(expectedPath);
});
it('should use wide layout at 80 columns', () => {
const { lastFrame } = renderWithWidth(80);
const tildePath = tildeifyPath(defaultProps.targetDir);
const expectedPath = '...' + tildePath.slice(tildePath.length - 32 + 3);
expect(lastFrame()).toContain(expectedPath);
});
it('should use narrow layout at 79 columns', () => {
const { lastFrame } = renderWithWidth(79);
const expectedPath = path.basename(defaultProps.targetDir);
expect(lastFrame()).toContain(expectedPath);
const tildePath = tildeifyPath(defaultProps.targetDir);
const unexpectedPath = '...' + tildePath.slice(tildePath.length - 31 + 3);
expect(lastFrame()).not.toContain(unexpectedPath);
});
});
it('displays the branch name when provided', () => {
const { lastFrame } = renderWithWidth(120);
expect(lastFrame()).toContain(`(${defaultProps.branchName}*)`);
});
it('does not display the branch name when not provided', () => {
const { lastFrame } = renderWithWidth(120, {
...defaultProps,
branchName: undefined,
});
expect(lastFrame()).not.toContain(`(${defaultProps.branchName}*)`);
});
it('displays the model name and context percentage', () => {
const { lastFrame } = renderWithWidth(120);
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+% context[\s\S]*left\)/);
});
});

View File

@@ -6,19 +6,19 @@
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import {
shortenPath,
tildeifyPath,
tokenLimit,
} from '@qwen-code/qwen-code-core';
import { theme } from '../semantic-colors.js';
import { shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process';
import path from 'node:path';
import Gradient from 'ink-gradient';
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { DebugProfiler } from './DebugProfiler.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
interface FooterProps {
model: string;
targetDir: string;
@@ -48,29 +48,43 @@ export const Footer: React.FC<FooterProps> = ({
nightly,
vimMode,
}) => {
const limit = tokenLimit(model);
const percentage = promptTokenCount / limit;
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
// Adjust path length based on terminal width
const pathLength = Math.max(20, Math.floor(terminalWidth * 0.4));
const displayPath = isNarrow
? path.basename(tildeifyPath(targetDir))
: shortenPath(tildeifyPath(targetDir), pathLength);
return (
<Box justifyContent="space-between" width="100%">
<Box
justifyContent="space-between"
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box>
{debugMode && <DebugProfiler />}
{vimMode && <Text color={Colors.Gray}>[{vimMode}] </Text>}
{vimMode && <Text color={theme.text.secondary}>[{vimMode}] </Text>}
{nightly ? (
<Gradient colors={Colors.GradientColors}>
<Gradient colors={theme.ui.gradient}>
<Text>
{shortenPath(tildeifyPath(targetDir), 70)}
{displayPath}
{branchName && <Text> ({branchName}*)</Text>}
</Text>
</Gradient>
) : (
<Text color={Colors.LightBlue}>
{shortenPath(tildeifyPath(targetDir), 70)}
{branchName && <Text color={Colors.Gray}> ({branchName}*)</Text>}
<Text color={theme.text.link}>
{displayPath}
{branchName && (
<Text color={theme.text.secondary}> ({branchName}*)</Text>
)}
</Text>
)}
{debugMode && (
<Text color={Colors.AccentRed}>
<Text color={theme.status.error}>
{' ' + (debugMessage || '--debug')}
</Text>
)}
@@ -78,49 +92,54 @@ export const Footer: React.FC<FooterProps> = ({
{/* Middle Section: Centered Sandbox Info */}
<Box
flexGrow={1}
flexGrow={isNarrow ? 0 : 1}
alignItems="center"
justifyContent="center"
justifyContent={isNarrow ? 'flex-start' : 'center'}
display="flex"
paddingX={isNarrow ? 0 : 1}
paddingTop={isNarrow ? 1 : 0}
>
{process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec' ? (
<Text color="green">
{process.env.SANDBOX.replace(/^gemini-(?:cli-)?/, '')}
</Text>
) : process.env.SANDBOX === 'sandbox-exec' ? (
<Text color={Colors.AccentYellow}>
<Text color={theme.status.warning}>
macOS Seatbelt{' '}
<Text color={Colors.Gray}>({process.env.SEATBELT_PROFILE})</Text>
<Text color={theme.text.secondary}>
({process.env.SEATBELT_PROFILE})
</Text>
</Text>
) : (
<Text color={Colors.AccentRed}>
no sandbox <Text color={Colors.Gray}>(see /docs)</Text>
<Text color={theme.status.error}>
no sandbox <Text color={theme.text.secondary}>(see /docs)</Text>
</Text>
)}
</Box>
{/* Right Section: Gemini Label and Console Summary */}
<Box alignItems="center">
<Text color={Colors.AccentBlue}>
{' '}
<Box alignItems="center" paddingTop={isNarrow ? 1 : 0}>
<Text color={theme.text.accent}>
{isNarrow ? '' : ' '}
{model}{' '}
<Text color={Colors.Gray}>
({((1 - percentage) * 100).toFixed(0)}% context left)
</Text>
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
model={model}
/>
</Text>
{corgiMode && (
<Text>
<Text color={Colors.Gray}>| </Text>
<Text color={Colors.AccentRed}></Text>
<Text color={Colors.Foreground}>(´</Text>
<Text color={Colors.AccentRed}></Text>
<Text color={Colors.Foreground}>`)</Text>
<Text color={Colors.AccentRed}>▼ </Text>
<Text color={theme.ui.symbol}>| </Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>(´</Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>`)</Text>
<Text color={theme.status.error}>▼ </Text>
</Text>
)}
{!showErrorDetails && errorCount > 0 && (
<Box>
<Text color={Colors.Gray}>| </Text>
<Text color={theme.ui.symbol}>| </Text>
<ConsoleSummaryDisplay errorCount={errorCount} />
</Box>
)}

View File

@@ -0,0 +1,44 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Header } from './Header.js';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
import { longAsciiLogo } from './AsciiArt.js';
vi.mock('../hooks/useTerminalSize.js');
describe('<Header />', () => {
beforeEach(() => {});
it('renders the long logo on a wide terminal', () => {
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
columns: 120,
rows: 20,
});
const { lastFrame } = render(<Header version="1.0.0" nightly={false} />);
expect(lastFrame()).toContain(longAsciiLogo);
});
it('renders custom ASCII art when provided', () => {
const customArt = 'CUSTOM ART';
const { lastFrame } = render(
<Header version="1.0.0" nightly={false} customAsciiArt={customArt} />,
);
expect(lastFrame()).toContain(customArt);
});
it('displays the version number when nightly is true', () => {
const { lastFrame } = render(<Header version="1.0.0" nightly={true} />);
expect(lastFrame()).toContain('v1.0.0');
});
it('does not display the version number when nightly is false', () => {
const { lastFrame } = render(<Header version="1.0.0" nightly={false} />);
expect(lastFrame()).not.toContain('v1.0.0');
});
});

View File

@@ -8,30 +8,34 @@ import React from 'react';
import { Box, Text } from 'ink';
import Gradient from 'ink-gradient';
import { Colors } from '../colors.js';
import { shortAsciiLogo, longAsciiLogo } from './AsciiArt.js';
import { shortAsciiLogo, longAsciiLogo, tinyAsciiLogo } from './AsciiArt.js';
import { getAsciiArtWidth } from '../utils/textUtils.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
interface HeaderProps {
customAsciiArt?: string; // For user-defined ASCII art
terminalWidth: number; // For responsive logo
version: string;
nightly: boolean;
}
export const Header: React.FC<HeaderProps> = ({
customAsciiArt,
terminalWidth,
version,
nightly,
}) => {
const { columns: terminalWidth } = useTerminalSize();
let displayTitle;
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo);
if (customAsciiArt) {
displayTitle = customAsciiArt;
} else if (terminalWidth >= widthOfLongLogo) {
displayTitle = longAsciiLogo;
} else if (terminalWidth >= widthOfShortLogo) {
displayTitle = shortAsciiLogo;
} else {
displayTitle =
terminalWidth >= widthOfLongLogo ? longAsciiLogo : shortAsciiLogo;
displayTitle = tinyAsciiLogo;
}
const artWidth = getAsciiArtWidth(displayTitle);
@@ -52,9 +56,13 @@ export const Header: React.FC<HeaderProps> = ({
)}
{nightly && (
<Box width="100%" flexDirection="row" justifyContent="flex-end">
<Gradient colors={Colors.GradientColors}>
{Colors.GradientColors ? (
<Gradient colors={Colors.GradientColors}>
<Text>v{version}</Text>
</Gradient>
) : (
<Text>v{version}</Text>
</Gradient>
)}
</Box>
)}
</Box>

View File

@@ -1,51 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type File, type IdeContext } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import path from 'node:path';
import { Colors } from '../colors.js';
interface IDEContextDetailDisplayProps {
ideContext: IdeContext | undefined;
detectedIdeDisplay: string | undefined;
}
export function IDEContextDetailDisplay({
ideContext,
detectedIdeDisplay,
}: IDEContextDetailDisplayProps) {
const openFiles = ideContext?.workspaceState?.openFiles;
if (!openFiles || openFiles.length === 0) {
return null;
}
return (
<Box
flexDirection="column"
marginTop={1}
borderStyle="round"
borderColor={Colors.AccentCyan}
paddingX={1}
>
<Text color={Colors.AccentCyan} bold>
{detectedIdeDisplay ? detectedIdeDisplay : 'IDE'} Context (ctrl+e to
toggle)
</Text>
{openFiles.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold>Open files:</Text>
{openFiles.map((file: File) => (
<Text key={file.path}>
- {path.basename(file.path)}
{file.isActive ? ' (active)' : ''}
</Text>
))}
</Box>
)}
</Box>
);
}

View File

@@ -5,6 +5,7 @@
*/
import { render } from 'ink-testing-library';
import { waitFor } from '@testing-library/react';
import { InputPrompt, InputPromptProps } from './InputPrompt.js';
import type { TextBuffer } from './shared/text-buffer.js';
import { Config } from '@qwen-code/qwen-code-core';
@@ -1190,6 +1191,106 @@ describe('InputPrompt', () => {
});
});
describe('enhanced input UX - double ESC clear functionality', () => {
it('should clear buffer on second ESC press', async () => {
const onEscapePromptChange = vi.fn();
props.onEscapePromptChange = onEscapePromptChange;
props.buffer.setText('text to clear');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x1B');
await wait();
stdin.write('\x1B');
await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('');
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
unmount();
});
it('should reset escape state on any non-ESC key', async () => {
const onEscapePromptChange = vi.fn();
props.onEscapePromptChange = onEscapePromptChange;
props.buffer.setText('some text');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x1B');
await wait();
expect(onEscapePromptChange).toHaveBeenCalledWith(true);
stdin.write('a');
await wait();
expect(onEscapePromptChange).toHaveBeenCalledWith(false);
unmount();
});
it('should handle ESC in shell mode by disabling shell mode', async () => {
props.shellModeActive = true;
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x1B');
await wait();
expect(props.setShellModeActive).toHaveBeenCalledWith(false);
unmount();
});
it('should handle ESC when completion suggestions are showing', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'suggestion', value: 'suggestion' }],
});
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x1B');
await wait();
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
unmount();
});
it('should not call onEscapePromptChange when not provided', async () => {
props.onEscapePromptChange = undefined;
props.buffer.setText('some text');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x1B');
await wait();
unmount();
});
it('should not interfere with existing keyboard shortcuts', async () => {
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x0C');
await wait();
expect(props.onClearScreen).toHaveBeenCalled();
stdin.write('\x01');
await wait();
expect(props.buffer.move).toHaveBeenCalledWith('home');
unmount();
});
});
describe('reverse search', () => {
beforeEach(async () => {
props.shellModeActive = true;
@@ -1226,11 +1327,12 @@ describe('InputPrompt', () => {
stdin.write('\x12');
await wait();
stdin.write('\x1B');
await wait();
const frame = stdout.lastFrame();
expect(frame).not.toContain('(r:)');
expect(frame).not.toContain('echo hello');
await waitFor(() => {
expect(stdout.lastFrame()).not.toContain('(r:)');
});
expect(stdout.lastFrame()).not.toContain('echo hello');
unmount();
});
@@ -1240,9 +1342,11 @@ describe('InputPrompt', () => {
stdin.write('\x12');
await wait();
stdin.write('\t');
await wait();
expect(stdout.lastFrame()).not.toContain('(r:)');
await waitFor(() => {
expect(stdout.lastFrame()).not.toContain('(r:)');
});
expect(props.buffer.setText).toHaveBeenCalledWith('echo hello');
unmount();
});
@@ -1253,9 +1357,11 @@ describe('InputPrompt', () => {
await wait();
expect(stdout.lastFrame()).toContain('(r:)');
stdin.write('\r');
await wait();
expect(stdout.lastFrame()).not.toContain('(r:)');
await waitFor(() => {
expect(stdout.lastFrame()).not.toContain('(r:)');
});
expect(props.onSubmit).toHaveBeenCalledWith('echo hello');
unmount();
});
@@ -1268,9 +1374,10 @@ describe('InputPrompt', () => {
await wait();
expect(stdout.lastFrame()).toContain('(r:)');
stdin.write('\x1B');
await wait();
expect(stdout.lastFrame()).not.toContain('(r:)');
await waitFor(() => {
expect(stdout.lastFrame()).not.toContain('(r:)');
});
expect(props.buffer.text).toBe('initial text');
expect(props.buffer.cursor).toEqual([0, 3]);

View File

@@ -4,9 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState, useRef } from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import { TextBuffer, logicalPosToOffset } from './shared/text-buffer.js';
@@ -17,6 +17,7 @@ import { useShellHistory } from '../hooks/useShellHistory.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
import { useKeypress, Key } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config } from '@qwen-code/qwen-code-core';
import {
@@ -40,6 +41,7 @@ export interface InputPromptProps {
suggestionsWidth: number;
shellModeActive: boolean;
setShellModeActive: (value: boolean) => void;
onEscapePromptChange?: (showPrompt: boolean) => void;
vimHandleInput?: (key: Key) => boolean;
}
@@ -57,9 +59,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
suggestionsWidth,
shellModeActive,
setShellModeActive,
onEscapePromptChange,
vimHandleInput,
}) => {
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
const [dirs, setDirs] = useState<readonly string[]>(
config.getWorkspaceContext().getDirectories(),
@@ -97,6 +103,32 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const resetReverseSearchCompletionState =
reverseSearchCompletion.resetCompletionState;
const resetEscapeState = useCallback(() => {
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
escapeTimerRef.current = null;
}
setEscPressCount(0);
setShowEscapePrompt(false);
}, []);
// Notify parent component about escape prompt state changes
useEffect(() => {
if (onEscapePromptChange) {
onEscapePromptChange(showEscapePrompt);
}
}, [showEscapePrompt, onEscapePromptChange]);
// Clear escape prompt timer on unmount
useEffect(
() => () => {
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
}
},
[],
);
const handleSubmitAndClear = useCallback(
(submittedValue: string) => {
if (shellModeActive) {
@@ -211,6 +243,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
// Reset ESC count and hide prompt on any non-ESC key
if (key.name !== 'escape') {
if (escPressCount > 0 || showEscapePrompt) {
resetEscapeState();
}
}
if (
key.sequence === '!' &&
buffer.text === '' &&
@@ -221,7 +260,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
if (key.name === 'escape') {
if (keyMatchers[Command.ESCAPE](key)) {
if (reverseSearchActive) {
setReverseSearchActive(false);
reverseSearchCompletion.resetCompletionState();
@@ -234,26 +273,48 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.moveToOffset(offset);
return;
}
if (shellModeActive) {
setShellModeActive(false);
resetEscapeState();
return;
}
if (completion.showSuggestions) {
completion.resetCompletionState();
resetEscapeState();
return;
}
// Handle double ESC for clearing input
if (escPressCount === 0) {
if (buffer.text === '') {
return;
}
setEscPressCount(1);
setShowEscapePrompt(true);
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
}
escapeTimerRef.current = setTimeout(() => {
resetEscapeState();
}, 500);
} else {
// clear input and immediately reset state
buffer.setText('');
resetCompletionState();
resetEscapeState();
}
return;
}
if (shellModeActive && key.ctrl && key.name === 'r') {
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
setReverseSearchActive(true);
setTextBeforeReverseSearch(buffer.text);
setCursorPosition(buffer.cursor);
return;
}
if (key.ctrl && key.name === 'l') {
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
onClearScreen();
return;
}
@@ -268,15 +329,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} = reverseSearchCompletion;
if (showSuggestions) {
if (key.name === 'up') {
if (keyMatchers[Command.NAVIGATION_UP](key)) {
navigateUp();
return;
}
if (key.name === 'down') {
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
navigateDown();
return;
}
if (key.name === 'tab') {
if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) {
reverseSearchCompletion.handleAutocomplete(activeSuggestionIndex);
reverseSearchCompletion.resetCompletionState();
setReverseSearchActive(false);
@@ -284,7 +345,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}
if (key.name === 'return' && !key.ctrl) {
if (keyMatchers[Command.SUBMIT_REVERSE_SEARCH](key)) {
const textToSubmit =
showSuggestions && activeSuggestionIndex > -1
? suggestions[activeSuggestionIndex].value
@@ -296,30 +357,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
// Prevent up/down from falling through to regular history navigation
if (key.name === 'up' || key.name === 'down') {
if (
keyMatchers[Command.NAVIGATION_UP](key) ||
keyMatchers[Command.NAVIGATION_DOWN](key)
) {
return;
}
}
// If the command is a perfect match, pressing enter should execute it.
if (completion.isPerfectMatch && key.name === 'return') {
if (completion.isPerfectMatch && keyMatchers[Command.RETURN](key)) {
handleSubmitAndClear(buffer.text);
return;
}
if (completion.showSuggestions) {
if (completion.suggestions.length > 1) {
if (key.name === 'up' || (key.ctrl && key.name === 'p')) {
if (keyMatchers[Command.COMPLETION_UP](key)) {
completion.navigateUp();
return;
}
if (key.name === 'down' || (key.ctrl && key.name === 'n')) {
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
completion.navigateDown();
return;
}
}
if (key.name === 'tab' || (key.name === 'return' && !key.ctrl)) {
if (keyMatchers[Command.ACCEPT_SUGGESTION](key)) {
if (completion.suggestions.length > 0) {
const targetIndex =
completion.activeSuggestionIndex === -1
@@ -334,17 +398,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
if (!shellModeActive) {
if (key.ctrl && key.name === 'p') {
if (keyMatchers[Command.HISTORY_UP](key)) {
inputHistory.navigateUp();
return;
}
if (key.ctrl && key.name === 'n') {
if (keyMatchers[Command.HISTORY_DOWN](key)) {
inputHistory.navigateDown();
return;
}
// Handle arrow-up/down for history on single-line or at edges
if (
key.name === 'up' &&
keyMatchers[Command.NAVIGATION_UP](key) &&
(buffer.allVisualLines.length === 1 ||
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
) {
@@ -352,7 +416,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
if (
key.name === 'down' &&
keyMatchers[Command.NAVIGATION_DOWN](key) &&
(buffer.allVisualLines.length === 1 ||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
) {
@@ -360,18 +424,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
} else {
if (key.name === 'up') {
// Shell History Navigation
if (keyMatchers[Command.NAVIGATION_UP](key)) {
const prevCommand = shellHistory.getPreviousCommand();
if (prevCommand !== null) buffer.setText(prevCommand);
return;
}
if (key.name === 'down') {
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
const nextCommand = shellHistory.getNextCommand();
if (nextCommand !== null) buffer.setText(nextCommand);
return;
}
}
if (key.name === 'return' && !key.ctrl && !key.meta && !key.paste) {
if (keyMatchers[Command.SUBMIT](key)) {
if (buffer.text.trim()) {
const [row, col] = buffer.cursor;
const line = buffer.lines[row];
@@ -387,50 +453,48 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
// Newline insertion
if (key.name === 'return' && (key.ctrl || key.meta || key.paste)) {
if (keyMatchers[Command.NEWLINE](key)) {
buffer.newline();
return;
}
// Ctrl+A (Home) / Ctrl+E (End)
if (key.ctrl && key.name === 'a') {
if (keyMatchers[Command.HOME](key)) {
buffer.move('home');
return;
}
if (key.ctrl && key.name === 'e') {
if (keyMatchers[Command.END](key)) {
buffer.move('end');
buffer.moveToOffset(cpLen(buffer.text));
return;
}
// Ctrl+C (Clear input)
if (key.ctrl && key.name === 'c') {
if (keyMatchers[Command.CLEAR_INPUT](key)) {
if (buffer.text.length > 0) {
buffer.setText('');
resetCompletionState();
return;
}
return;
}
// Kill line commands
if (key.ctrl && key.name === 'k') {
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
buffer.killLineRight();
return;
}
if (key.ctrl && key.name === 'u') {
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
buffer.killLineLeft();
return;
}
// External editor
const isCtrlX = key.ctrl && (key.name === 'x' || key.sequence === '\x18');
if (isCtrlX) {
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
buffer.openInExternalEditor();
return;
}
// Ctrl+V for clipboard image paste
if (key.ctrl && key.name === 'v') {
if (keyMatchers[Command.PASTE_CLIPBOARD_IMAGE](key)) {
handleClipboardImage();
return;
}
@@ -451,6 +515,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
reverseSearchCompletion,
handleClipboardImage,
resetCompletionState,
escPressCount,
showEscapePrompt,
resetEscapeState,
vimHandleInput,
reverseSearchActive,
textBeforeReverseSearch,
@@ -469,15 +536,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
<>
<Box
borderStyle="round"
borderColor={shellModeActive ? Colors.AccentYellow : Colors.AccentBlue}
borderColor={
shellModeActive ? theme.status.warning : theme.border.focused
}
paddingX={1}
>
<Text
color={shellModeActive ? Colors.AccentYellow : Colors.AccentPurple}
color={shellModeActive ? theme.status.warning : theme.text.accent}
>
{shellModeActive ? (
reverseSearchActive ? (
<Text color={Colors.AccentCyan}>(r:) </Text>
<Text color={theme.text.link}>(r:) </Text>
) : (
'! '
)
@@ -490,10 +559,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
focus ? (
<Text>
{chalk.inverse(placeholder.slice(0, 1))}
<Text color={Colors.Gray}>{placeholder.slice(1)}</Text>
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
</Text>
) : (
<Text color={Colors.Gray}>{placeholder}</Text>
<Text color={theme.text.secondary}>{placeholder}</Text>
)
) : (
linesToRender.map((lineText, visualIdxInRenderedSet) => {
@@ -536,7 +605,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
</Box>
</Box>
{completion.showSuggestions && (
<Box>
<Box paddingRight={2}>
<SuggestionsDisplay
suggestions={completion.suggestions}
activeIndex={completion.activeSuggestionIndex}
@@ -548,7 +617,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
</Box>
)}
{reverseSearchActive && (
<Box>
<Box paddingRight={2}>
<SuggestionsDisplay
suggestions={reverseSearchCompletion.suggestions}
activeIndex={reverseSearchCompletion.activeSuggestionIndex}

View File

@@ -11,6 +11,7 @@ import { LoadingIndicator } from './LoadingIndicator.js';
import { StreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { vi } from 'vitest';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
// Mock GeminiRespondingSpinner
vi.mock('./GeminiRespondingSpinner.js', () => ({
@@ -29,10 +30,18 @@ vi.mock('./GeminiRespondingSpinner.js', () => ({
},
}));
vi.mock('../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(),
}));
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
const renderWithContext = (
ui: React.ReactElement,
streamingStateValue: StreamingState,
width = 120,
) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
const contextValue: StreamingState = streamingStateValue;
return render(
<StreamingContext.Provider value={contextValue}>
@@ -223,4 +232,65 @@ describe('<LoadingIndicator />', () => {
expect(output).toContain('This should be displayed');
expect(output).not.toContain('This should not be displayed');
});
describe('responsive layout', () => {
it('should render on a single line on a wide terminal', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator
{...defaultProps}
rightContent={<Text>Right</Text>}
/>,
StreamingState.Responding,
120,
);
const output = lastFrame();
// Check for single line output
expect(output?.includes('\n')).toBe(false);
expect(output).toContain('Loading...');
expect(output).toContain('(esc to cancel, 5s)');
expect(output).toContain('Right');
});
it('should render on multiple lines on a narrow terminal', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator
{...defaultProps}
rightContent={<Text>Right</Text>}
/>,
StreamingState.Responding,
79,
);
const output = lastFrame();
const lines = output?.split('\n');
// Expecting 3 lines:
// 1. Spinner + Primary Text
// 2. Cancel + Timer
// 3. Right Content
expect(lines).toHaveLength(3);
if (lines) {
expect(lines[0]).toContain('Loading...');
expect(lines[0]).not.toContain('(esc to cancel, 5s)');
expect(lines[1]).toContain('(esc to cancel, 5s)');
expect(lines[2]).toContain('Right');
}
});
it('should use wide layout at 80 columns', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
80,
);
expect(lastFrame()?.includes('\n')).toBe(false);
});
it('should use narrow layout at 79 columns', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
79,
);
expect(lastFrame()?.includes('\n')).toBe(true);
});
});
});

View File

@@ -12,6 +12,8 @@ import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
import { formatDuration } from '../utils/formatters.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
interface LoadingIndicatorProps {
currentLoadingPhrase?: string;
@@ -27,6 +29,8 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
thought,
}) => {
const streamingState = useStreamingContext();
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
if (streamingState === StreamingState.Idle) {
return null;
@@ -34,28 +38,45 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
const primaryText = thought?.subject || currentLoadingPhrase;
const cancelAndTimerContent =
streamingState !== StreamingState.WaitingForConfirmation
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
: null;
return (
<Box marginTop={1} paddingLeft={0} flexDirection="column">
<Box paddingLeft={0} flexDirection="column">
{/* Main loading line */}
<Box>
<Box marginRight={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={
streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: ''
}
/>
<Box
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box>
<Box marginRight={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={
streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: ''
}
/>
</Box>
{primaryText && (
<Text color={Colors.AccentPurple}>{primaryText}</Text>
)}
{!isNarrow && cancelAndTimerContent && (
<Text color={Colors.Gray}> {cancelAndTimerContent}</Text>
)}
</Box>
{primaryText && <Text color={Colors.AccentPurple}>{primaryText}</Text>}
<Text color={Colors.Gray}>
{streamingState === StreamingState.WaitingForConfirmation
? ''
: ` (esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`}
</Text>
<Box flexGrow={1}>{/* Spacer */}</Box>
{rightContent && <Box>{rightContent}</Box>}
{!isNarrow && <Box flexGrow={1}>{/* Spacer */}</Box>}
{!isNarrow && rightContent && <Box>{rightContent}</Box>}
</Box>
{isNarrow && cancelAndTimerContent && (
<Box>
<Text color={Colors.Gray}>{cancelAndTimerContent}</Text>
</Box>
)}
{isNarrow && rightContent && <Box>{rightContent}</Box>}
</Box>
);
};

View File

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

View File

@@ -0,0 +1,831 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
*
*
* This test suite covers:
* - Initial rendering and display state
* - Keyboard navigation (arrows, vim keys, Tab)
* - Settings toggling (Enter, Space)
* - Focus section switching between settings and scope selector
* - Scope selection and settings persistence across scopes
* - Restart-required vs immediate settings behavior
* - VimModeContext integration
* - Complex user interaction workflows
* - Error handling and edge cases
* - Display values for inherited and overridden settings
*
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SettingsDialog } from './SettingsDialog.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { VimModeProvider } from '../contexts/VimModeContext.js';
// Mock the VimModeContext
const mockToggleVimEnabled = vi.fn();
const mockSetVimMode = vi.fn();
vi.mock('../contexts/VimModeContext.js', async () => {
const actual = await vi.importActual('../contexts/VimModeContext.js');
return {
...actual,
useVimMode: () => ({
vimEnabled: false,
vimMode: 'INSERT' as const,
toggleVimEnabled: mockToggleVimEnabled,
setVimMode: mockSetVimMode,
}),
};
});
vi.mock('../../utils/settingsUtils.js', async () => {
const actual = await vi.importActual('../../utils/settingsUtils.js');
return {
...actual,
saveModifiedSettings: vi.fn(),
};
});
// Mock console.log to avoid noise in tests
const originalConsoleLog = console.log;
const originalConsoleError = console.error;
describe('SettingsDialog', () => {
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
beforeEach(() => {
vi.clearAllMocks();
console.log = vi.fn();
console.error = vi.fn();
mockToggleVimEnabled.mockResolvedValue(true);
});
afterEach(() => {
console.log = originalConsoleLog;
console.error = originalConsoleError;
});
const createMockSettings = (
userSettings = {},
systemSettings = {},
workspaceSettings = {},
) =>
new LoadedSettings(
{
settings: { customThemes: {}, mcpServers: {}, ...systemSettings },
path: '/system/settings.json',
},
{
settings: {
customThemes: {},
mcpServers: {},
...userSettings,
},
path: '/user/settings.json',
},
{
settings: { customThemes: {}, mcpServers: {}, ...workspaceSettings },
path: '/workspace/settings.json',
},
[],
);
describe('Initial Rendering', () => {
it('should render the settings dialog with default state', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
const output = lastFrame();
expect(output).toContain('Settings');
expect(output).toContain('Apply To');
expect(output).toContain('Use Enter to select, Tab to change focus');
});
it('should show settings list with default values', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
const output = lastFrame();
// Should show some default settings
expect(output).toContain('●'); // Active indicator
});
it('should highlight first setting by default', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
const output = lastFrame();
// First item should be highlighted with green color and active indicator
expect(output).toContain('●');
});
});
describe('Settings Navigation', () => {
it('should navigate down with arrow key', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Press down arrow
stdin.write('\u001B[B'); // Down arrow
await wait();
// The active index should have changed (tested indirectly through behavior)
unmount();
});
it('should navigate up with arrow key', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// First go down, then up
stdin.write('\u001B[B'); // Down arrow
await wait();
stdin.write('\u001B[A'); // Up arrow
await wait();
unmount();
});
it('should navigate with vim keys (j/k)', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Navigate with vim keys
stdin.write('j'); // Down
await wait();
stdin.write('k'); // Up
await wait();
unmount();
});
it('should not navigate beyond bounds', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Try to go up from first item
stdin.write('\u001B[A'); // Up arrow
await wait();
// Should still be on first item
unmount();
});
});
describe('Settings Toggling', () => {
it('should toggle setting with Enter key', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Press Enter to toggle current setting
stdin.write('\u000D'); // Enter key
await wait();
unmount();
});
it('should toggle setting with Space key', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Press Space to toggle current setting
stdin.write(' '); // Space key
await wait();
unmount();
});
it('should handle vim mode setting specially', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Navigate to vim mode setting and toggle it
// This would require knowing the exact position, so we'll just test that the mock is called
stdin.write('\u000D'); // Enter key
await wait();
// The mock should potentially be called if vim mode was toggled
unmount();
});
});
describe('Scope Selection', () => {
it('should switch between scopes', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Switch to scope focus
stdin.write('\t'); // Tab key
await wait();
// Select different scope (numbers 1-3 typically available)
stdin.write('2'); // Select second scope option
await wait();
unmount();
});
it('should reset to settings focus when scope is selected', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame, stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Switch to scope focus
stdin.write('\t'); // Tab key
await wait();
expect(lastFrame()).toContain('> Apply To');
// Select a scope
stdin.write('1'); // Select first scope option
await wait();
// Should be back to settings focus
expect(lastFrame()).toContain(' Apply To');
unmount();
});
});
describe('Restart Prompt', () => {
it('should show restart prompt for restart-required settings', async () => {
const settings = createMockSettings();
const onRestartRequest = vi.fn();
const { unmount } = render(
<SettingsDialog
settings={settings}
onSelect={() => {}}
onRestartRequest={onRestartRequest}
/>,
);
// This test would need to trigger a restart-required setting change
// The exact steps depend on which settings require restart
await wait();
unmount();
});
it('should handle restart request when r is pressed', async () => {
const settings = createMockSettings();
const onRestartRequest = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog
settings={settings}
onSelect={() => {}}
onRestartRequest={onRestartRequest}
/>,
);
// Press 'r' key (this would only work if restart prompt is showing)
stdin.write('r');
await wait();
// If restart prompt was showing, onRestartRequest should be called
unmount();
});
});
describe('Escape Key Behavior', () => {
it('should call onSelect with undefined when Escape is pressed', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Press Escape key
stdin.write('\u001B'); // ESC key
await wait();
expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
unmount();
});
});
describe('Settings Persistence', () => {
it('should persist settings across scope changes', async () => {
const settings = createMockSettings({ vimMode: true });
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Switch to scope selector
stdin.write('\t'); // Tab
await wait();
// Change scope
stdin.write('2'); // Select workspace scope
await wait();
// Settings should be reloaded for new scope
unmount();
});
it('should show different values for different scopes', () => {
const settings = createMockSettings(
{ vimMode: true }, // User settings
{ vimMode: false }, // System settings
{ autoUpdate: false }, // Workspace settings
);
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Should show user scope values initially
const output = lastFrame();
expect(output).toContain('Settings');
});
});
describe('Error Handling', () => {
it('should handle vim mode toggle errors gracefully', async () => {
mockToggleVimEnabled.mockRejectedValue(new Error('Toggle failed'));
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Try to toggle a setting (this might trigger vim mode toggle)
stdin.write('\u000D'); // Enter
await wait();
// Should not crash
unmount();
});
});
describe('Complex State Management', () => {
it('should track modified settings correctly', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Toggle a setting
stdin.write('\u000D'); // Enter
await wait();
// Toggle another setting
stdin.write('\u001B[B'); // Down
await wait();
stdin.write('\u000D'); // Enter
await wait();
// Should track multiple modified settings
unmount();
});
it('should handle scrolling when there are many settings', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Navigate down many times to test scrolling
for (let i = 0; i < 10; i++) {
stdin.write('\u001B[B'); // Down arrow
await wait(10);
}
unmount();
});
});
describe('VimMode Integration', () => {
it('should sync with VimModeContext when vim mode is toggled', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<VimModeProvider settings={settings}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</VimModeProvider>,
);
// Navigate to and toggle vim mode setting
// This would require knowing the exact position of vim mode setting
stdin.write('\u000D'); // Enter
await wait();
unmount();
});
});
describe('Specific Settings Behavior', () => {
it('should show correct display values for settings with different states', () => {
const settings = createMockSettings(
{ vimMode: true, hideTips: false }, // User settings
{ hideWindowTitle: true }, // System settings
{ ideMode: false }, // Workspace settings
);
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
const output = lastFrame();
// Should contain settings labels
expect(output).toContain('Settings');
});
it('should handle immediate settings save for non-restart-required settings', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Toggle a non-restart-required setting (like hideTips)
stdin.write('\u000D'); // Enter - toggle current setting
await wait();
// Should save immediately without showing restart prompt
unmount();
});
it('should show restart prompt for restart-required settings', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// This test would need to navigate to a specific restart-required setting
// Since we can't easily target specific settings, we test the general behavior
await wait();
// Should not show restart prompt initially
expect(lastFrame()).not.toContain(
'To see changes, Gemini CLI must be restarted',
);
unmount();
});
it('should clear restart prompt when switching scopes', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Restart prompt should be cleared when switching scopes
unmount();
});
});
describe('Settings Display Values', () => {
it('should show correct values for inherited settings', () => {
const settings = createMockSettings(
{}, // No user settings
{ vimMode: true, hideWindowTitle: false }, // System settings
{}, // No workspace settings
);
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
const output = lastFrame();
// Settings should show inherited values
expect(output).toContain('Settings');
});
it('should show override indicator for overridden settings', () => {
const settings = createMockSettings(
{ vimMode: false }, // User overrides
{ vimMode: true }, // System default
{}, // No workspace settings
);
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
const output = lastFrame();
// Should show settings with override indicators
expect(output).toContain('Settings');
});
});
describe('Keyboard Shortcuts Edge Cases', () => {
it('should handle rapid key presses gracefully', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Rapid navigation
for (let i = 0; i < 5; i++) {
stdin.write('\u001B[B'); // Down arrow
stdin.write('\u001B[A'); // Up arrow
}
await wait(100);
// Should not crash
unmount();
});
it('should handle Ctrl+C to reset current setting to default', async () => {
const settings = createMockSettings({ vimMode: true }); // Start with vimMode enabled
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Press Ctrl+C to reset current setting to default
stdin.write('\u0003'); // Ctrl+C
await wait();
// Should reset the current setting to its default value
unmount();
});
it('should handle Ctrl+L to reset current setting to default', async () => {
const settings = createMockSettings({ vimMode: true }); // Start with vimMode enabled
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Press Ctrl+L to reset current setting to default
stdin.write('\u000C'); // Ctrl+L
await wait();
// Should reset the current setting to its default value
unmount();
});
it('should handle navigation when only one setting exists', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Try to navigate when potentially at bounds
stdin.write('\u001B[B'); // Down
await wait();
stdin.write('\u001B[A'); // Up
await wait();
unmount();
});
it('should properly handle Tab navigation between sections', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { lastFrame, stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Start in settings section
expect(lastFrame()).toContain(' Apply To');
// Tab to scope section
stdin.write('\t');
await wait();
expect(lastFrame()).toContain('> Apply To');
// Tab back to settings section
stdin.write('\t');
await wait();
expect(lastFrame()).toContain(' Apply To');
unmount();
});
});
describe('Error Recovery', () => {
it('should handle malformed settings gracefully', () => {
// Create settings with potentially problematic values
const settings = createMockSettings(
{ vimMode: null as unknown as boolean }, // Invalid value
{},
{},
);
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Should still render without crashing
expect(lastFrame()).toContain('Settings');
});
it('should handle missing setting definitions gracefully', () => {
const settings = createMockSettings();
const onSelect = vi.fn();
// Should not crash even if some settings are missing definitions
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
expect(lastFrame()).toContain('Settings');
});
});
describe('Complex User Interactions', () => {
it('should handle complete user workflow: navigate, toggle, change scope, exit', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Navigate down a few settings
stdin.write('\u001B[B'); // Down
await wait();
stdin.write('\u001B[B'); // Down
await wait();
// Toggle a setting
stdin.write('\u000D'); // Enter
await wait();
// Switch to scope selector
stdin.write('\t'); // Tab
await wait();
// Change scope
stdin.write('2'); // Select workspace
await wait();
// Go back to settings
stdin.write('\t'); // Tab
await wait();
// Navigate and toggle another setting
stdin.write('\u001B[B'); // Down
await wait();
stdin.write(' '); // Space to toggle
await wait();
// Exit
stdin.write('\u001B'); // Escape
await wait();
expect(onSelect).toHaveBeenCalledWith(undefined, expect.any(String));
unmount();
});
it('should allow changing multiple settings without losing pending changes', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Toggle first setting (should require restart)
stdin.write('\u000D'); // Enter
await wait();
// Navigate to next setting and toggle it (should not require restart - e.g., vimMode)
stdin.write('\u001B[B'); // Down
await wait();
stdin.write('\u000D'); // Enter
await wait();
// Navigate to another setting and toggle it (should also require restart)
stdin.write('\u001B[B'); // Down
await wait();
stdin.write('\u000D'); // Enter
await wait();
// The test verifies that all changes are preserved and the dialog still works
// This tests the fix for the bug where changing one setting would reset all pending changes
unmount();
});
it('should maintain state consistency during complex interactions', async () => {
const settings = createMockSettings({ vimMode: true });
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
);
// Multiple scope changes
stdin.write('\t'); // Tab to scope
await wait();
stdin.write('2'); // Workspace
await wait();
stdin.write('\t'); // Tab to settings
await wait();
stdin.write('\t'); // Tab to scope
await wait();
stdin.write('1'); // User
await wait();
// Should maintain consistent state
unmount();
});
it('should handle restart workflow correctly', async () => {
const settings = createMockSettings();
const onRestartRequest = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog
settings={settings}
onSelect={() => {}}
onRestartRequest={onRestartRequest}
/>,
);
// This would test the restart workflow if we could trigger it
stdin.write('r'); // Try restart key
await wait();
// Without restart prompt showing, this should have no effect
expect(onRestartRequest).not.toHaveBeenCalled();
unmount();
});
});
});

View File

@@ -0,0 +1,465 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink';
import { Colors } from '../colors.js';
import {
LoadedSettings,
SettingScope,
Settings,
} from '../../config/settings.js';
import {
getScopeItems,
getScopeMessageForSetting,
} from '../../utils/dialogScopeUtils.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import {
getDialogSettingKeys,
getSettingValue,
setPendingSettingValue,
getDisplayValue,
hasRestartRequiredSettings,
saveModifiedSettings,
getSettingDefinition,
isDefaultValue,
requiresRestart,
getRestartRequiredFromModified,
getDefaultValue,
} from '../../utils/settingsUtils.js';
import { useVimMode } from '../contexts/VimModeContext.js';
interface SettingsDialogProps {
settings: LoadedSettings;
onSelect: (settingName: string | undefined, scope: SettingScope) => void;
onRestartRequest?: () => void;
}
const maxItemsToShow = 8;
export function SettingsDialog({
settings,
onSelect,
onRestartRequest,
}: SettingsDialogProps): React.JSX.Element {
// Get vim mode context to sync vim mode changes
const { vimEnabled, toggleVimEnabled } = useVimMode();
// Focus state: 'settings' or 'scope'
const [focusSection, setFocusSection] = useState<'settings' | 'scope'>(
'settings',
);
// Scope selector state (User by default)
const [selectedScope, setSelectedScope] = useState<SettingScope>(
SettingScope.User,
);
// Active indices
const [activeSettingIndex, setActiveSettingIndex] = useState(0);
// Scroll offset for settings
const [scrollOffset, setScrollOffset] = useState(0);
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
// Local pending settings state for the selected scope
const [pendingSettings, setPendingSettings] = useState<Settings>(() =>
// Deep clone to avoid mutation
structuredClone(settings.forScope(selectedScope).settings),
);
// Track which settings have been modified by the user
const [modifiedSettings, setModifiedSettings] = useState<Set<string>>(
new Set(),
);
// Track the intended values for modified settings
const [modifiedValues, setModifiedValues] = useState<Map<string, boolean>>(
new Map(),
);
// Track restart-required settings across scope changes
const [restartRequiredSettings, setRestartRequiredSettings] = useState<
Set<string>
>(new Set());
useEffect(() => {
setPendingSettings(
structuredClone(settings.forScope(selectedScope).settings),
);
// Don't reset modifiedSettings when scope changes - preserve user's pending changes
if (restartRequiredSettings.size === 0) {
setShowRestartPrompt(false);
}
}, [selectedScope, settings, restartRequiredSettings]);
// Preserve pending changes when scope changes
useEffect(() => {
if (modifiedSettings.size > 0) {
setPendingSettings((prevPending) => {
let updatedPending = { ...prevPending };
// Reapply all modified settings to the new pending settings using stored values
modifiedSettings.forEach((key) => {
const storedValue = modifiedValues.get(key);
if (storedValue !== undefined) {
updatedPending = setPendingSettingValue(
key,
storedValue,
updatedPending,
);
}
});
return updatedPending;
});
}
}, [selectedScope, modifiedSettings, modifiedValues, settings]);
const generateSettingsItems = () => {
const settingKeys = getDialogSettingKeys();
return settingKeys.map((key: string) => {
const currentValue = getSettingValue(key, pendingSettings, {});
const definition = getSettingDefinition(key);
return {
label: definition?.label || key,
value: key,
checked: currentValue,
toggle: () => {
const newValue = !currentValue;
setPendingSettings((prev) =>
setPendingSettingValue(key, newValue, prev),
);
if (!requiresRestart(key)) {
const immediateSettings = new Set([key]);
const immediateSettingsObject = setPendingSettingValue(
key,
newValue,
{},
);
console.log(
`[DEBUG SettingsDialog] Saving ${key} immediately with value:`,
newValue,
);
saveModifiedSettings(
immediateSettings,
immediateSettingsObject,
settings,
selectedScope,
);
// Special handling for vim mode to sync with VimModeContext
if (key === 'vimMode' && newValue !== vimEnabled) {
// Call toggleVimEnabled to sync the VimModeContext local state
toggleVimEnabled().catch((error) => {
console.error('Failed to toggle vim mode:', error);
});
}
// Capture the current modified settings before updating state
const currentModifiedSettings = new Set(modifiedSettings);
// Remove the saved setting from modifiedSettings since it's now saved
setModifiedSettings((prev) => {
const updated = new Set(prev);
updated.delete(key);
return updated;
});
// Remove from modifiedValues as well
setModifiedValues((prev) => {
const updated = new Map(prev);
updated.delete(key);
return updated;
});
// Also remove from restart-required settings if it was there
setRestartRequiredSettings((prev) => {
const updated = new Set(prev);
updated.delete(key);
return updated;
});
setPendingSettings((_prevPending) => {
let updatedPending = structuredClone(
settings.forScope(selectedScope).settings,
);
currentModifiedSettings.forEach((modifiedKey) => {
if (modifiedKey !== key) {
const modifiedValue = modifiedValues.get(modifiedKey);
if (modifiedValue !== undefined) {
updatedPending = setPendingSettingValue(
modifiedKey,
modifiedValue,
updatedPending,
);
}
}
});
return updatedPending;
});
} else {
// For restart-required settings, store the actual value
setModifiedValues((prev) => {
const updated = new Map(prev);
updated.set(key, newValue);
return updated;
});
setModifiedSettings((prev) => {
const updated = new Set(prev).add(key);
const needsRestart = hasRestartRequiredSettings(updated);
console.log(
`[DEBUG SettingsDialog] Modified settings:`,
Array.from(updated),
'Needs restart:',
needsRestart,
);
if (needsRestart) {
setShowRestartPrompt(true);
setRestartRequiredSettings((prevRestart) =>
new Set(prevRestart).add(key),
);
}
return updated;
});
}
},
};
});
};
const items = generateSettingsItems();
// Scope selector items
const scopeItems = getScopeItems();
const handleScopeHighlight = (scope: SettingScope) => {
setSelectedScope(scope);
};
const handleScopeSelect = (scope: SettingScope) => {
handleScopeHighlight(scope);
setFocusSection('settings');
};
// Scroll logic for settings
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
// Always show arrows for consistent UI and to indicate circular navigation
const showScrollUp = true;
const showScrollDown = true;
useInput((input, key) => {
if (key.tab) {
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
}
if (focusSection === 'settings') {
if (key.upArrow || input === 'k') {
const newIndex =
activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1;
setActiveSettingIndex(newIndex);
// Adjust scroll offset for wrap-around
if (newIndex === items.length - 1) {
setScrollOffset(Math.max(0, items.length - maxItemsToShow));
} else if (newIndex < scrollOffset) {
setScrollOffset(newIndex);
}
} else if (key.downArrow || input === 'j') {
const newIndex =
activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0;
setActiveSettingIndex(newIndex);
// Adjust scroll offset for wrap-around
if (newIndex === 0) {
setScrollOffset(0);
} else if (newIndex >= scrollOffset + maxItemsToShow) {
setScrollOffset(newIndex - maxItemsToShow + 1);
}
} else if (key.return || input === ' ') {
items[activeSettingIndex]?.toggle();
} else if ((key.ctrl && input === 'c') || (key.ctrl && input === 'l')) {
// Ctrl+C or Ctrl+L: Clear current setting and reset to default
const currentSetting = items[activeSettingIndex];
if (currentSetting) {
const defaultValue = getDefaultValue(currentSetting.value);
// Ensure defaultValue is a boolean for setPendingSettingValue
const booleanDefaultValue =
typeof defaultValue === 'boolean' ? defaultValue : false;
// Update pending settings to default value
setPendingSettings((prev) =>
setPendingSettingValue(
currentSetting.value,
booleanDefaultValue,
prev,
),
);
// Remove from modified settings since it's now at default
setModifiedSettings((prev) => {
const updated = new Set(prev);
updated.delete(currentSetting.value);
return updated;
});
// Remove from restart-required settings if it was there
setRestartRequiredSettings((prev) => {
const updated = new Set(prev);
updated.delete(currentSetting.value);
return updated;
});
// If this setting doesn't require restart, save it immediately
if (!requiresRestart(currentSetting.value)) {
const immediateSettings = new Set([currentSetting.value]);
const immediateSettingsObject = setPendingSettingValue(
currentSetting.value,
booleanDefaultValue,
{},
);
saveModifiedSettings(
immediateSettings,
immediateSettingsObject,
settings,
selectedScope,
);
}
}
}
}
if (showRestartPrompt && input === 'r') {
// Only save settings that require restart (non-restart settings were already saved immediately)
const restartRequiredSettings =
getRestartRequiredFromModified(modifiedSettings);
const restartRequiredSet = new Set(restartRequiredSettings);
if (restartRequiredSet.size > 0) {
saveModifiedSettings(
restartRequiredSet,
pendingSettings,
settings,
selectedScope,
);
}
setShowRestartPrompt(false);
setRestartRequiredSettings(new Set()); // Clear restart-required settings
if (onRestartRequest) onRestartRequest();
}
if (key.escape) {
onSelect(undefined, selectedScope);
}
});
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="row"
padding={1}
width="100%"
height="100%"
>
<Box flexDirection="column" flexGrow={1}>
<Text bold color={Colors.AccentBlue}>
Settings
</Text>
<Box height={1} />
{showScrollUp && <Text color={Colors.Gray}></Text>}
{visibleItems.map((item, idx) => {
const isActive =
focusSection === 'settings' &&
activeSettingIndex === idx + scrollOffset;
const scopeSettings = settings.forScope(selectedScope).settings;
const mergedSettings = settings.merged;
const displayValue = getDisplayValue(
item.value,
scopeSettings,
mergedSettings,
modifiedSettings,
pendingSettings,
);
const shouldBeGreyedOut = isDefaultValue(item.value, scopeSettings);
// Generate scope message for this setting
const scopeMessage = getScopeMessageForSetting(
item.value,
selectedScope,
settings,
);
return (
<React.Fragment key={item.value}>
<Box flexDirection="row" alignItems="center">
<Box minWidth={2} flexShrink={0}>
<Text color={isActive ? Colors.AccentGreen : Colors.Gray}>
{isActive ? '●' : ''}
</Text>
</Box>
<Box minWidth={50}>
<Text
color={isActive ? Colors.AccentGreen : Colors.Foreground}
>
{item.label}
{scopeMessage && (
<Text color={Colors.Gray}> {scopeMessage}</Text>
)}
</Text>
</Box>
<Box minWidth={3} />
<Text
color={
isActive
? Colors.AccentGreen
: shouldBeGreyedOut
? Colors.Gray
: Colors.Foreground
}
>
{displayValue}
</Text>
</Box>
<Box height={1} />
</React.Fragment>
);
})}
{showScrollDown && <Text color={Colors.Gray}></Text>}
<Box height={1} />
<Box marginTop={1} flexDirection="column">
<Text bold={focusSection === 'scope'} wrap="truncate">
{focusSection === 'scope' ? '> ' : ' '}Apply To
</Text>
<RadioButtonSelect
items={scopeItems}
initialIndex={0}
onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight}
isFocused={focusSection === 'scope'}
showNumbers={focusSection === 'scope'}
/>
</Box>
<Box height={1} />
<Text color={Colors.Gray}>
(Use Enter to select, Tab to change focus)
</Text>
{showRestartPrompt && (
<Text color={Colors.AccentYellow}>
To see changes, Gemini CLI must be restarted. Press r to exit and
apply changes now.
</Text>
)}
</Box>
</Box>
);
}

View File

@@ -82,7 +82,7 @@ export function SuggestionsDisplay({
)}
{suggestion.description ? (
<Box flexGrow={1}>
<Text color={textColor} wrap="wrap">
<Text color={textColor} wrap="truncate">
{suggestion.description}
</Text>
</Box>

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