diff --git a/packages/cli/src/commands/extensions/examples/custom-commands/.gitignore b/packages/cli/src/commands/extensions/examples/custom-commands/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..b2a70da46e5515d38fc6b7551ac76b3b46c0e5ae --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/custom-commands/.gitignore @@ -0,0 +1,26 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-error.log +yarn-debug.log + +# Build output +dist/ + +# OS metadata +.DS_Store +Thumbs.db + +# TypeScript +*.tsbuildinfo + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDEs +.vscode/ +.idea/ diff --git a/packages/cli/src/commands/extensions/examples/custom-commands/commands/fs/grep-code.toml b/packages/cli/src/commands/extensions/examples/custom-commands/commands/fs/grep-code.toml new file mode 100644 index 0000000000000000000000000000000000000000..87d957542ad58c478bfb0e76659bfd0741afa53f --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/custom-commands/commands/fs/grep-code.toml @@ -0,0 +1,6 @@ +prompt = """ +Please summarize the findings for the pattern `{{args}}`. + +Search Results: +!{grep -r {{args}} .} +""" diff --git a/packages/cli/src/commands/extensions/examples/custom-commands/gemini-extension.json b/packages/cli/src/commands/extensions/examples/custom-commands/gemini-extension.json new file mode 100644 index 0000000000000000000000000000000000000000..d973ab8fe44973ae0c1c3fff342fe9ee8764d86b --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/custom-commands/gemini-extension.json @@ -0,0 +1,4 @@ +{ + "name": "custom-commands", + "version": "1.0.0" +} diff --git a/packages/cli/src/commands/extensions/examples/hooks/.gitignore b/packages/cli/src/commands/extensions/examples/hooks/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..b2a70da46e5515d38fc6b7551ac76b3b46c0e5ae --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/hooks/.gitignore @@ -0,0 +1,26 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-error.log +yarn-debug.log + +# Build output +dist/ + +# OS metadata +.DS_Store +Thumbs.db + +# TypeScript +*.tsbuildinfo + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDEs +.vscode/ +.idea/ diff --git a/packages/cli/src/commands/extensions/examples/hooks/gemini-extension.json b/packages/cli/src/commands/extensions/examples/hooks/gemini-extension.json new file mode 100644 index 0000000000000000000000000000000000000000..708e986346122baff7fb38792ae2d73843732628 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/hooks/gemini-extension.json @@ -0,0 +1,4 @@ +{ + "name": "hooks-example", + "version": "1.0.0" +} diff --git a/packages/cli/src/commands/extensions/examples/hooks/hooks/hooks.json b/packages/cli/src/commands/extensions/examples/hooks/hooks/hooks.json new file mode 100644 index 0000000000000000000000000000000000000000..f1af86d98002af326e3752b7386d79e93b5eab56 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/hooks/hooks/hooks.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node ${extensionPath}/scripts/on-start.js" + } + ] + } + ] + } +} diff --git a/packages/cli/src/commands/extensions/examples/hooks/scripts/on-start.js b/packages/cli/src/commands/extensions/examples/hooks/scripts/on-start.js new file mode 100644 index 0000000000000000000000000000000000000000..1f426f9a2fcaf728e848aceba1b70be5ff78c56f --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/hooks/scripts/on-start.js @@ -0,0 +1,8 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +console.log( + 'Session Started! This is running from a script in the hooks-example extension.', +); diff --git a/packages/cli/src/commands/extensions/examples/mcp-server/example.js b/packages/cli/src/commands/extensions/examples/mcp-server/example.js new file mode 100644 index 0000000000000000000000000000000000000000..21e01e17cb3eabc705047d8de9c0de2aee866add --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/mcp-server/example.js @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; + +const server = new McpServer({ + name: 'prompt-server', + version: '1.0.0', +}); + +server.registerTool( + 'fetch_posts', + { + description: 'Fetches a list of posts from a public API.', + inputSchema: z.object({}).shape, + }, + async () => { + const apiResponse = await fetch( + 'https://jsonplaceholder.typicode.com/posts', + ); + const posts = await apiResponse.json(); + const response = { posts: posts.slice(0, 5) }; + return { + content: [ + { + type: 'text', + text: JSON.stringify(response), + }, + ], + }; + }, +); + +server.registerPrompt( + 'poem-writer', + { + title: 'Poem Writer', + description: 'Write a nice haiku', + argsSchema: { title: z.string(), mood: z.string().optional() }, + }, + ({ title, mood }) => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Write a haiku${mood ? ` with the mood ${mood}` : ''} called ${title}. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables `, + }, + }, + ], + }), +); + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/packages/cli/src/commands/extensions/examples/mcp-server/package.json b/packages/cli/src/commands/extensions/examples/mcp-server/package.json new file mode 100644 index 0000000000000000000000000000000000000000..5200439041f939bbce433acc7d8538dca2ad1a02 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/mcp-server/package.json @@ -0,0 +1,11 @@ +{ + "name": "mcp-server-example", + "version": "1.0.0", + "description": "Example MCP Server for Gemini CLI Extension", + "type": "module", + "main": "example.js", + "dependencies": { + "@modelcontextprotocol/sdk": "1.23.0", + "zod": "3.22.4" + } +} diff --git a/packages/cli/src/commands/extensions/examples/policies/README.md b/packages/cli/src/commands/extensions/examples/policies/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d1c06de6e3b320531e96b321a27f053951a172e5 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/policies/README.md @@ -0,0 +1,41 @@ +# Policy engine example extension + +This extension demonstrates how to contribute security rules and safety checkers +to the Gemini CLI Policy Engine. + +## Description + +The extension uses a `policies/` directory containing `.toml` files to define: + +- A rule that requires user confirmation for `rm -rf` commands. +- A rule that denies searching for sensitive files (like `.env`) using `grep`. +- A safety checker that validates file paths for all write operations. + +## Structure + +- `gemini-extension.json`: The manifest file. +- `policies/`: Contains the `.toml` policy files. + +## How to use + +1. Link this extension to your local Gemini CLI installation: + + ```bash + gemini extensions link packages/cli/src/commands/extensions/examples/policies + ``` + +2. Restart your Gemini CLI session. + +3. **Observe the policies:** + - Try asking the model to delete a directory: The policy engine will prompt + you for confirmation due to the `rm -rf` rule. + - Try asking the model to search for secrets: The `grep` rule will deny the + request and display the custom deny message. + - Any file write operation will now be processed through the `allowed-path` + safety checker. + +## Security note + +For security, Gemini CLI ignores any `allow` decisions or `yolo` mode +configurations contributed by extensions. This ensures that extensions can +strengthen security but cannot bypass user confirmation. diff --git a/packages/cli/src/commands/extensions/examples/policies/gemini-extension.json b/packages/cli/src/commands/extensions/examples/policies/gemini-extension.json new file mode 100644 index 0000000000000000000000000000000000000000..2a2b99253235e4995494577b569ec214adf68699 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/policies/gemini-extension.json @@ -0,0 +1,5 @@ +{ + "name": "policy-example", + "version": "1.0.0", + "description": "An example extension demonstrating Policy Engine support." +} diff --git a/packages/cli/src/commands/extensions/examples/policies/policies/policies.toml b/packages/cli/src/commands/extensions/examples/policies/policies/policies.toml new file mode 100644 index 0000000000000000000000000000000000000000..225627c59b1ad0ffd05bcb0ffefe6ed6bc3d7063 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/policies/policies/policies.toml @@ -0,0 +1,28 @@ +# Example Policy Rules for Gemini CLI Extension +# +# Extensions run in Tier 2 (Extension Tier). +# Security Note: 'allow' decisions and 'yolo' mode configurations are ignored. + +# Rule: Always ask the user before running a specific dangerous shell command. +[[rule]] +toolName = "run_shell_command" +commandPrefix = "rm -rf" +decision = "ask_user" +priority = 100 + +# Rule: Deny access to sensitive files using the grep tool. +[[rule]] +toolName = "grep_search" +argsPattern = "(\.env|id_rsa|passwd)" +decision = "deny" +priority = 200 +denyMessage = "Access to sensitive credentials or system files is restricted by the policy-example extension." + +# Safety Checker: Apply path validation to all write operations. +[[safety_checker]] +toolName = ["write_file", "replace"] +priority = 300 +[safety_checker.checker] +type = "in-process" +name = "allowed-path" +required_context = ["environment"] diff --git a/packages/cli/src/commands/extensions/examples/skills/.gitignore b/packages/cli/src/commands/extensions/examples/skills/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..b2a70da46e5515d38fc6b7551ac76b3b46c0e5ae --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/skills/.gitignore @@ -0,0 +1,26 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-error.log +yarn-debug.log + +# Build output +dist/ + +# OS metadata +.DS_Store +Thumbs.db + +# TypeScript +*.tsbuildinfo + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDEs +.vscode/ +.idea/ diff --git a/packages/cli/src/commands/extensions/examples/skills/gemini-extension.json b/packages/cli/src/commands/extensions/examples/skills/gemini-extension.json new file mode 100644 index 0000000000000000000000000000000000000000..2674ef9e0f4081ba3ca282dde6849b092008541d --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/skills/gemini-extension.json @@ -0,0 +1,4 @@ +{ + "name": "skills-example", + "version": "1.0.0" +} diff --git a/packages/cli/src/commands/extensions/examples/skills/skills/greeter/SKILL.md b/packages/cli/src/commands/extensions/examples/skills/skills/greeter/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..24da110909044d1f40c8b60ff0f304c086a625f8 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/skills/skills/greeter/SKILL.md @@ -0,0 +1,7 @@ +--- +name: greeter +description: A friendly greeter skill +--- + +You are a friendly greeter. When the user says "hello" or asks for a greeting, +you should reply with: "Greetings from the skills-example extension! 👋" diff --git a/packages/cli/src/commands/extensions/examples/themes-example/README.md b/packages/cli/src/commands/extensions/examples/themes-example/README.md new file mode 100644 index 0000000000000000000000000000000000000000..aaac7f6c38a9b5cb56584e472977d47161d3e9b6 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/themes-example/README.md @@ -0,0 +1,31 @@ +# Themes Example + +This is an example of a Gemini CLI extension that adds a custom theme. + +## How to use + +1. Link this extension: + + ```bash + gemini extensions link packages/cli/src/commands/extensions/examples/themes-example + ``` + +2. Set the theme in your settings file (`~/.gemini/settings.json`): + + ```json + { + "ui": { + "theme": "shades-of-green (themes-example)" + } + } + ``` + + Alternatively, you can set it through the UI by running `gemini` and then + typing `/theme` and pressing Enter. + +3. **Observe the Changes:** + + After setting the theme, you should see the changes reflected in the Gemini + CLI's UI. The background will be a dark green, the primary text a lighter + green, and various other UI elements will display different shades of green, + as defined in this extension's `gemini-extension.json` file. diff --git a/packages/cli/src/commands/extensions/examples/themes-example/gemini-extension.json b/packages/cli/src/commands/extensions/examples/themes-example/gemini-extension.json new file mode 100644 index 0000000000000000000000000000000000000000..a526d57bf8de51c2f18049e6a70742198159e0e5 --- /dev/null +++ b/packages/cli/src/commands/extensions/examples/themes-example/gemini-extension.json @@ -0,0 +1,29 @@ +{ + "name": "themes-example", + "version": "1.0.0", + "themes": [ + { + "name": "shades-of-green", + "type": "custom", + "background": { + "primary": "#1a362a" + }, + "text": { + "primary": "#a6e3a1", + "secondary": "#6e8e7a", + "link": "#89e689" + }, + "status": { + "success": "#76c076", + "warning": "#d9e689", + "error": "#b34e4e" + }, + "border": { + "default": "#4a6c5a" + }, + "ui": { + "comment": "#6e8e7a" + } + } + ] +} diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-generate-a-consent-string-with-all-fields.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-generate-a-consent-string-with-all-fields.snap.svg new file mode 100644 index 0000000000000000000000000000000000000000..018da97b3ec11b730c75dd9f7a75875478b1efa1 --- /dev/null +++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-generate-a-consent-string-with-all-fields.snap.svg @@ -0,0 +1,20 @@ + + + + + Installing extension "test-ext". + This extension will run the following MCP servers: + * server1 (local): npm start + Config signature: 4fec6e8152e1eb7aa1a25665dde7964a218c1b60f801587e3c800f0d7fe5941a + * server2 (remote): https://remote.com + Config signature: 69391bfd3f62b97f9c7e944e9597ec5ba075838f8e29738f8adf0ba3eed35961 + This extension will append info to your gemini.md context using my-context.md + This extension will exclude the following core tools: tool1,tool2 + The extension you are about to install may have been created by a third-party developer and sourced + from a public repository. Google does not vet, endorse, or guarantee the functionality or security + of extensions. Please carefully inspect any extension and its source code before installing to + understand the permissions it requires and the actions it may perform. + + \ No newline at end of file diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-include-warning-when-hooks-are-present.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-include-warning-when-hooks-are-present.snap.svg new file mode 100644 index 0000000000000000000000000000000000000000..9f4866dbdd679e1da8ade79c01c0f4a2755596cb --- /dev/null +++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-include-warning-when-hooks-are-present.snap.svg @@ -0,0 +1,14 @@ + + + + + Installing extension "test-ext". + ⚠️ This extension contains Hooks which can automatically execute commands. + The extension you are about to install may have been created by a third-party developer and sourced + from a public repository. Google does not vet, endorse, or guarantee the functionality or security + of extensions. Please carefully inspect any extension and its source code before installing to + understand the permissions it requires and the actions it may perform. + + \ No newline at end of file diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-extension-is-migrated.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-extension-is-migrated.snap.svg new file mode 100644 index 0000000000000000000000000000000000000000..34161f8eb07af2233eb710951c43be884ee762c8 --- /dev/null +++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-extension-is-migrated.snap.svg @@ -0,0 +1,13 @@ + + + + + Migrating extension "old-ext" to a new repository, renaming to "test-ext", and installing updates. + The extension you are about to install may have been created by a third-party developer and sourced + from a public repository. Google does not vet, endorse, or guarantee the functionality or security + of extensions. Please carefully inspect any extension and its source code before installing to + understand the permissions it requires and the actions it may perform. + + \ No newline at end of file diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-skills-change.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-skills-change.snap.svg new file mode 100644 index 0000000000000000000000000000000000000000..74c907ec9619c6cbe8de246bab493490db438c42 --- /dev/null +++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-skills-change.snap.svg @@ -0,0 +1,34 @@ + + + + + Installing extension "test-ext". + This extension will run the following MCP servers: + * server1 (local): npm start + Config signature: 4fec6e8152e1eb7aa1a25665dde7964a218c1b60f801587e3c800f0d7fe5941a + * server2 (remote): https://remote.com + Config signature: 69391bfd3f62b97f9c7e944e9597ec5ba075838f8e29738f8adf0ba3eed35961 + This extension will append info to your gemini.md context using my-context.md + This extension will exclude the following core tools: tool1,tool2 + Agent Skills: + This extension will install the following agent skills: + * + skill1 + : desc1 + (Source: /mock/temp/dir/skill1/SKILL.md) (2 items in directory) + * + skill2 + : desc2 + (Source: /mock/temp/dir/skill2/SKILL.md) (1 items in directory) + The extension you are about to install may have been created by a third-party developer and sourced + from a public repository. Google does not vet, endorse, or guarantee the functionality or security + of extensions. Please carefully inspect any extension and its source code before installing to + understand the permissions it requires and the actions it may perform. + Agent skills inject specialized instructions and domain-specific knowledge into the agent's system + prompt. This can change how the agent interprets your requests and interacts with your environment. + Review the skill definitions at the location(s) provided below to ensure they meet your security + standards. + + \ No newline at end of file diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-show-a-warning-if-the-skill-directory-cannot-be-read.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-show-a-warning-if-the-skill-directory-cannot-be-read.snap.svg new file mode 100644 index 0000000000000000000000000000000000000000..b57af41589daa2cd9d57a2b5dca20e6e93b028c1 --- /dev/null +++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-show-a-warning-if-the-skill-directory-cannot-be-read.snap.svg @@ -0,0 +1,24 @@ + + + + + Installing extension "test-ext". + Agent Skills: + This extension will install the following agent skills: + * + locked-skill + : A skill in a locked dir + (Source: /mock/temp/dir/locked/SKILL.md) + ⚠️ (Could not count items in directory) + The extension you are about to install may have been created by a third-party developer and sourced + from a public repository. Google does not vet, endorse, or guarantee the functionality or security + of extensions. Please carefully inspect any extension and its source code before installing to + understand the permissions it requires and the actions it may perform. + Agent skills inject specialized instructions and domain-specific knowledge into the agent's system + prompt. This can change how the agent interprets your requests and interacts with your environment. + Review the skill definitions at the location(s) provided below to ensure they meet your security + standards. + + \ No newline at end of file diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-skillsConsentString-should-generate-a-consent-string-for-skills.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-skillsConsentString-should-generate-a-consent-string-for-skills.snap.svg new file mode 100644 index 0000000000000000000000000000000000000000..32b9d8e0a319e3f52278d13b465748ab5032cf6e --- /dev/null +++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-skillsConsentString-should-generate-a-consent-string-for-skills.snap.svg @@ -0,0 +1,19 @@ + + + + + Installing agent skill(s) from "https://example.com/repo.git". + The following agent skill(s) will be installing: + * + skill1 + : desc1 + (Source: /mock/temp/dir/skill1/SKILL.md) (1 items in directory) + Install Destination: /mock/target/dir + Agent skills inject specialized instructions and domain-specific knowledge into the agent's system + prompt. This can change how the agent interprets your requests and interacts with your environment. + Review the skill definitions at the location(s) provided below to ensure they meet your security + standards. + + \ No newline at end of file diff --git a/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap b/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..3d7d3bceca490a1efbdcd397238cf1d1107eaecb --- /dev/null +++ b/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap @@ -0,0 +1,106 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`consent > maybeRequestConsentOrFail > consent string generation > should generate a consent string with all fields 1`] = ` +"Installing extension "test-ext". +This extension will run the following MCP servers: + * server1 (local): npm start + Config signature: 4fec6e8152e1eb7aa1a25665dde7964a218c1b60f801587e3c800f0d7fe5941a + * server2 (remote): https://remote.com + Config signature: 69391bfd3f62b97f9c7e944e9597ec5ba075838f8e29738f8adf0ba3eed35961 +This extension will append info to your gemini.md context using my-context.md +This extension will exclude the following core tools: tool1,tool2 + +The extension you are about to install may have been created by a third-party developer and sourced +from a public repository. Google does not vet, endorse, or guarantee the functionality or security +of extensions. Please carefully inspect any extension and its source code before installing to +understand the permissions it requires and the actions it may perform." +`; + +exports[`consent > maybeRequestConsentOrFail > consent string generation > should include warning when hooks are present 1`] = ` +"Installing extension "test-ext". +⚠️ This extension contains Hooks which can automatically execute commands. + +The extension you are about to install may have been created by a third-party developer and sourced +from a public repository. Google does not vet, endorse, or guarantee the functionality or security +of extensions. Please carefully inspect any extension and its source code before installing to +understand the permissions it requires and the actions it may perform." +`; + +exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if extension is migrated 1`] = ` +"Migrating extension "old-ext" to a new repository, renaming to "test-ext", and installing updates. + +The extension you are about to install may have been created by a third-party developer and sourced +from a public repository. Google does not vet, endorse, or guarantee the functionality or security +of extensions. Please carefully inspect any extension and its source code before installing to +understand the permissions it requires and the actions it may perform." +`; + +exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if skills change 1`] = ` +"Installing extension "test-ext". +This extension will run the following MCP servers: + * server1 (local): npm start + Config signature: 4fec6e8152e1eb7aa1a25665dde7964a218c1b60f801587e3c800f0d7fe5941a + * server2 (remote): https://remote.com + Config signature: 69391bfd3f62b97f9c7e944e9597ec5ba075838f8e29738f8adf0ba3eed35961 +This extension will append info to your gemini.md context using my-context.md +This extension will exclude the following core tools: tool1,tool2 + +Agent Skills: + +This extension will install the following agent skills: + + * skill1: desc1 + (Source: /mock/temp/dir/skill1/SKILL.md) (2 items in directory) + + * skill2: desc2 + (Source: /mock/temp/dir/skill2/SKILL.md) (1 items in directory) + + +The extension you are about to install may have been created by a third-party developer and sourced +from a public repository. Google does not vet, endorse, or guarantee the functionality or security +of extensions. Please carefully inspect any extension and its source code before installing to +understand the permissions it requires and the actions it may perform. + +Agent skills inject specialized instructions and domain-specific knowledge into the agent's system +prompt. This can change how the agent interprets your requests and interacts with your environment. +Review the skill definitions at the location(s) provided below to ensure they meet your security +standards." +`; + +exports[`consent > maybeRequestConsentOrFail > consent string generation > should show a warning if the skill directory cannot be read 1`] = ` +"Installing extension "test-ext". + +Agent Skills: + +This extension will install the following agent skills: + + * locked-skill: A skill in a locked dir + (Source: /mock/temp/dir/locked/SKILL.md) ⚠️ (Could not count items in directory) + + +The extension you are about to install may have been created by a third-party developer and sourced +from a public repository. Google does not vet, endorse, or guarantee the functionality or security +of extensions. Please carefully inspect any extension and its source code before installing to +understand the permissions it requires and the actions it may perform. + +Agent skills inject specialized instructions and domain-specific knowledge into the agent's system +prompt. This can change how the agent interprets your requests and interacts with your environment. +Review the skill definitions at the location(s) provided below to ensure they meet your security +standards." +`; + +exports[`consent > skillsConsentString > should generate a consent string for skills 1`] = ` +"Installing agent skill(s) from "https://example.com/repo.git". + +The following agent skill(s) will be installing: + + * skill1: desc1 + (Source: /mock/temp/dir/skill1/SKILL.md) (1 items in directory) + +Install Destination: /mock/target/dir + +Agent skills inject specialized instructions and domain-specific knowledge into the agent's system +prompt. This can change how the agent interprets your requests and interacts with your environment. +Review the skill definitions at the location(s) provided below to ensure they meet your security +standards." +`; diff --git a/packages/cli/src/config/extensions/consent.test.ts b/packages/cli/src/config/extensions/consent.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e3344d6c9086541e5f7f9cdb8b85360f4f396f98 --- /dev/null +++ b/packages/cli/src/config/extensions/consent.test.ts @@ -0,0 +1,563 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Text } from 'ink'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { render, cleanup } from '../../test-utils/render.js'; +import { + requestConsentNonInteractive, + requestConsentInteractive, + maybeRequestConsentOrFail, +} from './consent.js'; +import type { ConfirmationRequest } from '../../ui/types.js'; +import type { ExtensionConfig } from '../extension.js'; +import { debugLogger, type SkillDefinition } from '@google/gemini-cli-core'; + +const mockReadline = vi.hoisted(() => ({ + createInterface: vi.fn().mockReturnValue({ + question: vi.fn(), + close: vi.fn(), + }), +})); + +const mockReaddir = vi.hoisted(() => vi.fn()); +const originalReaddir = vi.hoisted(() => ({ + current: null as typeof fs.readdir | null, +})); + +// Mocking readline for non-interactive prompts +vi.mock('node:readline', () => ({ + default: mockReadline, + createInterface: mockReadline.createInterface, +})); + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + originalReaddir.current = actual.readdir; + return { + ...actual, + readdir: mockReaddir, + }; +}); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + debugLogger: { + log: vi.fn(), + }, + }; +}); + +async function expectConsentSnapshot(consentString: string) { + const renderResult = await render( + React.createElement(Text, null, consentString), + ); + await expect(renderResult).toMatchSvgSnapshot(); +} + +/** + * Normalizes a consent string for snapshot testing by: + * 1. Replacing the dynamic temp directory path with a static placeholder. + * 2. Converting Windows backslashes to forward slashes for platform-agnosticism. + */ +function normalizePathsForSnapshot(str: string, tempDir: string): string { + return str.replaceAll(tempDir, '/mock/temp/dir').replaceAll('\\', '/'); +} + +describe('consent', () => { + let tempDir: string; + + beforeEach(async () => { + vi.clearAllMocks(); + if (originalReaddir.current) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockReaddir.mockImplementation(originalReaddir.current as any); + } + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'consent-test-')); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + } + cleanup(); + }); + + describe('requestConsentNonInteractive', () => { + it.each([ + { input: 'y', expected: true }, + { input: 'Y', expected: true }, + { input: '', expected: true }, + { input: 'n', expected: false }, + { input: 'N', expected: false }, + { input: 'yes', expected: true }, + ])( + 'should return $expected for input "$input"', + async ({ input, expected }) => { + const questionMock = vi.fn().mockImplementation((_, callback) => { + callback(input); + }); + mockReadline.createInterface.mockReturnValue({ + question: questionMock, + close: vi.fn(), + }); + + const consent = await requestConsentNonInteractive('Test consent'); + expect(debugLogger.log).toHaveBeenCalledWith('Test consent'); + expect(questionMock).toHaveBeenCalledWith( + 'Do you want to continue? [Y/n]: ', + expect.any(Function), + ); + expect(consent).toBe(expected); + }, + ); + }); + + describe('requestConsentInteractive', () => { + it.each([ + { confirmed: true, expected: true }, + { confirmed: false, expected: false }, + ])( + 'should resolve with $expected when user confirms with $confirmed', + async ({ confirmed, expected }) => { + const addExtensionUpdateConfirmationRequest = vi + .fn() + .mockImplementation((request: ConfirmationRequest) => { + request.onConfirm(confirmed); + }); + + const consent = await requestConsentInteractive( + 'Test consent', + addExtensionUpdateConfirmationRequest, + ); + + expect(addExtensionUpdateConfirmationRequest).toHaveBeenCalledWith({ + prompt: 'Test consent\n\nDo you want to continue?', + onConfirm: expect.any(Function), + }); + expect(consent).toBe(expected); + }, + ); + + it('should clear the active confirmation request before resolving', async () => { + const clearConfirmationRequest = vi.fn(); + const steps: string[] = []; + const addExtensionUpdateConfirmationRequest = vi + .fn() + .mockImplementation((request: ConfirmationRequest) => { + steps.push('prompted'); + request.onConfirm(true); + steps.push('confirmed'); + }); + + const consentPromise = requestConsentInteractive( + 'Test consent', + addExtensionUpdateConfirmationRequest, + () => { + steps.push('cleared'); + clearConfirmationRequest(); + }, + ).then((consent) => { + steps.push('resolved'); + return consent; + }); + + expect(clearConfirmationRequest).toHaveBeenCalledTimes(1); + expect(steps).toEqual(['prompted', 'cleared', 'confirmed']); + await expect(consentPromise).resolves.toBe(true); + expect(steps).toEqual(['prompted', 'cleared', 'confirmed', 'resolved']); + }); + }); + + describe('maybeRequestConsentOrFail', () => { + const baseConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + }; + + it('should request consent if there is no previous config', async () => { + const requestConsent = vi.fn().mockResolvedValue(true); + await maybeRequestConsentOrFail( + baseConfig, + requestConsent, + false, + undefined, + ); + expect(requestConsent).toHaveBeenCalledTimes(1); + }); + + it('should not request consent if configs are identical', async () => { + const requestConsent = vi.fn().mockResolvedValue(true); + await maybeRequestConsentOrFail( + baseConfig, + requestConsent, + false, + baseConfig, + false, + ); + expect(requestConsent).not.toHaveBeenCalled(); + }); + + it('should throw an error if consent is denied', async () => { + const requestConsent = vi.fn().mockResolvedValue(false); + await expect( + maybeRequestConsentOrFail(baseConfig, requestConsent, false, undefined), + ).rejects.toThrow('Installation cancelled for "test-ext".'); + }); + + describe('consent string generation', () => { + it('should generate a consent string with all fields', async () => { + const config: ExtensionConfig = { + ...baseConfig, + mcpServers: { + server1: { command: 'npm', args: ['start'] }, + server2: { httpUrl: 'https://remote.com' }, + }, + contextFileName: 'my-context.md', + excludeTools: ['tool1', 'tool2'], + }; + const requestConsent = vi.fn().mockResolvedValue(true); + await maybeRequestConsentOrFail( + config, + requestConsent, + false, + undefined, + ); + + expect(requestConsent).toHaveBeenCalledTimes(1); + const consentString = requestConsent.mock.calls[0][0] as string; + await expectConsentSnapshot(consentString); + }); + + it('should sort environment variables alphabetically and handle undefined envValue', async () => { + const config: ExtensionConfig = { + ...baseConfig, + mcpServers: { + server1: { + command: 'node', + args: ['server.js'], + env: { + VAR_B: 'b', + VAR_A: 'a', + VAR_C: undefined as unknown as string, + }, + }, + }, + }; + const requestConsent = vi.fn().mockResolvedValue(true); + await maybeRequestConsentOrFail( + config, + requestConsent, + false, + undefined, + ); + + expect(requestConsent).toHaveBeenCalledTimes(1); + const consentString = requestConsent.mock.calls[0][0] as string; + expect(consentString).toContain(' - VAR_A: a'); + expect(consentString).toContain(' - VAR_B: b'); + expect(consentString).toContain(' - VAR_C: '); + + const posA = consentString.indexOf(' - VAR_A: a'); + const posB = consentString.indexOf(' - VAR_B: b'); + const posC = consentString.indexOf(' - VAR_C: '); + expect(posA).toBeLessThan(posB); + expect(posB).toBeLessThan(posC); + }); + + it('should mask sensitive environment variables, headers, and OAuth parameters', async () => { + const config: ExtensionConfig = { + ...baseConfig, + mcpServers: { + server1: { + command: 'node', + args: ['server.js'], + env: { + SAFE_VAR: 'safe-value', + SUPER_TOKEN: 'secret-token-123', + DB_PASSWORD: 'my-password', + }, + headers: { + 'X-API-Key': 'some-api-key', + Authorization: 'Bearer 12345', + 'Content-Type': 'application/json', + }, + oauth: { + clientId: 'my-client-id', + clientSecret: 'secret-oauth-value', + }, + }, + }, + }; + const requestConsent = vi.fn().mockResolvedValue(true); + await maybeRequestConsentOrFail( + config, + requestConsent, + false, + undefined, + ); + + expect(requestConsent).toHaveBeenCalledTimes(1); + const consentString = requestConsent.mock.calls[0][0] as string; + + // Assert sensitive env values are masked + expect(consentString).toContain(' - SAFE_VAR: safe-value'); + expect(consentString).toContain(' - SUPER_TOKEN: ********'); + expect(consentString).toContain(' - DB_PASSWORD: ********'); + + // Assert sensitive headers are masked + expect(consentString).toContain( + ' - Content-Type: application/json', + ); + expect(consentString).toContain(' - Authorization: ********'); + expect(consentString).toContain(' - X-API-Key: ********'); + + // Assert sensitive OAuth parameters are masked + expect(consentString).toContain(' - clientId: my-client-id'); + expect(consentString).toContain(' - clientSecret: ********'); + }); + + it('should request consent if mcpServers change', async () => { + const prevConfig: ExtensionConfig = { ...baseConfig }; + const newConfig: ExtensionConfig = { + ...baseConfig, + mcpServers: { server1: { command: 'npm', args: ['start'] } }, + }; + const requestConsent = vi.fn().mockResolvedValue(true); + await maybeRequestConsentOrFail( + newConfig, + requestConsent, + false, + prevConfig, + false, + ); + expect(requestConsent).toHaveBeenCalledTimes(1); + }); + + it('should request consent if mcpServer env changes', async () => { + const prevConfig: ExtensionConfig = { + ...baseConfig, + mcpServers: { + server1: { command: 'node', args: ['server.js'] }, + }, + }; + const newConfig: ExtensionConfig = { + ...baseConfig, + mcpServers: { + server1: { + command: 'node', + args: ['server.js'], + env: { NODE_OPTIONS: '--require=./payload.js' }, + }, + }, + }; + const requestConsent = vi.fn().mockResolvedValue(true); + await maybeRequestConsentOrFail( + newConfig, + requestConsent, + false, + prevConfig, + false, + ); + expect(requestConsent).toHaveBeenCalledTimes(1); + }); + + it('should request consent if contextFileName changes', async () => { + const prevConfig: ExtensionConfig = { ...baseConfig }; + const newConfig: ExtensionConfig = { + ...baseConfig, + contextFileName: 'new-context.md', + }; + const requestConsent = vi.fn().mockResolvedValue(true); + await maybeRequestConsentOrFail( + newConfig, + requestConsent, + false, + prevConfig, + false, + ); + expect(requestConsent).toHaveBeenCalledTimes(1); + }); + + it('should request consent if excludeTools changes', async () => { + const prevConfig: ExtensionConfig = { ...baseConfig }; + const newConfig: ExtensionConfig = { + ...baseConfig, + excludeTools: ['new-tool'], + }; + const requestConsent = vi.fn().mockResolvedValue(true); + await maybeRequestConsentOrFail( + newConfig, + requestConsent, + false, + prevConfig, + false, + ); + expect(requestConsent).toHaveBeenCalledTimes(1); + }); + + it('should include warning when hooks are present', async () => { + const requestConsent = vi.fn().mockResolvedValue(true); + await maybeRequestConsentOrFail( + baseConfig, + requestConsent, + true, + undefined, + ); + + expect(requestConsent).toHaveBeenCalledTimes(1); + const consentString = requestConsent.mock.calls[0][0] as string; + await expectConsentSnapshot(consentString); + }); + + it('should request consent if hooks status changes', async () => { + const requestConsent = vi.fn().mockResolvedValue(true); + await maybeRequestConsentOrFail( + baseConfig, + requestConsent, + true, + baseConfig, + false, + ); + expect(requestConsent).toHaveBeenCalledTimes(1); + }); + + it('should request consent if extension is migrated', async () => { + const requestConsent = vi.fn().mockResolvedValue(true); + await maybeRequestConsentOrFail( + baseConfig, + requestConsent, + false, + { ...baseConfig, name: 'old-ext' }, + false, + [], + [], + true, + ); + + expect(requestConsent).toHaveBeenCalledTimes(1); + let consentString = requestConsent.mock.calls[0][0] as string; + consentString = normalizePathsForSnapshot(consentString, tempDir); + await expectConsentSnapshot(consentString); + }); + + it('should request consent if skills change', async () => { + const skill1Dir = path.join(tempDir, 'skill1'); + const skill2Dir = path.join(tempDir, 'skill2'); + await fs.mkdir(skill1Dir, { recursive: true }); + await fs.mkdir(skill2Dir, { recursive: true }); + await fs.writeFile(path.join(skill1Dir, 'SKILL.md'), 'body1'); + await fs.writeFile(path.join(skill1Dir, 'extra.txt'), 'extra'); + await fs.writeFile(path.join(skill2Dir, 'SKILL.md'), 'body2'); + + const skill1: SkillDefinition = { + name: 'skill1', + description: 'desc1', + location: path.join(skill1Dir, 'SKILL.md'), + body: 'body1', + }; + const skill2: SkillDefinition = { + name: 'skill2', + description: 'desc2', + location: path.join(skill2Dir, 'SKILL.md'), + body: 'body2', + }; + + const config: ExtensionConfig = { + ...baseConfig, + mcpServers: { + server1: { command: 'npm', args: ['start'] }, + server2: { httpUrl: 'https://remote.com' }, + }, + contextFileName: 'my-context.md', + excludeTools: ['tool1', 'tool2'], + }; + const requestConsent = vi.fn().mockResolvedValue(true); + await maybeRequestConsentOrFail( + config, + requestConsent, + false, + undefined, + false, + [skill1, skill2], + ); + + expect(requestConsent).toHaveBeenCalledTimes(1); + let consentString = requestConsent.mock.calls[0][0] as string; + consentString = normalizePathsForSnapshot(consentString, tempDir); + await expectConsentSnapshot(consentString); + }); + + it('should show a warning if the skill directory cannot be read', async () => { + const lockedDir = path.join(tempDir, 'locked'); + await fs.mkdir(lockedDir, { recursive: true }); + + const skill: SkillDefinition = { + name: 'locked-skill', + description: 'A skill in a locked dir', + location: path.join(lockedDir, 'SKILL.md'), + body: 'body', + }; + + // Mock readdir to simulate a permission error. + // We do this instead of using fs.mkdir(..., { mode: 0o000 }) because + // directory permissions work differently on Windows and 0o000 doesn't + // effectively block access there, leading to test failures in Windows CI. + mockReaddir.mockRejectedValueOnce( + new Error('EACCES: permission denied, scandir'), + ); + + const requestConsent = vi.fn().mockResolvedValue(true); + await maybeRequestConsentOrFail( + baseConfig, + requestConsent, + false, + undefined, + false, + [skill], + ); + + expect(requestConsent).toHaveBeenCalledTimes(1); + let consentString = requestConsent.mock.calls[0][0] as string; + consentString = normalizePathsForSnapshot(consentString, tempDir); + await expectConsentSnapshot(consentString); + }); + }); + }); + + describe('skillsConsentString', () => { + it('should generate a consent string for skills', async () => { + const skill1Dir = path.join(tempDir, 'skill1'); + await fs.mkdir(skill1Dir, { recursive: true }); + await fs.writeFile(path.join(skill1Dir, 'SKILL.md'), 'body1'); + + const skill1: SkillDefinition = { + name: 'skill1', + description: 'desc1', + location: path.join(skill1Dir, 'SKILL.md'), + body: 'body1', + }; + + const { skillsConsentString } = await import('./consent.js'); + let consentString = await skillsConsentString( + [skill1], + 'https://example.com/repo.git', + '/mock/target/dir', + ); + + consentString = normalizePathsForSnapshot(consentString, tempDir); + await expectConsentSnapshot(consentString); + }); + }); +}); diff --git a/packages/cli/src/config/extensions/consent.ts b/packages/cli/src/config/extensions/consent.ts new file mode 100644 index 0000000000000000000000000000000000000000..a04e57d0300264868b933e77c4dcccd20cd01ce7 --- /dev/null +++ b/packages/cli/src/config/extensions/consent.ts @@ -0,0 +1,399 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { + debugLogger, + type SkillDefinition, + type MCPServerConfig, +} from '@google/gemini-cli-core'; +import chalk from 'chalk'; + +import type { ConfirmationRequest } from '../../ui/types.js'; +import { escapeAnsiCtrlCodes } from '../../ui/utils/textUtils.js'; +import type { ExtensionConfig } from '../extension.js'; + +export const INSTALL_WARNING_MESSAGE = chalk.yellow( + 'The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform.', +); + +export const SKILLS_WARNING_MESSAGE = chalk.yellow( + "Agent skills inject specialized instructions and domain-specific knowledge into the agent's system prompt. This can change how the agent interprets your requests and interacts with your environment. Review the skill definitions at the location(s) provided below to ensure they meet your security standards.", +); + +/** + * Builds a consent string for installing agent skills. + */ +export async function skillsConsentString( + skills: SkillDefinition[], + source: string, + targetDir?: string, + isLink = false, +): Promise { + const action = isLink ? 'Linking' : 'Installing'; + const output: string[] = []; + output.push(`${action} agent skill(s) from "${source}".`); + output.push( + `\nThe following agent skill(s) will be ${action.toLowerCase()}:\n`, + ); + output.push(...(await renderSkillsList(skills))); + + if (targetDir) { + const destLabel = isLink ? 'Link' : 'Install'; + output.push(`${destLabel} Destination: ${targetDir}`); + } + output.push('\n' + SKILLS_WARNING_MESSAGE); + + return output.join('\n'); +} + +/** + * Requests consent from the user to perform an action, by reading a Y/n + * character from stdin. + * + * This should not be called from interactive mode as it will break the CLI. + * + * @param consentDescription The description of the thing they will be consenting to. + * @returns boolean, whether they consented or not. + */ +export async function requestConsentNonInteractive( + consentDescription: string, +): Promise { + debugLogger.log(consentDescription); + const result = await promptForConsentNonInteractive( + 'Do you want to continue? [Y/n]: ', + ); + return result; +} + +/** + * Requests consent from the user to perform an action, in interactive mode. + * + * This should not be called from non-interactive mode as it will not work. + * + * @param consentDescription The description of the thing they will be consenting to. + * @param addExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI. + * @returns boolean, whether they consented or not. + */ +export async function requestConsentInteractive( + consentDescription: string, + addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void, + clearConfirmationRequest?: () => void, +): Promise { + return promptForConsentInteractive( + consentDescription + '\n\nDo you want to continue?', + addExtensionUpdateConfirmationRequest, + clearConfirmationRequest, + ); +} + +/** + * Asks users a prompt and awaits for a y/n response on stdin. + * + * This should not be called from interactive mode as it will break the CLI. + * + * @param prompt A yes/no prompt to ask the user + * @param defaultValue Whether to resolve as true or false on enter. + * @returns Whether or not the user answers 'y' (yes). + */ +export async function promptForConsentNonInteractive( + prompt: string, + defaultValue = true, +): Promise { + const readline = await import('node:readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(prompt, (answer) => { + rl.close(); + const trimmedAnswer = answer.trim().toLowerCase(); + if (trimmedAnswer === '') { + resolve(defaultValue); + } else { + resolve(['y', 'yes'].includes(trimmedAnswer)); + } + }); + }); +} + +/** + * Asks users an interactive yes/no prompt. + * + * This should not be called from non-interactive mode as it will break the CLI. + * + * @param prompt A markdown prompt to ask the user + * @param addExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request. + * @returns Whether or not the user answers yes. + */ +async function promptForConsentInteractive( + prompt: string, + addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void, + clearConfirmationRequest?: () => void, +): Promise { + return new Promise((resolve) => { + addExtensionUpdateConfirmationRequest({ + prompt, + onConfirm: (resolvedConfirmed) => { + clearConfirmationRequest?.(); + setImmediate(() => resolve(resolvedConfirmed)); + }, + }); + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Helper to generate a deterministic canonical hash of an MCPServerConfig object. + * Excludes circular or complex fields like extension. + */ +function getMcpServerConfigHash(mcpServer: MCPServerConfig): string { + const plainMcpServer: Record = isRecord(mcpServer) + ? mcpServer + : {}; + const copy = { ...plainMcpServer }; + delete copy['extension']; + + const canonicalString = JSON.stringify(copy, (key, value) => { + if (value instanceof RegExp) { + return value.toString(); + } + if (isRecord(value)) { + return Object.keys(value) + .sort() + .reduce((result: Record, k) => { + result[k] = value[k]; + return result; + }, {}); + } + return value as unknown; + }); + + return crypto.createHash('sha256').update(canonicalString).digest('hex'); +} + +/** + * Builds a consent string for installing an extension based on it's + * extensionConfig. + */ +async function extensionConsentString( + extensionConfig: ExtensionConfig, + hasHooks: boolean, + skills: SkillDefinition[] = [], + previousName?: string, + wasMigrated?: boolean, +): Promise { + const sanitizedConfig = escapeAnsiCtrlCodes(extensionConfig); + const output: string[] = []; + const mcpServerEntries = Object.entries(sanitizedConfig.mcpServers || {}); + + if (wasMigrated) { + if (previousName && previousName !== sanitizedConfig.name) { + output.push( + `Migrating extension "${previousName}" to a new repository, renaming to "${sanitizedConfig.name}", and installing updates.`, + ); + } else { + output.push( + `Migrating extension "${sanitizedConfig.name}" to a new repository and installing updates.`, + ); + } + } else if (previousName && previousName !== sanitizedConfig.name) { + output.push( + `Renaming extension "${previousName}" to "${sanitizedConfig.name}" and installing updates.`, + ); + } else { + output.push(`Installing extension "${sanitizedConfig.name}".`); + } + + if (mcpServerEntries.length) { + output.push('This extension will run the following MCP servers:'); + for (const [key, mcpServer] of mcpServerEntries) { + const isLocal = !!mcpServer.command; + const source = + mcpServer.httpUrl ?? + `${mcpServer.command || ''}${mcpServer.args ? ' ' + mcpServer.args.join(' ') : ''}`; + output.push(` * ${key} (${isLocal ? 'local' : 'remote'}): ${source}`); + if (mcpServer.cwd) { + output.push(` Working directory: ${mcpServer.cwd}`); + } + if (mcpServer.env && Object.keys(mcpServer.env).length > 0) { + output.push(' Environment variables:'); + const sortedEnv = Object.entries(mcpServer.env).sort(([a], [b]) => + a.localeCompare(b), + ); + for (const [envKey, envValue] of sortedEnv) { + const isSensitive = + /token|secret|password|passwd|key|auth|credential|creds|private|cert/i.test( + envKey, + ); + const displayValue = + isSensitive && envValue ? '********' : (envValue ?? ''); + output.push(` - ${envKey}: ${displayValue}`); + } + } + if (mcpServer.headers && Object.keys(mcpServer.headers).length > 0) { + output.push(' Headers:'); + const sortedHeaders = Object.entries(mcpServer.headers).sort( + ([a], [b]) => a.localeCompare(b), + ); + for (const [headerKey, headerValue] of sortedHeaders) { + const isSensitive = /authorization|key|cookie|token|secret/i.test( + headerKey, + ); + const displayValue = + isSensitive && headerValue ? '********' : (headerValue ?? ''); + output.push(` - ${headerKey}: ${displayValue}`); + } + } + if (mcpServer.url) { + output.push(` URL: ${mcpServer.url}`); + } + if (mcpServer.tcp) { + output.push(` TCP: ${mcpServer.tcp}`); + } + if (mcpServer.type) { + output.push(` Type: ${mcpServer.type}`); + } + if (mcpServer.timeout !== undefined) { + output.push(` Timeout: ${mcpServer.timeout}`); + } + if (mcpServer.trust !== undefined) { + output.push(` Trust: ${mcpServer.trust}`); + } + if (mcpServer.description) { + output.push(` Description: ${mcpServer.description}`); + } + if (mcpServer.includeTools && mcpServer.includeTools.length > 0) { + output.push(` Include tools: ${mcpServer.includeTools.join(', ')}`); + } + if (mcpServer.excludeTools && mcpServer.excludeTools.length > 0) { + output.push(` Exclude tools: ${mcpServer.excludeTools.join(', ')}`); + } + if (mcpServer.authProviderType) { + output.push(` Auth Provider Type: ${mcpServer.authProviderType}`); + } + if (mcpServer.targetAudience) { + output.push(` Target Audience: ${mcpServer.targetAudience}`); + } + if (mcpServer.targetServiceAccount) { + output.push( + ` Target Service Account: ${mcpServer.targetServiceAccount}`, + ); + } + if (mcpServer.oauth) { + output.push(' OAuth Configuration:'); + const sortedOauth = Object.entries(mcpServer.oauth).sort(([a], [b]) => + a.localeCompare(b), + ); + for (const [oKey, oValue] of sortedOauth) { + const isSensitive = /secret|token|password/i.test(oKey); + const valStr = Array.isArray(oValue) + ? oValue.join(', ') + : String(oValue ?? ''); + const displayValue = isSensitive && valStr ? '********' : valStr; + output.push(` - ${oKey}: ${displayValue}`); + } + } + output.push(` Config signature: ${getMcpServerConfigHash(mcpServer)}`); + } + } + if (sanitizedConfig.contextFileName) { + output.push( + `This extension will append info to your gemini.md context using ${sanitizedConfig.contextFileName}`, + ); + } + if (sanitizedConfig.excludeTools) { + output.push( + `This extension will exclude the following core tools: ${sanitizedConfig.excludeTools}`, + ); + } + if (hasHooks) { + output.push( + '⚠️ This extension contains Hooks which can automatically execute commands.', + ); + } + if (skills.length > 0) { + output.push(`\n${chalk.bold('Agent Skills:')}`); + output.push('\nThis extension will install the following agent skills:\n'); + output.push(...(await renderSkillsList(skills))); + } + + output.push('\n' + INSTALL_WARNING_MESSAGE); + if (skills.length > 0) { + output.push('\n' + SKILLS_WARNING_MESSAGE); + } + + return output.join('\n'); +} + +/** + * Shared logic for formatting a list of agent skills for a consent prompt. + */ +async function renderSkillsList(skills: SkillDefinition[]): Promise { + const output: string[] = []; + for (const skill of skills) { + output.push(` * ${chalk.bold(skill.name)}: ${skill.description}`); + const skillDir = path.dirname(skill.location); + let fileCountStr = ''; + try { + const skillDirItems = await fs.readdir(skillDir); + fileCountStr = ` (${skillDirItems.length} items in directory)`; + } catch { + fileCountStr = ` ${chalk.red('⚠️ (Could not count items in directory)')}`; + } + output.push(chalk.dim(` (Source: ${skill.location})${fileCountStr}`)); + output.push(''); + } + return output; +} + +/** + * Requests consent from the user to install an extension (extensionConfig), if + * there is any difference between the consent string for `extensionConfig` and + * `previousExtensionConfig`. + * + * Always requests consent if previousExtensionConfig is null. + * + * Throws if the user does not consent. + */ +export async function maybeRequestConsentOrFail( + extensionConfig: ExtensionConfig, + requestConsent: (consent: string) => Promise, + hasHooks: boolean, + previousExtensionConfig?: ExtensionConfig, + previousHasHooks?: boolean, + skills: SkillDefinition[] = [], + previousSkills: SkillDefinition[] = [], + isMigrating: boolean = false, +) { + const extensionConsent = await extensionConsentString( + extensionConfig, + hasHooks, + skills, + previousExtensionConfig?.name, + isMigrating, + ); + if (previousExtensionConfig) { + const previousExtensionConsent = await extensionConsentString( + previousExtensionConfig, + previousHasHooks ?? false, + previousSkills, + ); + if (previousExtensionConsent === extensionConsent) { + return; + } + } + if (!(await requestConsent(extensionConsent))) { + throw new Error(`Installation cancelled for "${extensionConfig.name}".`); + } +} diff --git a/packages/cli/src/config/extensions/extensionEnablement.test.ts b/packages/cli/src/config/extensions/extensionEnablement.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb0a7c88341e02f1447c39a08122e26846494cfb --- /dev/null +++ b/packages/cli/src/config/extensions/extensionEnablement.test.ts @@ -0,0 +1,534 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import fs from 'node:fs'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ExtensionEnablementManager, Override } from './extensionEnablement.js'; + +import { ExtensionStorage } from './storage.js'; + +vi.mock('./storage.js'); + +import { + coreEvents, + GEMINI_DIR, + type GeminiCLIExtension, +} from '@google/gemini-cli-core'; + +vi.mock('node:os', () => ({ + homedir: vi.fn().mockReturnValue('/virtual-home'), + tmpdir: vi.fn().mockReturnValue('/virtual-tmp'), +})); + +const inMemoryFs: { [key: string]: string } = {}; + +// Helper to create a temporary directory for testing +function createTestDir() { + const dirPath = `/virtual-tmp/gemini-test-${Math.random().toString(36).substring(2, 15)}`; + inMemoryFs[dirPath] = ''; // Simulate directory existence + return { + path: dirPath, + cleanup: () => { + for (const key in inMemoryFs) { + if (key.startsWith(dirPath)) { + delete inMemoryFs[key]; + } + } + }, + }; +} + +let testDir: { path: string; cleanup: () => void }; +let manager: ExtensionEnablementManager; + +describe('ExtensionEnablementManager', () => { + beforeEach(() => { + // Clear the in-memory file system before each test + for (const key in inMemoryFs) { + delete inMemoryFs[key]; + } + expect(Object.keys(inMemoryFs).length).toBe(0); // Add this assertion + + // Mock fs functions + vi.spyOn(fs, 'readFileSync').mockImplementation( + (path: fs.PathOrFileDescriptor) => { + const content = inMemoryFs[path.toString()]; + if (content === undefined) { + const error = new Error( + `ENOENT: no such file or directory, open '${path}'`, + ); + (error as NodeJS.ErrnoException).code = 'ENOENT'; + throw error; + } + return content; + }, + ); + vi.spyOn(fs, 'writeFileSync').mockImplementation( + ( + path: fs.PathOrFileDescriptor, + data: string | NodeJS.ArrayBufferView, + ) => { + inMemoryFs[path.toString()] = data.toString(); // Convert ArrayBufferView to string for inMemoryFs + }, + ); + vi.spyOn(fs, 'mkdirSync').mockImplementation( + ( + _path: fs.PathLike, + _options?: fs.MakeDirectoryOptions | fs.Mode | null, + ) => undefined, + ); + vi.spyOn(fs, 'mkdtempSync').mockImplementation((prefix: string) => { + const virtualPath = `/virtual-tmp/${prefix.replace(/[^a-zA-Z0-9]/g, '')}`; + return virtualPath; + }); + vi.spyOn(fs, 'rmSync').mockImplementation(() => {}); + + testDir = createTestDir(); + vi.mocked(ExtensionStorage.getUserExtensionsDir).mockReturnValue( + path.join(testDir.path, GEMINI_DIR), + ); + manager = new ExtensionEnablementManager(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + // Reset the singleton instance for test isolation + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (ExtensionEnablementManager as any).instance = undefined; + }); + + describe('isEnabled', () => { + it('should return true if extension is not configured', () => { + expect(manager.isEnabled('ext-test', '/any/path')).toBe(true); + }); + + it('should return true if no overrides match', () => { + manager.disable('ext-test', false, '/another/path'); + expect(manager.isEnabled('ext-test', '/any/path')).toBe(true); + }); + + it('should enable a path based on an override rule', () => { + manager.disable('ext-test', true, '/'); + manager.enable('ext-test', true, '/home/user/projects/'); + expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe( + true, + ); + }); + + it('should disable a path based on a disable override rule', () => { + manager.enable('ext-test', true, '/'); + manager.disable('ext-test', true, '/home/user/projects/'); + expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe( + false, + ); + }); + + it('should respect the last matching rule (enable wins)', () => { + manager.disable('ext-test', true, '/home/user/projects/'); + manager.enable('ext-test', false, '/home/user/projects/my-app'); + expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe( + true, + ); + }); + + it('should respect the last matching rule (disable wins)', () => { + manager.enable('ext-test', true, '/home/user/projects/'); + manager.disable('ext-test', false, '/home/user/projects/my-app'); + expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe( + false, + ); + }); + + it('should handle overlapping rules correctly', () => { + manager.enable('ext-test', true, '/home/user/projects'); + manager.disable('ext-test', false, '/home/user/projects/my-app'); + expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe( + false, + ); + expect( + manager.isEnabled('ext-test', '/home/user/projects/something-else'), + ).toBe(true); + }); + }); + + describe('remove', () => { + it('should remove an extension from the config', () => { + manager.enable('ext-test', true, '/path/to/dir'); + const config = manager.readConfig(); + expect(config['ext-test']).toBeDefined(); + + manager.remove('ext-test'); + const newConfig = manager.readConfig(); + expect(newConfig['ext-test']).toBeUndefined(); + }); + + it('should not throw when removing a non-existent extension', () => { + const config = manager.readConfig(); + expect(config['ext-test']).toBeUndefined(); + expect(() => manager.remove('ext-test')).not.toThrow(); + }); + }); + + describe('readConfig', () => { + it('should return an empty object if the config file is corrupted', () => { + const configPath = path.join( + testDir.path, + GEMINI_DIR, + 'extension-enablement.json', + ); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, 'not a json'); + const config = manager.readConfig(); + expect(config).toEqual({}); + }); + + it('should return an empty object on generic read error', () => { + vi.spyOn(fs, 'readFileSync').mockImplementation(() => { + throw new Error('Read error'); + }); + const config = manager.readConfig(); + expect(config).toEqual({}); + }); + }); + + describe('includeSubdirs', () => { + it('should add a glob when enabling with includeSubdirs', () => { + manager.enable('ext-test', true, '/path/to/dir'); + const config = manager.readConfig(); + expect(config['ext-test'].overrides).toContain('/path/to/dir/*'); + }); + + it('should not add a glob when enabling without includeSubdirs', () => { + manager.enable('ext-test', false, '/path/to/dir'); + const config = manager.readConfig(); + expect(config['ext-test'].overrides).toContain('/path/to/dir/'); + expect(config['ext-test'].overrides).not.toContain('/path/to/dir/*'); + }); + + it('should add a glob when disabling with includeSubdirs', () => { + manager.disable('ext-test', true, '/path/to/dir'); + const config = manager.readConfig(); + expect(config['ext-test'].overrides).toContain('!/path/to/dir/*'); + }); + + it('should remove conflicting glob rule when enabling without subdirs', () => { + manager.enable('ext-test', true, '/path/to/dir'); // Adds /path/to/dir* + manager.enable('ext-test', false, '/path/to/dir'); // Should remove the glob + const config = manager.readConfig(); + expect(config['ext-test'].overrides).toContain('/path/to/dir/'); + expect(config['ext-test'].overrides).not.toContain('/path/to/dir/*'); + }); + + it('should remove conflicting non-glob rule when enabling with subdirs', () => { + manager.enable('ext-test', false, '/path/to/dir'); // Adds /path/to/dir + manager.enable('ext-test', true, '/path/to/dir'); // Should remove the non-glob + const config = manager.readConfig(); + expect(config['ext-test'].overrides).toContain('/path/to/dir/*'); + expect(config['ext-test'].overrides).not.toContain('/path/to/dir/'); + }); + + it('should remove conflicting rules when disabling', () => { + manager.enable('ext-test', true, '/path/to/dir'); // enabled with glob + manager.disable('ext-test', false, '/path/to/dir'); // disabled without + const config = manager.readConfig(); + expect(config['ext-test'].overrides).toContain('!/path/to/dir/'); + expect(config['ext-test'].overrides).not.toContain('/path/to/dir/*'); + }); + + it('should correctly evaluate isEnabled with subdirs', () => { + manager.disable('ext-test', true, '/'); + manager.enable('ext-test', true, '/path/to/dir'); + expect(manager.isEnabled('ext-test', '/path/to/dir/')).toBe(true); + expect(manager.isEnabled('ext-test', '/path/to/dir/sub/')).toBe(true); + expect(manager.isEnabled('ext-test', '/path/to/another/')).toBe(false); + }); + + it('should correctly evaluate isEnabled without subdirs', () => { + manager.disable('ext-test', true, '/*'); + manager.enable('ext-test', false, '/path/to/dir'); + expect(manager.isEnabled('ext-test', '/path/to/dir')).toBe(true); + expect(manager.isEnabled('ext-test', '/path/to/dir/sub')).toBe(false); + }); + }); + + describe('pruning child rules', () => { + it('should remove child rules when enabling a parent with subdirs', () => { + // Pre-existing rules for children + manager.enable('ext-test', false, '/path/to/dir/subdir1'); + manager.disable('ext-test', true, '/path/to/dir/subdir2'); + manager.enable('ext-test', false, '/path/to/another/dir'); + + // Enable the parent directory + manager.enable('ext-test', true, '/path/to/dir'); + + const config = manager.readConfig(); + const overrides = config['ext-test'].overrides; + + // The new parent rule should be present + expect(overrides).toContain(`/path/to/dir/*`); + + // Child rules should be removed + expect(overrides).not.toContain('/path/to/dir/subdir1/'); + expect(overrides).not.toContain(`!/path/to/dir/subdir2/*`); + + // Unrelated rules should remain + expect(overrides).toContain('/path/to/another/dir/'); + }); + + it('should remove child rules when disabling a parent with subdirs', () => { + // Pre-existing rules for children + manager.enable('ext-test', false, '/path/to/dir/subdir1'); + manager.disable('ext-test', true, '/path/to/dir/subdir2'); + manager.enable('ext-test', false, '/path/to/another/dir'); + + // Disable the parent directory + manager.disable('ext-test', true, '/path/to/dir'); + + const config = manager.readConfig(); + const overrides = config['ext-test'].overrides; + + // The new parent rule should be present + expect(overrides).toContain(`!/path/to/dir/*`); + + // Child rules should be removed + expect(overrides).not.toContain('/path/to/dir/subdir1/'); + expect(overrides).not.toContain(`!/path/to/dir/subdir2/*`); + + // Unrelated rules should remain + expect(overrides).toContain('/path/to/another/dir/'); + }); + + it('should not remove child rules if includeSubdirs is false', () => { + manager.enable('ext-test', false, '/path/to/dir/subdir1'); + manager.enable('ext-test', false, '/path/to/dir'); // Not including subdirs + + const config = manager.readConfig(); + const overrides = config['ext-test'].overrides; + + expect(overrides).toContain('/path/to/dir/subdir1/'); + expect(overrides).toContain('/path/to/dir/'); + }); + }); + + it('should correctly prioritize more specific enable rules', () => { + manager.disable('ext-test', true, '/Users/chrstn'); + manager.enable('ext-test', true, '/Users/chrstn/gemini-cli'); + + expect(manager.isEnabled('ext-test', '/Users/chrstn/gemini-cli')).toBe( + true, + ); + }); + + it('should not disable subdirectories if includeSubdirs is false', () => { + manager.disable('ext-test', false, '/Users/chrstn'); + expect(manager.isEnabled('ext-test', '/Users/chrstn/gemini-cli')).toBe( + true, + ); + }); + + describe('extension overrides (-e )', () => { + beforeEach(() => { + manager = new ExtensionEnablementManager(['ext-test']); + }); + + it('can enable extensions, case-insensitive', () => { + manager.disable('ext-test', true, '/'); + expect(manager.isEnabled('ext-test', '/')).toBe(true); + expect(manager.isEnabled('Ext-Test', '/')).toBe(true); + // Double check that it would have been disabled otherwise + expect(new ExtensionEnablementManager().isEnabled('ext-test', '/')).toBe( + false, + ); + }); + + it('disable all other extensions', () => { + manager = new ExtensionEnablementManager(['ext-test']); + manager.enable('ext-test-2', true, '/'); + expect(manager.isEnabled('ext-test-2', '/')).toBe(false); + // Double check that it would have been enabled otherwise + expect( + new ExtensionEnablementManager().isEnabled('ext-test-2', '/'), + ).toBe(true); + }); + + it('none disables all extensions', () => { + manager = new ExtensionEnablementManager(['none']); + manager.enable('ext-test', true, '/'); + expect(manager.isEnabled('ext-test', '/path/to/dir')).toBe(false); + // Double check that it would have been enabled otherwise + expect(new ExtensionEnablementManager().isEnabled('ext-test', '/')).toBe( + true, + ); + }); + }); + + describe('validateExtensionOverrides', () => { + let coreEventsEmitSpy: ReturnType; + + beforeEach(() => { + coreEventsEmitSpy = vi.spyOn(coreEvents, 'emitFeedback'); + }); + + afterEach(() => { + coreEventsEmitSpy.mockRestore(); + }); + + it('should not log an error if enabledExtensionNamesOverride is empty', () => { + const manager = new ExtensionEnablementManager([]); + manager.validateExtensionOverrides([]); + expect(coreEventsEmitSpy).not.toHaveBeenCalled(); + }); + + it('should not log an error if all enabledExtensionNamesOverride are valid', () => { + const manager = new ExtensionEnablementManager(['ext-one', 'ext-two']); + const extensions = [ + { name: 'ext-one' }, + { name: 'ext-two' }, + ] as GeminiCLIExtension[]; + manager.validateExtensionOverrides(extensions); + expect(coreEventsEmitSpy).not.toHaveBeenCalled(); + }); + + it('should log an error for each invalid extension name in enabledExtensionNamesOverride', () => { + const manager = new ExtensionEnablementManager([ + 'ext-one', + 'ext-invalid', + 'ext-another-invalid', + ]); + const extensions = [ + { name: 'ext-one' }, + { name: 'ext-two' }, + ] as GeminiCLIExtension[]; + manager.validateExtensionOverrides(extensions); + expect(coreEventsEmitSpy).toHaveBeenCalledTimes(2); + expect(coreEventsEmitSpy).toHaveBeenCalledWith( + 'error', + 'Extension not found: ext-invalid', + ); + expect(coreEventsEmitSpy).toHaveBeenCalledWith( + 'error', + 'Extension not found: ext-another-invalid', + ); + }); + + it('should not log an error if "none" is in enabledExtensionNamesOverride', () => { + const manager = new ExtensionEnablementManager(['none']); + manager.validateExtensionOverrides([]); + expect(coreEventsEmitSpy).not.toHaveBeenCalled(); + }); + }); +}); + +describe('Override', () => { + it('should create an override from input', () => { + const override = Override.fromInput('/path/to/dir', true); + expect(override.baseRule).toBe(`/path/to/dir/`); + expect(override.isDisable).toBe(false); + expect(override.includeSubdirs).toBe(true); + }); + + it('should create a disable override from input', () => { + const override = Override.fromInput('!/path/to/dir', false); + expect(override.baseRule).toBe(`/path/to/dir/`); + expect(override.isDisable).toBe(true); + expect(override.includeSubdirs).toBe(false); + }); + + it('should create an override from a file rule', () => { + const override = Override.fromFileRule('/path/to/dir/'); + expect(override.baseRule).toBe('/path/to/dir/'); + expect(override.isDisable).toBe(false); + expect(override.includeSubdirs).toBe(false); + }); + + it('should create an override from a file rule without a trailing slash', () => { + const override = Override.fromFileRule('/path/to/dir'); + expect(override.baseRule).toBe('/path/to/dir'); + expect(override.isDisable).toBe(false); + expect(override.includeSubdirs).toBe(false); + }); + + it('should create a disable override from a file rule', () => { + const override = Override.fromFileRule('!/path/to/dir/'); + expect(override.isDisable).toBe(true); + expect(override.baseRule).toBe('/path/to/dir/'); + expect(override.includeSubdirs).toBe(false); + }); + + it('should create an override with subdirs from a file rule', () => { + const override = Override.fromFileRule('/path/to/dir/*'); + expect(override.baseRule).toBe('/path/to/dir/'); + expect(override.isDisable).toBe(false); + expect(override.includeSubdirs).toBe(true); + }); + + it('should correctly identify conflicting overrides', () => { + const override1 = Override.fromInput('/path/to/dir', true); + const override2 = Override.fromInput('/path/to/dir', false); + expect(override1.conflictsWith(override2)).toBe(true); + }); + + it('should correctly identify non-conflicting overrides', () => { + const override1 = Override.fromInput('/path/to/dir', true); + const override2 = Override.fromInput('/path/to/another/dir', true); + expect(override1.conflictsWith(override2)).toBe(false); + }); + + it('should correctly identify equal overrides', () => { + const override1 = Override.fromInput('/path/to/dir', true); + const override2 = Override.fromInput('/path/to/dir', true); + expect(override1.isEqualTo(override2)).toBe(true); + }); + + it('should correctly identify unequal overrides', () => { + const override1 = Override.fromInput('/path/to/dir', true); + const override2 = Override.fromInput('!/path/to/dir', true); + expect(override1.isEqualTo(override2)).toBe(false); + }); + + it('should generate the correct regex', () => { + const override = Override.fromInput('/path/to/dir', true); + const regex = override.asRegex(); + expect(regex.test('/path/to/dir/')).toBe(true); + expect(regex.test('/path/to/dir/subdir')).toBe(true); + expect(regex.test('/path/to/another/dir')).toBe(false); + }); + + it('should correctly identify child overrides', () => { + const parent = Override.fromInput('/path/to/dir', true); + const child = Override.fromInput('/path/to/dir/subdir', false); + expect(child.isChildOf(parent)).toBe(true); + }); + + it('should correctly identify child overrides with glob', () => { + const parent = Override.fromInput('/path/to/dir/*', true); + const child = Override.fromInput('/path/to/dir/subdir', false); + expect(child.isChildOf(parent)).toBe(true); + }); + + it('should correctly identify non-child overrides', () => { + const parent = Override.fromInput('/path/to/dir', true); + const other = Override.fromInput('/path/to/another/dir', false); + expect(other.isChildOf(parent)).toBe(false); + }); + + it('should generate the correct output string', () => { + const override = Override.fromInput('/path/to/dir', true); + expect(override.output()).toBe(`/path/to/dir/*`); + }); + + it('should generate the correct output string for a disable override', () => { + const override = Override.fromInput('!/path/to/dir', false); + expect(override.output()).toBe(`!/path/to/dir/`); + }); + + it('should disable a path based on a disable override rule', () => { + const override = Override.fromInput('!/path/to/dir', false); + expect(override.output()).toBe(`!/path/to/dir/`); + }); +}); diff --git a/packages/cli/src/config/extensions/extensionEnablement.ts b/packages/cli/src/config/extensions/extensionEnablement.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ba0800f44334cf19eb77c4a747bf70ae62ad506 --- /dev/null +++ b/packages/cli/src/config/extensions/extensionEnablement.ts @@ -0,0 +1,251 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { coreEvents, type GeminiCLIExtension } from '@google/gemini-cli-core'; +import { ExtensionStorage } from './storage.js'; +import { z } from 'zod'; + +export interface ExtensionEnablementConfig { + overrides: string[]; +} + +export interface AllExtensionsEnablementConfig { + [extensionName: string]: ExtensionEnablementConfig; +} + +export class Override { + constructor( + public baseRule: string, + public isDisable: boolean, + public includeSubdirs: boolean, + ) {} + + static fromInput(inputRule: string, includeSubdirs: boolean): Override { + const isDisable = inputRule.startsWith('!'); + let baseRule = isDisable ? inputRule.substring(1) : inputRule; + baseRule = ensureLeadingAndTrailingSlash(baseRule); + return new Override(baseRule, isDisable, includeSubdirs); + } + + static fromFileRule(fileRule: string): Override { + const isDisable = fileRule.startsWith('!'); + let baseRule = isDisable ? fileRule.substring(1) : fileRule; + const includeSubdirs = baseRule.endsWith('*'); + baseRule = includeSubdirs + ? baseRule.substring(0, baseRule.length - 1) + : baseRule; + return new Override(baseRule, isDisable, includeSubdirs); + } + + conflictsWith(other: Override): boolean { + if (this.baseRule === other.baseRule) { + return ( + this.includeSubdirs !== other.includeSubdirs || + this.isDisable !== other.isDisable + ); + } + return false; + } + + isEqualTo(other: Override): boolean { + return ( + this.baseRule === other.baseRule && + this.includeSubdirs === other.includeSubdirs && + this.isDisable === other.isDisable + ); + } + + asRegex(): RegExp { + return globToRegex(`${this.baseRule}${this.includeSubdirs ? '*' : ''}`); + } + + isChildOf(parent: Override) { + if (!parent.includeSubdirs) { + return false; + } + return parent.asRegex().test(this.baseRule); + } + + output(): string { + return `${this.isDisable ? '!' : ''}${this.baseRule}${this.includeSubdirs ? '*' : ''}`; + } + + matchesPath(path: string) { + return this.asRegex().test(path); + } +} + +const ensureLeadingAndTrailingSlash = function (dirPath: string): string { + // Normalize separators to forward slashes for consistent matching across platforms. + let result = dirPath.replace(/\\/g, '/'); + if (result.charAt(0) !== '/') { + result = '/' + result; + } + if (result.charAt(result.length - 1) !== '/') { + result = result + '/'; + } + return result; +}; + +/** + * Converts a glob pattern to a RegExp object. + * This is a simplified implementation that supports `*`. + * + * @param glob The glob pattern to convert. + * @returns A RegExp object. + */ +function globToRegex(glob: string): RegExp { + const regexString = glob + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special regex characters + .replace(/(\/?)\*/g, '($1.*)?'); // Convert * to optional group + + return new RegExp(`^${regexString}$`); +} + +export class ExtensionEnablementManager { + private configFilePath: string; + private configDir: string; + // If non-empty, this overrides all other extension configuration and enables + // only the ones in this list. + private enabledExtensionNamesOverride: string[]; + + constructor(enabledExtensionNames?: string[]) { + this.configDir = ExtensionStorage.getUserExtensionsDir(); + this.configFilePath = path.join( + this.configDir, + 'extension-enablement.json', + ); + this.enabledExtensionNamesOverride = + enabledExtensionNames?.map((name) => name.toLowerCase()) ?? []; + } + + validateExtensionOverrides(extensions: GeminiCLIExtension[]) { + for (const name of this.enabledExtensionNamesOverride) { + if (name === 'none') continue; + if ( + !extensions.some((ext) => ext.name.toLowerCase() === name.toLowerCase()) + ) { + coreEvents.emitFeedback('error', `Extension not found: ${name}`); + } + } + } + + /** + * Determines if an extension is enabled based on its name and the current + * path. The last matching rule in the overrides list wins. + * + * @param extensionName The name of the extension. + * @param currentPath The absolute path of the current working directory. + * @returns True if the extension is enabled, false otherwise. + */ + isEnabled(extensionName: string, currentPath: string): boolean { + // If we have a single override called 'none', this disables all extensions. + // Typically, this comes from the user passing `-e none`. + if ( + this.enabledExtensionNamesOverride.length === 1 && + this.enabledExtensionNamesOverride[0] === 'none' + ) { + return false; + } + + // If we have explicit overrides, only enable those extensions. + if (this.enabledExtensionNamesOverride.length > 0) { + // When checking against overrides ONLY, we use a case insensitive match. + // The override names are already lowercased in the constructor. + return this.enabledExtensionNamesOverride.includes( + extensionName.toLocaleLowerCase(), + ); + } + + // Otherwise, we use the configuration settings + const config = this.readConfig(); + const extensionConfig = config[extensionName]; + // Extensions are enabled by default. + let enabled = true; + const allOverrides = extensionConfig?.overrides ?? []; + for (const rule of allOverrides) { + const override = Override.fromFileRule(rule); + if (override.matchesPath(ensureLeadingAndTrailingSlash(currentPath))) { + enabled = !override.isDisable; + } + } + return enabled; + } + + readConfig(): AllExtensionsEnablementConfig { + try { + const content = fs.readFileSync(this.configFilePath, 'utf-8'); + const parsed: unknown = JSON.parse(content); + const schema = z.record( + z.string(), + z.object({ overrides: z.array(z.string()) }), + ); + return schema.parse(parsed); + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) { + return {}; + } + coreEvents.emitFeedback( + 'error', + 'Failed to read extension enablement config.', + error, + ); + return {}; + } + } + + writeConfig(config: AllExtensionsEnablementConfig): void { + fs.mkdirSync(this.configDir, { recursive: true }); + fs.writeFileSync(this.configFilePath, JSON.stringify(config, null, 2)); + } + + enable( + extensionName: string, + includeSubdirs: boolean, + scopePath: string, + ): void { + const config = this.readConfig(); + if (!config[extensionName]) { + config[extensionName] = { overrides: [] }; + } + const override = Override.fromInput(scopePath, includeSubdirs); + const overrides = config[extensionName].overrides.filter((rule) => { + const fileOverride = Override.fromFileRule(rule); + if ( + fileOverride.conflictsWith(override) || + fileOverride.isEqualTo(override) + ) { + return false; // Remove conflicts and equivalent values. + } + return !fileOverride.isChildOf(override); + }); + overrides.push(override.output()); + config[extensionName].overrides = overrides; + this.writeConfig(config); + } + + disable( + extensionName: string, + includeSubdirs: boolean, + scopePath: string, + ): void { + this.enable(extensionName, includeSubdirs, `!${scopePath}`); + } + + remove(extensionName: string): void { + const config = this.readConfig(); + if (config[extensionName]) { + delete config[extensionName]; + this.writeConfig(config); + } + } +} diff --git a/packages/cli/src/config/extensions/extensionSettings.test.ts b/packages/cli/src/config/extensions/extensionSettings.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bdbbdb24016d0a25549105738cd1cdd6eb5aa5e3 --- /dev/null +++ b/packages/cli/src/config/extensions/extensionSettings.test.ts @@ -0,0 +1,937 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + getEnvContents, + maybePromptForSettings, + promptForSetting, + type ExtensionSetting, + updateSetting, + ExtensionSettingScope, + getScopedEnvContents, +} from './extensionSettings.js'; +import type { ExtensionConfig } from '../extension.js'; +import { ExtensionStorage } from './storage.js'; +import prompts from 'prompts'; +import * as fsPromises from 'node:fs/promises'; +import * as fs from 'node:fs'; +import { KeychainTokenStorage } from '@google/gemini-cli-core'; +import { EXTENSION_SETTINGS_FILENAME } from './variables.js'; + +vi.mock('prompts'); +vi.mock('os', async (importOriginal) => { + const mockedOs = await importOriginal(); + return { + ...mockedOs, + homedir: vi.fn(), + }; +}); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + KeychainTokenStorage: vi.fn(), + }; +}); + +describe('extensionSettings', () => { + let tempHomeDir: string; + let tempWorkspaceDir: string; + let extensionDir: string; + let mockKeychainData: Record>; + + beforeEach(() => { + vi.clearAllMocks(); + mockKeychainData = {}; + vi.mocked(KeychainTokenStorage).mockImplementation( + (serviceName: string) => { + if (!mockKeychainData[serviceName]) { + mockKeychainData[serviceName] = {}; + } + const keychainData = mockKeychainData[serviceName]; + return { + getSecret: vi + .fn() + .mockImplementation( + async (key: string) => keychainData[key] || null, + ), + setSecret: vi + .fn() + .mockImplementation(async (key: string, value: string) => { + keychainData[key] = value; + }), + deleteSecret: vi.fn().mockImplementation(async (key: string) => { + delete keychainData[key]; + }), + listSecrets: vi + .fn() + .mockImplementation(async () => Object.keys(keychainData)), + isAvailable: vi.fn().mockResolvedValue(true), + } as unknown as KeychainTokenStorage; + }, + ); + tempHomeDir = os.tmpdir() + path.sep + `gemini-cli-test-home-${Date.now()}`; + tempWorkspaceDir = path.join( + os.tmpdir(), + `gemini-cli-test-workspace-${Date.now()}`, + ); + extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext'); + // Spy and mock the method, but also create the directory so we can write to it. + vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue( + extensionDir, + ); + fs.mkdirSync(extensionDir, { recursive: true }); + fs.mkdirSync(tempWorkspaceDir, { recursive: true }); + vi.mocked(os.homedir).mockReturnValue(tempHomeDir); + vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); + vi.mocked(prompts).mockClear(); + }); + + afterEach(() => { + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + describe('maybePromptForSettings', () => { + const mockRequestSetting = vi.fn( + async (setting: ExtensionSetting) => `mock-${setting.envVar}`, + ); + + beforeEach(() => { + mockRequestSetting.mockClear(); + }); + + it('should do nothing if settings are undefined', async () => { + const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0' }; + await maybePromptForSettings( + config, + '12345', + mockRequestSetting, + undefined, + undefined, + ); + expect(mockRequestSetting).not.toHaveBeenCalled(); + }); + + it('should do nothing if settings are empty', async () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [], + }; + await maybePromptForSettings( + config, + '12345', + mockRequestSetting, + undefined, + undefined, + ); + expect(mockRequestSetting).not.toHaveBeenCalled(); + }); + + it('should prompt for all settings if there is no previous config', async () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { name: 's1', description: 'd1', envVar: 'VAR1' }, + { name: 's2', description: 'd2', envVar: 'VAR2' }, + ], + }; + await maybePromptForSettings( + config, + '12345', + mockRequestSetting, + undefined, + undefined, + ); + expect(mockRequestSetting).toHaveBeenCalledTimes(2); + expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![0]); + expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![1]); + }); + + it('should only prompt for new settings', async () => { + const previousConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }], + }; + const newConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { name: 's1', description: 'd1', envVar: 'VAR1' }, + { name: 's2', description: 'd2', envVar: 'VAR2' }, + ], + }; + const previousSettings = { VAR1: 'previous-VAR1' }; + + await maybePromptForSettings( + newConfig, + '12345', + mockRequestSetting, + previousConfig, + previousSettings, + ); + + expect(mockRequestSetting).toHaveBeenCalledTimes(1); + expect(mockRequestSetting).toHaveBeenCalledWith(newConfig.settings![1]); + + const expectedEnvPath = path.join(extensionDir, '.env'); + const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); + const expectedContent = 'VAR1=previous-VAR1\nVAR2=mock-VAR2\n'; + expect(actualContent).toBe(expectedContent); + }); + + it('should clear settings if new config has no settings', async () => { + const previousConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { name: 's1', description: 'd1', envVar: 'VAR1' }, + { + name: 's2', + description: 'd2', + envVar: 'SENSITIVE_VAR', + sensitive: true, + }, + ], + }; + const newConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [], + }; + const previousSettings = { + VAR1: 'previous-VAR1', + SENSITIVE_VAR: 'secret', + }; + const userKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext 12345`, + ); + await userKeychain.setSecret('SENSITIVE_VAR', 'secret'); + const envPath = path.join(extensionDir, '.env'); + await fsPromises.writeFile(envPath, 'VAR1=previous-VAR1'); + + await maybePromptForSettings( + newConfig, + '12345', + mockRequestSetting, + previousConfig, + previousSettings, + ); + + expect(mockRequestSetting).not.toHaveBeenCalled(); + const actualContent = await fsPromises.readFile(envPath, 'utf-8'); + expect(actualContent).toBe(''); + expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull(); + }); + + it('should remove sensitive settings from keychain', async () => { + const previousConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { + name: 's1', + description: 'd1', + envVar: 'SENSITIVE_VAR', + sensitive: true, + }, + ], + }; + const newConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [], + }; + const previousSettings = { SENSITIVE_VAR: 'secret' }; + const userKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext 12345`, + ); + await userKeychain.setSecret('SENSITIVE_VAR', 'secret'); + + await maybePromptForSettings( + newConfig, + '12345', + mockRequestSetting, + previousConfig, + previousSettings, + ); + + expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull(); + }); + + it('should remove settings that are no longer in the config', async () => { + const previousConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { name: 's1', description: 'd1', envVar: 'VAR1' }, + { name: 's2', description: 'd2', envVar: 'VAR2' }, + ], + }; + const newConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }], + }; + const previousSettings = { + VAR1: 'previous-VAR1', + VAR2: 'previous-VAR2', + }; + + await maybePromptForSettings( + newConfig, + '12345', + mockRequestSetting, + previousConfig, + previousSettings, + ); + + expect(mockRequestSetting).not.toHaveBeenCalled(); + + const expectedEnvPath = path.join(extensionDir, '.env'); + const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); + const expectedContent = 'VAR1=previous-VAR1\n'; + expect(actualContent).toBe(expectedContent); + }); + + it('should reprompt if a setting changes sensitivity', async () => { + const previousConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { name: 's1', description: 'd1', envVar: 'VAR1', sensitive: false }, + ], + }; + const newConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { name: 's1', description: 'd1', envVar: 'VAR1', sensitive: true }, + ], + }; + const previousSettings = { VAR1: 'previous-VAR1' }; + + await maybePromptForSettings( + newConfig, + '12345', + mockRequestSetting, + previousConfig, + previousSettings, + ); + + expect(mockRequestSetting).toHaveBeenCalledTimes(1); + expect(mockRequestSetting).toHaveBeenCalledWith(newConfig.settings![0]); + + // The value should now be in keychain, not the .env file. + const expectedEnvPath = path.join(extensionDir, '.env'); + const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); + expect(actualContent).toBe(''); + }); + + it('should not prompt if settings are identical', async () => { + const previousConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { name: 's1', description: 'd1', envVar: 'VAR1' }, + { name: 's2', description: 'd2', envVar: 'VAR2' }, + ], + }; + const newConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { name: 's1', description: 'd1', envVar: 'VAR1' }, + { name: 's2', description: 'd2', envVar: 'VAR2' }, + ], + }; + const previousSettings = { + VAR1: 'previous-VAR1', + VAR2: 'previous-VAR2', + }; + + await maybePromptForSettings( + newConfig, + '12345', + mockRequestSetting, + previousConfig, + previousSettings, + ); + + expect(mockRequestSetting).not.toHaveBeenCalled(); + const expectedEnvPath = path.join(extensionDir, '.env'); + const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); + const expectedContent = 'VAR1=previous-VAR1\nVAR2=previous-VAR2\n'; + expect(actualContent).toBe(expectedContent); + }); + + it('should wrap values with spaces in quotes', async () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }], + }; + mockRequestSetting.mockResolvedValue('a value with spaces'); + + await maybePromptForSettings( + config, + '12345', + mockRequestSetting, + undefined, + undefined, + ); + + const expectedEnvPath = path.join(extensionDir, '.env'); + const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); + expect(actualContent).toBe('VAR1="a value with spaces"\n'); + }); + + it('should not set sensitive settings if the value is empty during initial setup', async () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { + name: 's1', + description: 'd1', + envVar: 'SENSITIVE_VAR', + sensitive: true, + }, + ], + }; + mockRequestSetting.mockResolvedValue(''); + + await maybePromptForSettings( + config, + '12345', + mockRequestSetting, + undefined, + undefined, + ); + + const userKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext 12345`, + ); + expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull(); + }); + + it('should not attempt to clear secrets if keychain is unavailable', async () => { + // Arrange + const mockIsAvailable = vi.fn().mockResolvedValue(false); + const mockListSecrets = vi.fn(); + + vi.mocked(KeychainTokenStorage).mockImplementation( + () => + ({ + isAvailable: mockIsAvailable, + listSecrets: mockListSecrets, + deleteSecret: vi.fn(), + getSecret: vi.fn(), + setSecret: vi.fn(), + }) as unknown as KeychainTokenStorage, + ); + + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [], // Empty settings triggers clearSettings + }; + + const previousConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }], + }; + + // Act + await maybePromptForSettings( + config, + '12345', + mockRequestSetting, + previousConfig, + undefined, + ); + + // Assert + expect(mockIsAvailable).toHaveBeenCalled(); + expect(mockListSecrets).not.toHaveBeenCalled(); + }); + }); + + describe('promptForSetting', () => { + it.each([ + { + description: + 'should use prompts with type "password" for sensitive settings', + setting: { + name: 'API Key', + description: 'Your secret key', + envVar: 'API_KEY', + sensitive: true, + }, + expectedType: 'password', + promptValue: 'secret-key', + }, + { + description: + 'should use prompts with type "text" for non-sensitive settings', + setting: { + name: 'Username', + description: 'Your public username', + envVar: 'USERNAME', + sensitive: false, + }, + expectedType: 'text', + promptValue: 'test-user', + }, + { + description: 'should default to "text" if sensitive is undefined', + setting: { + name: 'Username', + description: 'Your public username', + envVar: 'USERNAME', + }, + expectedType: 'text', + promptValue: 'test-user', + }, + ])('$description', async ({ setting, expectedType, promptValue }) => { + vi.mocked(prompts).mockResolvedValue({ value: promptValue }); + + const result = await promptForSetting(setting as ExtensionSetting); + + expect(prompts).toHaveBeenCalledWith({ + type: expectedType, + name: 'value', + message: `${setting.name}\n${setting.description}`, + }); + expect(result).toBe(promptValue); + }); + + it('should return undefined if the user cancels the prompt', async () => { + vi.mocked(prompts).mockResolvedValue({ value: undefined }); + const result = await promptForSetting({ + name: 'Test', + description: 'Test desc', + envVar: 'TEST_VAR', + }); + expect(result).toBeUndefined(); + }); + }); + + describe('getScopedEnvContents', () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { name: 's1', description: 'd1', envVar: 'VAR1' }, + { + name: 's2', + description: 'd2', + envVar: 'SENSITIVE_VAR', + sensitive: true, + }, + ], + }; + const extensionId = '12345'; + + it('should return combined contents from user .env and keychain for USER scope', async () => { + const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME); + await fsPromises.writeFile(userEnvPath, 'VAR1=user-value1'); + const userKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext 12345`, + ); + await userKeychain.setSecret('SENSITIVE_VAR', 'user-secret'); + + const contents = await getScopedEnvContents( + config, + extensionId, + ExtensionSettingScope.USER, + tempWorkspaceDir, + ); + + expect(contents).toEqual({ + VAR1: 'user-value1', + SENSITIVE_VAR: 'user-secret', + }); + }); + + it('should return combined contents from workspace .env and keychain for WORKSPACE scope', async () => { + const workspaceEnvPath = path.join( + tempWorkspaceDir, + EXTENSION_SETTINGS_FILENAME, + ); + await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1'); + const workspaceKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`, + ); + await workspaceKeychain.setSecret('SENSITIVE_VAR', 'workspace-secret'); + + const contents = await getScopedEnvContents( + config, + extensionId, + ExtensionSettingScope.WORKSPACE, + tempWorkspaceDir, + ); + + expect(contents).toEqual({ + VAR1: 'workspace-value1', + SENSITIVE_VAR: 'workspace-secret', + }); + }); + + it('should ignore .env if it is a directory', async () => { + const workspaceEnvPath = path.join( + tempWorkspaceDir, + EXTENSION_SETTINGS_FILENAME, + ); + fs.mkdirSync(workspaceEnvPath); + const workspaceKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`, + ); + await workspaceKeychain.setSecret('SENSITIVE_VAR', 'workspace-secret'); + + const contents = await getScopedEnvContents( + config, + extensionId, + ExtensionSettingScope.WORKSPACE, + tempWorkspaceDir, + ); + + expect(contents).toEqual({ + SENSITIVE_VAR: 'workspace-secret', + }); + }); + }); + + describe('getEnvContents (merged)', () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { name: 's1', description: 'd1', envVar: 'VAR1' }, + { name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true }, + { name: 's3', description: 'd3', envVar: 'VAR3' }, + ], + }; + const extensionId = '12345'; + + it('should merge user and workspace settings, with workspace taking precedence', async () => { + // User settings + const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME); + await fsPromises.writeFile( + userEnvPath, + 'VAR1=user-value1\nVAR3=user-value3', + ); + const userKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext ${extensionId}`, + ); + await userKeychain.setSecret('VAR2', 'user-secret2'); + + // Workspace settings + const workspaceEnvPath = path.join( + tempWorkspaceDir, + EXTENSION_SETTINGS_FILENAME, + ); + await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1'); + const workspaceKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext ${extensionId} ${tempWorkspaceDir}`, + ); + await workspaceKeychain.setSecret('VAR2', 'workspace-secret2'); + + const contents = await getEnvContents( + config, + extensionId, + tempWorkspaceDir, + ); + + expect(contents).toEqual({ + VAR1: 'workspace-value1', + VAR2: 'workspace-secret2', + VAR3: 'user-value3', + }); + }); + }); + + describe('updateSetting', () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [ + { name: 's1', description: 'd1', envVar: 'VAR1' }, + { name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true }, + ], + }; + const mockRequestSetting = vi.fn(); + + beforeEach(async () => { + const userEnvPath = path.join(extensionDir, '.env'); + await fsPromises.writeFile(userEnvPath, 'VAR1=value1\n'); + const userKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext 12345`, + ); + await userKeychain.setSecret('VAR2', 'value2'); + mockRequestSetting.mockClear(); + }); + + it('should update a non-sensitive setting in USER scope', async () => { + mockRequestSetting.mockResolvedValue('new-value1'); + + await updateSetting( + config, + '12345', + 'VAR1', + mockRequestSetting, + ExtensionSettingScope.USER, + tempWorkspaceDir, + ); + + const expectedEnvPath = path.join(extensionDir, '.env'); + const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); + expect(actualContent).toContain('VAR1=new-value1'); + }); + + it('should update a non-sensitive setting in WORKSPACE scope', async () => { + mockRequestSetting.mockResolvedValue('new-workspace-value'); + + await updateSetting( + config, + '12345', + 'VAR1', + mockRequestSetting, + ExtensionSettingScope.WORKSPACE, + tempWorkspaceDir, + ); + + const expectedEnvPath = path.join(tempWorkspaceDir, '.env'); + const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); + expect(actualContent).toContain('VAR1=new-workspace-value'); + }); + + it('should throw an error when trying to write to a workspace with a .env directory', async () => { + const workspaceEnvPath = path.join(tempWorkspaceDir, '.env'); + fs.mkdirSync(workspaceEnvPath); + + mockRequestSetting.mockResolvedValue('new-workspace-value'); + + await expect( + updateSetting( + config, + '12345', + 'VAR1', + mockRequestSetting, + ExtensionSettingScope.WORKSPACE, + tempWorkspaceDir, + ), + ).rejects.toThrow( + /Cannot write extension settings to .* because it is a directory./, + ); + }); + + it('should update a sensitive setting in USER scope', async () => { + mockRequestSetting.mockResolvedValue('new-value2'); + + await updateSetting( + config, + '12345', + 'VAR2', + mockRequestSetting, + ExtensionSettingScope.USER, + tempWorkspaceDir, + ); + + const userKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext 12345`, + ); + expect(await userKeychain.getSecret('VAR2')).toBe('new-value2'); + }); + + it('should update a sensitive setting in WORKSPACE scope', async () => { + mockRequestSetting.mockResolvedValue('new-workspace-secret'); + + await updateSetting( + config, + '12345', + 'VAR2', + mockRequestSetting, + ExtensionSettingScope.WORKSPACE, + tempWorkspaceDir, + ); + + const workspaceKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`, + ); + expect(await workspaceKeychain.getSecret('VAR2')).toBe( + 'new-workspace-secret', + ); + }); + + it('should leave existing, unmanaged .env variables intact when updating in WORKSPACE scope', async () => { + // Setup a pre-existing .env file in the workspace with unmanaged variables + const workspaceEnvPath = path.join(tempWorkspaceDir, '.env'); + const originalEnvContent = + 'PROJECT_VAR_1=value_1\nPROJECT_VAR_2=value_2\nVAR1=original-value'; // VAR1 is managed by extension + await fsPromises.writeFile(workspaceEnvPath, originalEnvContent); + + // Simulate updating an extension-managed non-sensitive setting + mockRequestSetting.mockResolvedValue('updated-value'); + await updateSetting( + config, + '12345', + 'VAR1', + mockRequestSetting, + ExtensionSettingScope.WORKSPACE, + tempWorkspaceDir, + ); + + // Read the .env file after update + const actualContent = await fsPromises.readFile( + workspaceEnvPath, + 'utf-8', + ); + + // Assert that original variables are intact and extension variable is updated + expect(actualContent).toContain('PROJECT_VAR_1=value_1'); + expect(actualContent).toContain('PROJECT_VAR_2=value_2'); + expect(actualContent).toContain('VAR1=updated-value'); + + // Ensure no other unexpected changes or deletions + const lines = actualContent.split('\n').filter((line) => line.length > 0); + expect(lines).toHaveLength(3); // Should only have the three variables + }); + + it('should delete a sensitive setting if the new value is empty', async () => { + mockRequestSetting.mockResolvedValue(''); + + await updateSetting( + config, + '12345', + 'VAR2', + mockRequestSetting, + ExtensionSettingScope.USER, + tempWorkspaceDir, + ); + + const userKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext 12345`, + ); + expect(await userKeychain.getSecret('VAR2')).toBeNull(); + }); + + it('should delete a non-sensitive setting if the new value is empty', async () => { + mockRequestSetting.mockResolvedValue(''); + + await updateSetting( + config, + '12345', + 'VAR1', + mockRequestSetting, + ExtensionSettingScope.USER, + tempWorkspaceDir, + ); + + const expectedEnvPath = path.join(extensionDir, '.env'); + const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); + expect(actualContent).not.toContain('VAR1='); + }); + + it('should not throw if deleting a non-existent sensitive setting with empty value', async () => { + mockRequestSetting.mockResolvedValue(''); + // Ensure it doesn't exist first + const userKeychain = new KeychainTokenStorage( + `Gemini CLI Extensions test-ext 12345`, + ); + await userKeychain.deleteSecret('VAR2'); + + await updateSetting( + config, + '12345', + 'VAR2', + mockRequestSetting, + ExtensionSettingScope.USER, + tempWorkspaceDir, + ); + // Should complete without error + }); + + it('should throw error if env var name contains invalid characters', async () => { + const securityConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [{ name: 's2', description: 'd2', envVar: 'VAR-BAD' }], + }; + mockRequestSetting.mockResolvedValue('value'); + + await expect( + updateSetting( + securityConfig, + '12345', + 'VAR-BAD', + mockRequestSetting, + ExtensionSettingScope.USER, + tempWorkspaceDir, + ), + ).rejects.toThrow(/Invalid environment variable name/); + }); + + it('should throw error if env var value contains newlines', async () => { + mockRequestSetting.mockResolvedValue('value\nwith\nnewlines'); + + await expect( + updateSetting( + config, + '12345', + 'VAR1', + mockRequestSetting, + ExtensionSettingScope.USER, + tempWorkspaceDir, + ), + ).rejects.toThrow(/Invalid environment variable value/); + }); + + it('should quote values with spaces', async () => { + mockRequestSetting.mockResolvedValue('value with spaces'); + + await updateSetting( + config, + '12345', + 'VAR1', + mockRequestSetting, + ExtensionSettingScope.USER, + tempWorkspaceDir, + ); + + const expectedEnvPath = path.join(extensionDir, '.env'); + const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); + expect(actualContent).toContain('VAR1="value with spaces"'); + }); + + it('should escape quotes in values', async () => { + mockRequestSetting.mockResolvedValue('value with "quotes"'); + + await updateSetting( + config, + '12345', + 'VAR1', + mockRequestSetting, + ExtensionSettingScope.USER, + tempWorkspaceDir, + ); + + const expectedEnvPath = path.join(extensionDir, '.env'); + const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8'); + expect(actualContent).toContain('VAR1="value with \\"quotes\\""'); + }); + }); +}); diff --git a/packages/cli/src/config/extensions/extensionSettings.ts b/packages/cli/src/config/extensions/extensionSettings.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b1981374e00c1fdadcaf4e989a8d55a6271b214 --- /dev/null +++ b/packages/cli/src/config/extensions/extensionSettings.ts @@ -0,0 +1,389 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as fsSync from 'node:fs'; +import * as dotenv from 'dotenv'; +import * as path from 'node:path'; + +import { ExtensionStorage } from './storage.js'; +import type { ExtensionConfig } from '../extension.js'; + +import prompts from 'prompts'; +import { debugLogger, KeychainTokenStorage } from '@google/gemini-cli-core'; +import { EXTENSION_SETTINGS_FILENAME } from './variables.js'; + +export enum ExtensionSettingScope { + USER = 'user', + WORKSPACE = 'workspace', +} + +export interface ExtensionSetting { + name: string; + description: string; + envVar: string; + // NOTE: If no value is set, this setting will be considered NOT sensitive. + sensitive?: boolean; +} + +const getKeychainStorageName = ( + extensionName: string, + extensionId: string, + scope: ExtensionSettingScope, + workspaceDir?: string, +): string => { + const base = `Gemini CLI Extensions ${extensionName} ${extensionId}`; + if (scope === ExtensionSettingScope.WORKSPACE) { + if (!workspaceDir) { + throw new Error('Workspace directory is required for workspace scope'); + } + return `${base} ${workspaceDir}`; + } + return base; +}; + +export const getEnvFilePath = ( + extensionName: string, + scope: ExtensionSettingScope, + workspaceDir?: string, +): string => { + if (scope === ExtensionSettingScope.WORKSPACE) { + if (!workspaceDir) { + throw new Error('Workspace directory is required for workspace scope'); + } + return path.join(workspaceDir, EXTENSION_SETTINGS_FILENAME); + } + return new ExtensionStorage(extensionName).getEnvFilePath(); +}; + +export async function maybePromptForSettings( + extensionConfig: ExtensionConfig, + extensionId: string, + requestSetting: (setting: ExtensionSetting) => Promise, + previousExtensionConfig?: ExtensionConfig, + previousSettings?: Record, +): Promise { + const { name: extensionName, settings } = extensionConfig; + if ( + (!settings || settings.length === 0) && + (!previousExtensionConfig?.settings || + previousExtensionConfig.settings.length === 0) + ) { + return; + } + // We assume user scope here because we don't have a way to ask the user for scope during the initial setup. + // The user can change the scope later using the `settings set` command. + const scope = ExtensionSettingScope.USER; + const envFilePath = getEnvFilePath(extensionName, scope); + const keychain = new KeychainTokenStorage( + getKeychainStorageName(extensionName, extensionId, scope), + ); + + if (!settings || settings.length === 0) { + await clearSettings(envFilePath, keychain); + return; + } + + const settingsChanges = getSettingsChanges( + settings, + previousExtensionConfig?.settings ?? [], + ); + + const allSettings: Record = { ...previousSettings }; + + for (const removedEnvSetting of settingsChanges.removeEnv) { + delete allSettings[removedEnvSetting.envVar]; + } + + for (const removedSensitiveSetting of settingsChanges.removeSensitive) { + await keychain.deleteSecret(removedSensitiveSetting.envVar); + } + + for (const setting of settingsChanges.promptForSensitive.concat( + settingsChanges.promptForEnv, + )) { + const answer = await requestSetting(setting); + if (answer !== undefined) { + allSettings[setting.envVar] = answer; + } + } + + const nonSensitiveSettings: Record = {}; + for (const setting of settings) { + const value = allSettings[setting.envVar]; + if (value === undefined || value === '') { + continue; + } + if (setting.sensitive) { + await keychain.setSecret(setting.envVar, value); + } else { + nonSensitiveSettings[setting.envVar] = value; + } + } + + const envContent = formatEnvContent(nonSensitiveSettings); + + if (fsSync.existsSync(envFilePath)) { + const stat = fsSync.statSync(envFilePath); + if (stat.isDirectory()) { + throw new Error( + `Cannot write extension settings to ${envFilePath} because it is a directory.`, + ); + } + } + + await fs.writeFile(envFilePath, envContent); +} + +function formatEnvContent(settings: Record): string { + let envContent = ''; + for (const [key, value] of Object.entries(settings)) { + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { + throw new Error( + `Invalid environment variable name: "${key}". Must contain only alphanumeric characters and underscores.`, + ); + } + if (value.includes('\n') || value.includes('\r')) { + throw new Error( + `Invalid environment variable value for "${key}". Values cannot contain newlines.`, + ); + } + const formattedValue = value.includes(' ') + ? `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` + : value; + envContent += `${key}=${formattedValue}\n`; + } + return envContent; +} + +export async function promptForSetting( + setting: ExtensionSetting, +): Promise { + const response = await prompts({ + type: setting.sensitive ? 'password' : 'text', + name: 'value', + message: `${setting.name}\n${setting.description}`, + }); + return typeof response.value === 'string' ? response.value : undefined; +} + +export async function getScopedEnvContents( + extensionConfig: ExtensionConfig, + extensionId: string, + scope: ExtensionSettingScope, + workspaceDir?: string, +): Promise> { + const { name: extensionName } = extensionConfig; + const keychain = new KeychainTokenStorage( + getKeychainStorageName(extensionName, extensionId, scope, workspaceDir), + ); + const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir); + let customEnv: Record = {}; + if (fsSync.existsSync(envFilePath)) { + const stat = fsSync.statSync(envFilePath); + if (!stat.isDirectory()) { + const envFile = fsSync.readFileSync(envFilePath, 'utf-8'); + customEnv = dotenv.parse(envFile); + } + } + + if (extensionConfig.settings) { + for (const setting of extensionConfig.settings) { + if (setting.sensitive) { + const secret = await keychain.getSecret(setting.envVar); + if (secret) { + customEnv[setting.envVar] = secret; + } + } + } + } + return customEnv; +} + +export async function getEnvContents( + extensionConfig: ExtensionConfig, + extensionId: string, + workspaceDir: string, +): Promise> { + if (!extensionConfig.settings || extensionConfig.settings.length === 0) { + return Promise.resolve({}); + } + + const userSettings = await getScopedEnvContents( + extensionConfig, + extensionId, + ExtensionSettingScope.USER, + ); + const workspaceSettings = await getScopedEnvContents( + extensionConfig, + extensionId, + ExtensionSettingScope.WORKSPACE, + workspaceDir, + ); + + return { ...userSettings, ...workspaceSettings }; +} + +export async function updateSetting( + extensionConfig: ExtensionConfig, + extensionId: string, + settingKey: string, + requestSetting: (setting: ExtensionSetting) => Promise, + scope: ExtensionSettingScope, + workspaceDir: string, +): Promise { + const { name: extensionName, settings } = extensionConfig; + if (!settings || settings.length === 0) { + debugLogger.log('This extension does not have any settings.'); + return; + } + + const settingToUpdate = settings.find( + (s) => s.name === settingKey || s.envVar === settingKey, + ); + + if (!settingToUpdate) { + debugLogger.log(`Setting ${settingKey} not found.`); + return; + } + + const newValue = await requestSetting(settingToUpdate); + if (newValue === undefined) { + return; + } + + const keychain = new KeychainTokenStorage( + getKeychainStorageName(extensionName, extensionId, scope, workspaceDir), + ); + + if (settingToUpdate.sensitive) { + if (newValue) { + await keychain.setSecret(settingToUpdate.envVar, newValue); + } else { + try { + await keychain.deleteSecret(settingToUpdate.envVar); + } catch { + // Ignore if secret does not exist + } + } + return; + } + + // For non-sensitive settings, we need to read the existing .env file, + // update the value, and write it back, preserving any other values. + const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir); + let envContent = ''; + if (fsSync.existsSync(envFilePath)) { + const stat = fsSync.statSync(envFilePath); + if (stat.isDirectory()) { + throw new Error( + `Cannot write extension settings to ${envFilePath} because it is a directory.`, + ); + } + envContent = await fs.readFile(envFilePath, 'utf-8'); + } + + const parsedEnv = dotenv.parse(envContent); + if (!newValue) { + delete parsedEnv[settingToUpdate.envVar]; + } else { + parsedEnv[settingToUpdate.envVar] = newValue; + } + + // We only want to write back the variables that are not sensitive. + const nonSensitiveSettings: Record = {}; + const sensitiveEnvVars = new Set( + settings.filter((s) => s.sensitive).map((s) => s.envVar), + ); + for (const [key, value] of Object.entries(parsedEnv)) { + if (!sensitiveEnvVars.has(key)) { + nonSensitiveSettings[key] = value; + } + } + + const newEnvContent = formatEnvContent(nonSensitiveSettings); + await fs.writeFile(envFilePath, newEnvContent); +} + +interface settingsChanges { + promptForSensitive: ExtensionSetting[]; + removeSensitive: ExtensionSetting[]; + promptForEnv: ExtensionSetting[]; + removeEnv: ExtensionSetting[]; +} +function getSettingsChanges( + settings: ExtensionSetting[], + oldSettings: ExtensionSetting[], +): settingsChanges { + const isSameSetting = (a: ExtensionSetting, b: ExtensionSetting) => + a.envVar === b.envVar && (a.sensitive ?? false) === (b.sensitive ?? false); + + const sensitiveOld = oldSettings.filter((s) => s.sensitive ?? false); + const sensitiveNew = settings.filter((s) => s.sensitive ?? false); + const envOld = oldSettings.filter((s) => !(s.sensitive ?? false)); + const envNew = settings.filter((s) => !(s.sensitive ?? false)); + + return { + promptForSensitive: sensitiveNew.filter( + (s) => !sensitiveOld.some((old) => isSameSetting(s, old)), + ), + removeSensitive: sensitiveOld.filter( + (s) => !sensitiveNew.some((neu) => isSameSetting(s, neu)), + ), + promptForEnv: envNew.filter( + (s) => !envOld.some((old) => isSameSetting(s, old)), + ), + removeEnv: envOld.filter( + (s) => !envNew.some((neu) => isSameSetting(s, neu)), + ), + }; +} + +async function clearSettings( + envFilePath: string, + keychain: KeychainTokenStorage, +) { + if (fsSync.existsSync(envFilePath)) { + const stat = fsSync.statSync(envFilePath); + if (!stat.isDirectory()) { + await fs.writeFile(envFilePath, ''); + } + } + if (!(await keychain.isAvailable())) { + return; + } + const secrets = await keychain.listSecrets(); + for (const secret of secrets) { + await keychain.deleteSecret(secret); + } + return; +} + +export async function getMissingSettings( + extensionConfig: ExtensionConfig, + extensionId: string, + workspaceDir: string, +): Promise { + const { settings } = extensionConfig; + if (!settings || settings.length === 0) { + return []; + } + + const existingSettings = await getEnvContents( + extensionConfig, + extensionId, + workspaceDir, + ); + const missingSettings: ExtensionSetting[] = []; + + for (const setting of settings) { + if (existingSettings[setting.envVar] === undefined) { + missingSettings.push(setting); + } + } + + return missingSettings; +} diff --git a/packages/cli/src/config/extensions/extensionUpdates.test.ts b/packages/cli/src/config/extensions/extensionUpdates.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..89282fcd8ac2b41f6f7558f2f971e79362ba9518 --- /dev/null +++ b/packages/cli/src/config/extensions/extensionUpdates.test.ts @@ -0,0 +1,308 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import { getMissingSettings } from './extensionSettings.js'; +import type { ExtensionConfig } from '../extension.js'; +import { + debugLogger, + type ExtensionInstallMetadata, + type GeminiCLIExtension, + coreEvents, +} from '@google/gemini-cli-core'; +import { ExtensionManager } from '../extension-manager.js'; +import { createTestMergedSettings } from '../settings.js'; +import { isWorkspaceTrusted } from '../trustedFolders.js'; + +// --- Mocks --- + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(), + statSync: vi.fn(), + lstatSync: vi.fn(), + realpathSync: vi.fn((p) => p), + promises: { + ...actual.promises, + mkdir: vi.fn(), + readdir: vi.fn(), + writeFile: vi.fn(), + rm: vi.fn(), + cp: vi.fn(), + readFile: vi.fn(), + lstat: vi.fn(), + chmod: vi.fn(), + }, + }; +}); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + KeychainTokenStorage: vi.fn(), + debugLogger: { + warn: vi.fn(), + error: vi.fn(), + log: vi.fn(), + }, + coreEvents: { + emitFeedback: vi.fn(), + on: vi.fn(), + off: vi.fn(), + emitConsoleLog: vi.fn(), + }, + loadSkillsFromDir: vi.fn().mockResolvedValue([]), + loadAgentsFromDirectory: vi + .fn() + .mockResolvedValue({ agents: [], errors: [] }), + logExtensionInstallEvent: vi.fn().mockResolvedValue(undefined), + logExtensionUpdateEvent: vi.fn().mockResolvedValue(undefined), + logExtensionUninstall: vi.fn().mockResolvedValue(undefined), + logExtensionEnable: vi.fn().mockResolvedValue(undefined), + logExtensionDisable: vi.fn().mockResolvedValue(undefined), + Config: vi.fn().mockImplementation(() => ({ + getEnableExtensionReloading: vi.fn().mockReturnValue(true), + })), + KeychainService: class { + isAvailable = vi.fn().mockResolvedValue(true); + getPassword = vi.fn().mockResolvedValue('test-key'); + setPassword = vi.fn().mockResolvedValue(undefined); + }, + ExtensionIntegrityManager: class { + verify = vi.fn().mockResolvedValue('verified'); + store = vi.fn().mockResolvedValue(undefined); + }, + IntegrityDataStatus: { + VERIFIED: 'verified', + MISSING: 'missing', + INVALID: 'invalid', + }, + }; +}); + +vi.mock('./consent.js', () => ({ + maybeRequestConsentOrFail: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('./extensionSettings.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getEnvContents: vi.fn().mockResolvedValue({}), + getMissingSettings: vi.fn(), // We will mock this implementation per test + }; +}); + +vi.mock('../trustedFolders.js', () => ({ + isWorkspaceTrusted: vi.fn().mockReturnValue({ isTrusted: true }), // Default to trusted to simplify flow + loadTrustedFolders: vi.fn().mockReturnValue({ + setValue: vi.fn().mockResolvedValue(undefined), + }), + TrustLevel: { TRUST_FOLDER: 'TRUST_FOLDER' }, +})); + +// Mock ExtensionStorage to avoid real FS paths +vi.mock('./storage.js', () => ({ + ExtensionStorage: class { + constructor(public name: string) {} + getExtensionDir() { + return `/mock/extensions/${this.name}`; + } + static getUserExtensionsDir() { + return '/mock/extensions'; + } + static createTmpDir() { + return Promise.resolve('/mock/tmp'); + } + }, +})); + +vi.mock('os', async (importOriginal) => { + const mockedOs = await importOriginal(); + return { + ...mockedOs, + homedir: vi.fn().mockReturnValue('/mock/home'), + }; +}); + +describe('extensionUpdates', () => { + let tempWorkspaceDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + // Default fs mocks + vi.mocked(fs.promises.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.promises.rm).mockResolvedValue(undefined); + vi.mocked(fs.promises.cp).mockResolvedValue(undefined); + vi.mocked(fs.promises.readdir).mockResolvedValue([]); + vi.mocked(fs.promises.lstat).mockResolvedValue({ + isDirectory: () => true, + mode: 0o755, + } as unknown as fs.Stats); + vi.mocked(fs.promises.chmod).mockResolvedValue(undefined); + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + vi.mocked(getMissingSettings).mockResolvedValue([]); + + // Allow directories to exist by default to satisfy Config/WorkspaceContext checks + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as unknown as fs.Stats); + vi.mocked(fs.lstatSync).mockReturnValue({ + isDirectory: () => true, + } as unknown as fs.Stats); + vi.mocked(fs.realpathSync).mockImplementation((p) => p as string); + + tempWorkspaceDir = '/mock/workspace'; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('ExtensionManager integration', () => { + it('should warn about missing settings after update', async () => { + // 1. Setup Data + const newConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.1.0', + settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }], + }; + + const previousConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + settings: [], + }; + + const installMetadata: ExtensionInstallMetadata = { + source: '/mock/source', + type: 'local', + autoUpdate: true, + }; + + // 2. Setup Manager + const manager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + settings: createTestMergedSettings({ + telemetry: { enabled: false }, + experimental: { extensionConfig: true }, + }), + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: null, + }); + + // 3. Mock Internal Manager Methods + vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig); + vi.spyOn(manager, 'getExtensions').mockReturnValue([ + { + name: 'test-ext', + version: '1.0.0', + installMetadata, + path: '/mock/extensions/test-ext', + contextFiles: [], + mcpServers: {}, + hooks: undefined, + isActive: true, + id: 'test-id', + settings: [], + resolvedSettings: [], + skills: [], + } as unknown as GeminiCLIExtension, + ]); + vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined); + // Mock loadExtension to return something so the method doesn't crash at the end + vi.spyOn(manager, 'loadExtension').mockResolvedValue({ + name: 'test-ext', + version: '1.1.0', + } as unknown as GeminiCLIExtension); + + // 4. Mock External Helpers + // This is the key fix: we explicitly mock `getMissingSettings` to return + // the result we expect, avoiding any real FS or logic execution during the update. + vi.mocked(getMissingSettings).mockResolvedValue([ + { + name: 's1', + description: 'd1', + envVar: 'VAR1', + }, + ]); + + // 5. Execute + await manager.installOrUpdateExtension(installMetadata, previousConfig); + + // 6. Assert + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining( + 'Extension "test-ext" has missing settings: s1', + ), + ); + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'warning', + expect.stringContaining( + 'Please run "gemini extensions config test-ext [setting-name]"', + ), + ); + }); + + it('should store integrity data after update', async () => { + const newConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.1.0', + }; + + const previousConfig: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + }; + + const installMetadata: ExtensionInstallMetadata = { + source: '/mock/source', + type: 'local', + }; + + const manager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + settings: createTestMergedSettings(), + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: null, + }); + + await manager.loadExtensions(); + vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig); + vi.spyOn(manager, 'getExtensions').mockReturnValue([ + { + name: 'test-ext', + version: '1.0.0', + installMetadata, + path: '/mock/extensions/test-ext', + isActive: true, + } as unknown as GeminiCLIExtension, + ]); + vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined); + vi.spyOn(manager, 'loadExtension').mockResolvedValue({ + name: 'test-ext', + version: '1.1.0', + } as unknown as GeminiCLIExtension); + + const storeSpy = vi.spyOn(manager, 'storeExtensionIntegrity'); + + await manager.installOrUpdateExtension(installMetadata, previousConfig); + + expect(storeSpy).toHaveBeenCalledWith('test-ext', installMetadata); + }); + }); +}); diff --git a/packages/cli/src/config/extensions/github.test.ts b/packages/cli/src/config/extensions/github.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6a6d4e5dd819d6285d98575db8211b17ad8f422 --- /dev/null +++ b/packages/cli/src/config/extensions/github.test.ts @@ -0,0 +1,654 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + cloneFromGit, + tryParseGithubUrl, + fetchReleaseFromGithub, + checkForExtensionUpdate, + downloadFromGitHubRelease, + findReleaseAsset, + downloadFile, + extractFile, +} from './github.js'; +import { simpleGit, type SimpleGit } from 'simple-git'; +import { ExtensionUpdateState } from '../../ui/state/extensions.js'; +import * as os from 'node:os'; +import * as fs from 'node:fs'; +import * as https from 'node:https'; +import * as tar from 'tar'; +import * as extract from 'extract-zip'; +import type { ExtensionManager } from '../extension-manager.js'; +import { fetchJson } from './github_fetch.js'; +import { EventEmitter } from 'node:events'; +import type { + GeminiCLIExtension, + ExtensionInstallMetadata, +} from '@google/gemini-cli-core'; +import type { ExtensionConfig } from '../extension.js'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + Storage: { + getGlobalSettingsPath: vi.fn().mockReturnValue('/mock/settings.json'), + getGlobalGeminiDir: vi.fn().mockReturnValue('/mock/.gemini'), + }, + debugLogger: { + error: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock('simple-git'); +vi.mock('node:os'); +vi.mock('node:fs'); +vi.mock('node:https'); +vi.mock('tar'); +vi.mock('extract-zip'); +vi.mock('./github_fetch.js'); +vi.mock('../extension-manager.js'); +// Mock settings.ts to avoid top-level side effects if possible, or just rely on Storage mock +vi.mock('../settings.js', () => ({ + loadSettings: vi.fn(), + USER_SETTINGS_PATH: '/mock/settings.json', +})); + +describe('github.ts', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe('cloneFromGit', () => { + let mockGit: { + clone: ReturnType; + getRemotes: ReturnType; + fetch: ReturnType; + checkout: ReturnType; + listRemote: ReturnType; + revparse: ReturnType; + env: ReturnType; + }; + + beforeEach(() => { + mockGit = { + clone: vi.fn(), + getRemotes: vi.fn(), + fetch: vi.fn(), + checkout: vi.fn(), + listRemote: vi.fn(), + revparse: vi.fn(), + env: vi.fn().mockReturnThis(), + }; + vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit); + }); + + it('should clone, fetch and checkout a repo', async () => { + mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); + + await cloneFromGit( + { + type: 'git', + source: 'https://github.com/owner/repo.git', + ref: 'v1.0.0', + }, + '/dest', + ); + + expect(mockGit.clone).toHaveBeenCalledWith( + 'https://github.com/owner/repo.git', + './', + ['--depth', '1'], + ); + expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'v1.0.0'); + expect(mockGit.checkout).toHaveBeenCalledWith('FETCH_HEAD'); + }); + + it('should throw if no remotes found', async () => { + mockGit.getRemotes.mockResolvedValue([]); + + await expect( + cloneFromGit({ type: 'git', source: 'src' }, '/dest'), + ).rejects.toThrow('Unable to find any remotes'); + }); + + it('should throw on clone error', async () => { + mockGit.clone.mockRejectedValue(new Error('Clone failed')); + + await expect( + cloneFromGit({ type: 'git', source: 'src' }, '/dest'), + ).rejects.toThrow('Failed to clone Git repository'); + }); + }); + + describe('tryParseGithubUrl', () => { + it.each([ + ['https://github.com/owner/repo', 'owner', 'repo'], + ['https://github.com/owner/repo.git', 'owner', 'repo'], + ['git@github.com:owner/repo.git', 'owner', 'repo'], + ['owner/repo', 'owner', 'repo'], + ])('should parse %s to %s/%s', (url, owner, repo) => { + expect(tryParseGithubUrl(url)).toEqual({ owner, repo }); + }); + + it.each([ + 'https://gitlab.com/owner/repo', + 'https://my-git-host.com/owner/group/repo', + 'git@gitlab.com:some-group/some-project/some-repo.git', + ])('should return null for non-GitHub URLs', (url) => { + expect(tryParseGithubUrl(url)).toBeNull(); + }); + + it('should throw for invalid formats', () => { + expect(() => tryParseGithubUrl('invalid')).toThrow( + 'Invalid GitHub repository source', + ); + }); + }); + + describe('fetchReleaseFromGithub', () => { + it('should fetch latest release if no ref provided', async () => { + vi.mocked(fetchJson).mockResolvedValue({ tag_name: 'v1.0.0' }); + + await fetchReleaseFromGithub('owner', 'repo'); + + expect(fetchJson).toHaveBeenCalledWith( + 'https://api.github.com/repos/owner/repo/releases/latest', + ); + }); + + it('should fetch specific ref if provided', async () => { + vi.mocked(fetchJson).mockResolvedValue({ tag_name: 'v1.0.0' }); + + await fetchReleaseFromGithub('owner', 'repo', 'v1.0.0'); + + expect(fetchJson).toHaveBeenCalledWith( + 'https://api.github.com/repos/owner/repo/releases/tags/v1.0.0', + ); + }); + + it('should handle pre-releases if allowed', async () => { + vi.mocked(fetchJson).mockResolvedValueOnce([{ tag_name: 'v1.0.0-beta' }]); + + const result = await fetchReleaseFromGithub( + 'owner', + 'repo', + undefined, + true, + ); + + expect(result).toEqual({ tag_name: 'v1.0.0-beta' }); + }); + + it('should return null if no releases found', async () => { + vi.mocked(fetchJson).mockResolvedValueOnce([]); + + const result = await fetchReleaseFromGithub( + 'owner', + 'repo', + undefined, + true, + ); + + expect(result).toBeNull(); + }); + }); + + describe('checkForExtensionUpdate', () => { + let mockExtensionManager: ExtensionManager; + let mockGit: { + getRemotes: ReturnType; + listRemote: ReturnType; + revparse: ReturnType; + env: ReturnType; + }; + + beforeEach(() => { + mockExtensionManager = { + loadExtensionConfig: vi.fn(), + } as unknown as ExtensionManager; + mockGit = { + getRemotes: vi.fn(), + listRemote: vi.fn(), + revparse: vi.fn(), + env: vi.fn().mockReturnThis(), + }; + vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit); + }); + + it('should return NOT_UPDATABLE for non-git/non-release extensions', async () => { + vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue( + Promise.resolve({ + version: '1.0.0', + } as unknown as ExtensionConfig), + ); + + const linkExt = { + installMetadata: { type: 'link' }, + } as unknown as GeminiCLIExtension; + expect(await checkForExtensionUpdate(linkExt, mockExtensionManager)).toBe( + ExtensionUpdateState.NOT_UPDATABLE, + ); + }); + + it('should return UPDATE_AVAILABLE if git remote hash differs', async () => { + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'url' } }, + ]); + mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD'); + mockGit.revparse.mockResolvedValue('local-hash'); + + const ext = { + path: '/path', + installMetadata: { type: 'git', source: 'url' }, + } as unknown as GeminiCLIExtension; + expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe( + ExtensionUpdateState.UPDATE_AVAILABLE, + ); + }); + + it('should return UP_TO_DATE if git remote hash matches', async () => { + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'url' } }, + ]); + mockGit.listRemote.mockResolvedValue('hash\tHEAD'); + mockGit.revparse.mockResolvedValue('hash'); + + const ext = { + path: '/path', + installMetadata: { type: 'git', source: 'url' }, + } as unknown as GeminiCLIExtension; + expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe( + ExtensionUpdateState.UP_TO_DATE, + ); + }); + + it('should return NOT_UPDATABLE if local extension config cannot be loaded', async () => { + vi.mocked(mockExtensionManager.loadExtensionConfig).mockImplementation( + async () => { + throw new Error('Config not found'); + }, + ); + + const ext = { + name: 'local-ext', + version: '1.0.0', + path: '/path/to/installed/ext', + installMetadata: { type: 'local', source: '/path/to/source/ext' }, + } as unknown as GeminiCLIExtension; + + expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe( + ExtensionUpdateState.NOT_UPDATABLE, + ); + }); + + it('should check migratedTo source if present and return UPDATE_AVAILABLE', async () => { + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'new-url' } }, + ]); + mockGit.listRemote.mockResolvedValue('hash\tHEAD'); + mockGit.revparse.mockResolvedValue('hash'); + + const ext = { + path: '/path', + migratedTo: 'new-url', + installMetadata: { type: 'git', source: 'old-url' }, + } as unknown as GeminiCLIExtension; + expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe( + ExtensionUpdateState.UPDATE_AVAILABLE, + ); + }); + }); + + describe('downloadFromGitHubRelease', () => { + it('should fail if no release data found', async () => { + // Mock fetchJson to throw for latest release check + vi.mocked(fetchJson).mockRejectedValue(new Error('Not found')); + + const result = await downloadFromGitHubRelease( + { + type: 'github-release', + source: 'owner/repo', + ref: 'v1', + } as unknown as ExtensionInstallMetadata, + '/dest', + { owner: 'owner', repo: 'repo' }, + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.failureReason).toBe('failed to fetch release data'); + } + }); + + it('should use correct headers for release assets', async () => { + vi.mocked(fetchJson).mockResolvedValue({ + tag_name: 'v1.0.0', + assets: [{ name: 'asset.tar.gz', url: 'http://asset.url' }], + }); + vi.mocked(os.platform).mockReturnValue('linux'); + vi.mocked(os.arch).mockReturnValue('x64'); + + // Mock https.get and fs.createWriteStream for downloadFile + const mockReq = new EventEmitter(); + const mockRes = + new EventEmitter() as unknown as import('node:http').IncomingMessage; + Object.assign(mockRes, { statusCode: 200, pipe: vi.fn() }); + + vi.mocked(https.get).mockImplementation((url, options, cb) => { + if (typeof options === 'function') { + cb = options; + } + if (cb) cb(mockRes); + return mockReq as unknown as import('node:http').ClientRequest; + }); + + const mockStream = new EventEmitter() as unknown as fs.WriteStream; + Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) }); + vi.mocked(fs.createWriteStream).mockReturnValue(mockStream); + + // Mock fs.promises.readdir to return empty array (no cleanup needed) + vi.mocked(fs.promises.readdir).mockResolvedValue([]); + // Mock fs.promises.unlink + vi.mocked(fs.promises.unlink).mockResolvedValue(undefined); + + const promise = downloadFromGitHubRelease( + { + type: 'github-release', + source: 'owner/repo', + ref: 'v1.0.0', + } as unknown as ExtensionInstallMetadata, + '/dest', + { owner: 'owner', repo: 'repo' }, + ); + + // Wait for downloadFile to be called and stream to be created + await vi.waitUntil( + () => vi.mocked(fs.createWriteStream).mock.calls.length > 0, + ); + + // Trigger stream events to complete download + mockRes.emit('end'); + mockStream.emit('finish'); + + await promise; + + expect(https.get).toHaveBeenCalledWith( + 'http://asset.url', + expect.objectContaining({ + headers: expect.objectContaining({ + Accept: 'application/octet-stream', + }), + }), + expect.anything(), + ); + }); + + it('should use correct headers for source tarballs', async () => { + vi.mocked(fetchJson).mockResolvedValue({ + tag_name: 'v1.0.0', + assets: [], + tarball_url: 'http://tarball.url', + }); + + // Mock https.get and fs.createWriteStream for downloadFile + const mockReq = new EventEmitter(); + const mockRes = + new EventEmitter() as unknown as import('node:http').IncomingMessage; + Object.assign(mockRes, { statusCode: 200, pipe: vi.fn() }); + + vi.mocked(https.get).mockImplementation((url, options, cb) => { + if (typeof options === 'function') { + cb = options; + } + if (cb) cb(mockRes); + return mockReq as unknown as import('node:http').ClientRequest; + }); + + const mockStream = new EventEmitter() as unknown as fs.WriteStream; + Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) }); + vi.mocked(fs.createWriteStream).mockReturnValue(mockStream); + + // Mock fs.promises.readdir to return empty array + vi.mocked(fs.promises.readdir).mockResolvedValue([]); + // Mock fs.promises.unlink + vi.mocked(fs.promises.unlink).mockResolvedValue(undefined); + + const promise = downloadFromGitHubRelease( + { + type: 'github-release', + source: 'owner/repo', + ref: 'v1.0.0', + } as unknown as ExtensionInstallMetadata, + '/dest', + { owner: 'owner', repo: 'repo' }, + ); + + // Wait for downloadFile to be called and stream to be created + await vi.waitUntil( + () => vi.mocked(fs.createWriteStream).mock.calls.length > 0, + ); + + // Trigger stream events to complete download + mockRes.emit('end'); + mockStream.emit('finish'); + + await promise; + + expect(https.get).toHaveBeenCalledWith( + 'http://tarball.url', + expect.objectContaining({ + headers: expect.objectContaining({ + Accept: 'application/vnd.github+json', + }), + }), + expect.anything(), + ); + }); + }); + + describe('findReleaseAsset', () => { + it('should find platform/arch specific asset', () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.mocked(os.arch).mockReturnValue('arm64'); + const assets = [ + { name: 'darwin.arm64.tar.gz', url: 'url1' }, + { name: 'linux.x64.tar.gz', url: 'url2' }, + ]; + expect(findReleaseAsset(assets)).toEqual(assets[0]); + }); + + it('should find generic asset', () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + const assets = [{ name: 'generic.tar.gz', url: 'url' }]; + expect(findReleaseAsset(assets)).toEqual(assets[0]); + }); + }); + + describe('downloadFile', () => { + it('should download file successfully', async () => { + const mockReq = new EventEmitter(); + const mockRes = + new EventEmitter() as unknown as import('node:http').IncomingMessage; + Object.assign(mockRes, { statusCode: 200, pipe: vi.fn() }); + + vi.mocked(https.get).mockImplementation((url, options, cb) => { + if (typeof options === 'function') { + cb = options; + } + if (cb) cb(mockRes); + return mockReq as unknown as import('node:http').ClientRequest; + }); + + const mockStream = new EventEmitter() as unknown as fs.WriteStream; + Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) }); + vi.mocked(fs.createWriteStream).mockReturnValue(mockStream); + + const promise = downloadFile('url', '/dest'); + mockRes.emit('end'); + mockStream.emit('finish'); + + await expect(promise).resolves.toBeUndefined(); + }); + + it('should fail on non-200 status', async () => { + const mockReq = new EventEmitter(); + const mockRes = + new EventEmitter() as unknown as import('node:http').IncomingMessage; + Object.assign(mockRes, { statusCode: 404 }); + + vi.mocked(https.get).mockImplementation((url, options, cb) => { + if (typeof options === 'function') { + cb = options; + } + if (cb) cb(mockRes); + return mockReq as unknown as import('node:http').ClientRequest; + }); + + await expect(downloadFile('url', '/dest')).rejects.toThrow( + 'Request failed with status code 404', + ); + }); + + it('should follow redirects', async () => { + const mockReq = new EventEmitter(); + const mockResRedirect = + new EventEmitter() as unknown as import('node:http').IncomingMessage; + Object.assign(mockResRedirect, { + statusCode: 302, + headers: { location: 'new-url' }, + }); + + const mockResSuccess = + new EventEmitter() as unknown as import('node:http').IncomingMessage; + Object.assign(mockResSuccess, { statusCode: 200, pipe: vi.fn() }); + + vi.mocked(https.get) + .mockImplementationOnce((url, options, cb) => { + if (typeof options === 'function') cb = options; + if (cb) cb(mockResRedirect); + return mockReq as unknown as import('node:http').ClientRequest; + }) + .mockImplementationOnce((url, options, cb) => { + if (typeof options === 'function') cb = options; + if (cb) cb(mockResSuccess); + return mockReq as unknown as import('node:http').ClientRequest; + }); + + const mockStream = new EventEmitter() as unknown as fs.WriteStream; + Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) }); + vi.mocked(fs.createWriteStream).mockReturnValue(mockStream); + + const promise = downloadFile('url', '/dest'); + mockResSuccess.emit('end'); + mockStream.emit('finish'); + + await expect(promise).resolves.toBeUndefined(); + expect(https.get).toHaveBeenCalledTimes(2); + expect(https.get).toHaveBeenLastCalledWith( + 'new-url', + expect.anything(), + expect.anything(), + ); + }); + + it('should fail after too many redirects', async () => { + const mockReq = new EventEmitter(); + const mockResRedirect = + new EventEmitter() as unknown as import('node:http').IncomingMessage; + Object.assign(mockResRedirect, { + statusCode: 302, + headers: { location: 'new-url' }, + }); + + vi.mocked(https.get).mockImplementation((url, options, cb) => { + if (typeof options === 'function') cb = options; + if (cb) cb(mockResRedirect); + return mockReq as unknown as import('node:http').ClientRequest; + }); + + await expect(downloadFile('url', '/dest')).rejects.toThrow( + 'Too many redirects', + ); + }, 10000); // Increase timeout for this test if needed, though with mocks it should be fast + + it('should fail if redirect location is missing', async () => { + const mockReq = new EventEmitter(); + const mockResRedirect = + new EventEmitter() as unknown as import('node:http').IncomingMessage; + Object.assign(mockResRedirect, { + statusCode: 302, + headers: {}, // No location + }); + + vi.mocked(https.get).mockImplementation((url, options, cb) => { + if (typeof options === 'function') cb = options; + if (cb) cb(mockResRedirect); + return mockReq as unknown as import('node:http').ClientRequest; + }); + + await expect(downloadFile('url', '/dest')).rejects.toThrow( + 'Redirect response missing Location header', + ); + }); + + it('should pass custom headers', async () => { + const mockReq = new EventEmitter(); + const mockRes = + new EventEmitter() as unknown as import('node:http').IncomingMessage; + Object.assign(mockRes, { statusCode: 200, pipe: vi.fn() }); + + vi.mocked(https.get).mockImplementation((url, options, cb) => { + if (typeof options === 'function') cb = options; + if (cb) cb(mockRes); + return mockReq as unknown as import('node:http').ClientRequest; + }); + + const mockStream = new EventEmitter() as unknown as fs.WriteStream; + Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) }); + vi.mocked(fs.createWriteStream).mockReturnValue(mockStream); + + const promise = downloadFile('url', '/dest', { + headers: { 'X-Custom': 'value' }, + }); + mockRes.emit('end'); + mockStream.emit('finish'); + + await expect(promise).resolves.toBeUndefined(); + expect(https.get).toHaveBeenCalledWith( + 'url', + expect.objectContaining({ + headers: expect.objectContaining({ 'X-Custom': 'value' }), + }), + expect.anything(), + ); + }); + }); + + describe('extractFile', () => { + it('should extract tar.gz using tar', async () => { + await extractFile('file.tar.gz', '/dest'); + expect(tar.x).toHaveBeenCalled(); + }); + + it('should extract zip using extract-zip', async () => { + vi.mocked(extract.default || extract).mockResolvedValue(undefined); + await extractFile('file.zip', '/dest'); + // Check if extract was called. Note: extract-zip export might be default or named depending on mock + }); + + it('should throw for unsupported extensions', async () => { + await expect(extractFile('file.txt', '/dest')).rejects.toThrow( + 'Unsupported file extension', + ); + }); + }); +}); diff --git a/packages/cli/src/config/extensions/github.ts b/packages/cli/src/config/extensions/github.ts new file mode 100644 index 0000000000000000000000000000000000000000..46d52dd0e4ba7f456a5ec4af0c9c4dea6ede60cc --- /dev/null +++ b/packages/cli/src/config/extensions/github.ts @@ -0,0 +1,576 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { simpleGit } from 'simple-git'; +import { + debugLogger, + getErrorMessage, + getSafeGitEnv, + type ExtensionInstallMetadata, + type GeminiCLIExtension, +} from '@google/gemini-cli-core'; +import { ExtensionUpdateState } from '../../ui/state/extensions.js'; +import * as os from 'node:os'; +import * as https from 'node:https'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as tar from 'tar'; +import extract from 'extract-zip'; +import { fetchJson, getGitHubToken } from './github_fetch.js'; +import type { ExtensionConfig } from '../extension.js'; +import type { ExtensionManager } from '../extension-manager.js'; +import { EXTENSIONS_CONFIG_FILENAME } from './variables.js'; + +/** + * Clones a Git repository to a specified local path. + * @param installMetadata The metadata for the extension to install. + * @param destination The destination path to clone the repository to. + */ +export async function cloneFromGit( + installMetadata: ExtensionInstallMetadata, + destination: string, +): Promise { + try { + const git = simpleGit(destination).env(getSafeGitEnv()); + let sourceUrl = installMetadata.source; + const token = getGitHubToken(); + if (token) { + try { + const parsedUrl = new URL(sourceUrl); + if ( + parsedUrl.protocol === 'https:' && + parsedUrl.hostname === 'github.com' + ) { + if (!parsedUrl.username) { + parsedUrl.username = token; + } + sourceUrl = parsedUrl.toString(); + } + } catch { + // If source is not a valid URL, we don't inject the token. + // We let git handle the source as is. + } + } + await git.clone(sourceUrl, './', ['--depth', '1']); + + const remotes = await git.getRemotes(true); + if (remotes.length === 0) { + throw new Error( + `Unable to find any remotes for repo ${installMetadata.source}`, + ); + } + + const refToFetch = installMetadata.ref || 'HEAD'; + + await git.fetch(remotes[0].name, refToFetch); + + // After fetching, checkout FETCH_HEAD to get the content of the fetched ref. + // This results in a detached HEAD state, which is fine for this purpose. + await git.checkout('FETCH_HEAD'); + } catch (error) { + throw new Error( + `Failed to clone Git repository from ${installMetadata.source} ${getErrorMessage(error)}`, + { + cause: error, + }, + ); + } +} + +export interface GithubRepoInfo { + owner: string; + repo: string; +} + +export function tryParseGithubUrl(source: string): GithubRepoInfo | null { + // Handle SCP-style SSH URLs. + if (source.startsWith('git@')) { + if (source.startsWith('git@github.com:')) { + // It's a GitHub SSH URL, so normalize it for the URL parser. + source = source.replace('git@github.com:', ''); + } else { + // It's another provider's SSH URL (e.g., gitlab), so not a GitHub repo. + return null; + } + } + // Default to a github repo path, so `source` can be just an org/repo + let parsedUrl: URL; + try { + // Use the standard URL constructor for backward compatibility. + parsedUrl = new URL(source, 'https://github.com'); + } catch (e) { + // Throw a TypeError to maintain a consistent error contract for invalid URLs. + // This avoids a breaking change for consumers who might expect a TypeError. + throw new TypeError(`Invalid repo URL: ${source}`, { cause: e }); + } + + if (!parsedUrl) { + throw new Error(`Invalid repo URL: ${source}`); + } + if (parsedUrl?.host !== 'github.com') { + return null; + } + // The pathname should be "/owner/repo". + const parts = parsedUrl?.pathname + .split('/') + // Remove the empty segments, fixes trailing and leading slashes + .filter((part) => part !== ''); + + if (parts?.length !== 2) { + throw new Error( + `Invalid GitHub repository source: ${source}. Expected "owner/repo" or a github repo uri.`, + ); + } + const owner = parts[0]; + const repo = parts[1].replace('.git', ''); + + return { + owner, + repo, + }; +} + +export async function fetchReleaseFromGithub( + owner: string, + repo: string, + ref?: string, + allowPreRelease?: boolean, +): Promise { + if (ref) { + return fetchJson( + `https://api.github.com/repos/${owner}/${repo}/releases/tags/${ref}`, + ); + } + + if (!allowPreRelease) { + // Grab the release that is tagged as the "latest", github does not allow + // this to be a pre-release so we can blindly grab it. + try { + return await fetchJson( + `https://api.github.com/repos/${owner}/${repo}/releases/latest`, + ); + } catch { + // This can fail if there is no release marked latest. In that case + // we want to just try the pre-release logic below. + } + } + + // If pre-releases are allowed, we just grab the most recent release. + const releases = await fetchJson( + `https://api.github.com/repos/${owner}/${repo}/releases?per_page=1`, + ); + if (releases.length === 0) { + return null; + } + return releases[0]; +} + +export async function checkForExtensionUpdate( + extension: GeminiCLIExtension, + extensionManager: ExtensionManager, +): Promise { + const installMetadata = extension.installMetadata; + if (installMetadata?.type === 'local') { + let latestConfig: ExtensionConfig | undefined; + try { + latestConfig = await extensionManager.loadExtensionConfig( + installMetadata.source, + ); + } catch (e) { + debugLogger.warn( + `Failed to check for update for local extension "${extension.name}". Could not load extension from source path: ${installMetadata.source}. Error: ${getErrorMessage(e)}`, + ); + return ExtensionUpdateState.NOT_UPDATABLE; + } + + if (!latestConfig) { + debugLogger.warn( + `Failed to check for update for local extension "${extension.name}". Could not load extension from source path: ${installMetadata.source}`, + ); + return ExtensionUpdateState.NOT_UPDATABLE; + } + if (latestConfig.version !== extension.version) { + return ExtensionUpdateState.UPDATE_AVAILABLE; + } + return ExtensionUpdateState.UP_TO_DATE; + } + if ( + !installMetadata || + (installMetadata.type !== 'git' && + installMetadata.type !== 'github-release') + ) { + return ExtensionUpdateState.NOT_UPDATABLE; + } + + if (extension.migratedTo) { + const migratedState = await checkForExtensionUpdate( + { + ...extension, + installMetadata: { ...installMetadata, source: extension.migratedTo }, + migratedTo: undefined, + }, + extensionManager, + ); + if ( + migratedState === ExtensionUpdateState.UPDATE_AVAILABLE || + migratedState === ExtensionUpdateState.UP_TO_DATE + ) { + return ExtensionUpdateState.UPDATE_AVAILABLE; + } + } + + try { + if (installMetadata.type === 'git') { + const git = simpleGit(extension.path).env(getSafeGitEnv()); + const remotes = await git.getRemotes(true); + if (remotes.length === 0) { + debugLogger.error('No git remotes found.'); + return ExtensionUpdateState.ERROR; + } + const remoteUrl = remotes[0].refs.fetch; + if (!remoteUrl) { + debugLogger.error( + `No fetch URL found for git remote ${remotes[0].name}.`, + ); + return ExtensionUpdateState.ERROR; + } + + // Determine the ref to check on the remote. + const refToCheck = installMetadata.ref || 'HEAD'; + + const lsRemoteOutput = await git.listRemote([remoteUrl, refToCheck]); + + if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') { + debugLogger.error(`Git ref ${refToCheck} not found.`); + return ExtensionUpdateState.ERROR; + } + + const remoteHash = lsRemoteOutput.split('\t')[0]; + const localHash = await git.revparse(['HEAD']); + + if (!remoteHash) { + debugLogger.error( + `Unable to parse hash from git ls-remote output "${lsRemoteOutput}"`, + ); + return ExtensionUpdateState.ERROR; + } + if (remoteHash === localHash) { + return ExtensionUpdateState.UP_TO_DATE; + } + return ExtensionUpdateState.UPDATE_AVAILABLE; + } else { + const { source, releaseTag } = installMetadata; + if (!source) { + debugLogger.error(`No "source" provided for extension.`); + return ExtensionUpdateState.ERROR; + } + const repoInfo = tryParseGithubUrl(source); + if (!repoInfo) { + debugLogger.error( + `Source is not a valid GitHub repository for release checks: ${source}`, + ); + return ExtensionUpdateState.ERROR; + } + const { owner, repo } = repoInfo; + + const releaseData = await fetchReleaseFromGithub( + owner, + repo, + installMetadata.ref, + installMetadata.allowPreRelease, + ); + if (!releaseData) { + return ExtensionUpdateState.ERROR; + } + if (releaseData.tag_name !== releaseTag) { + return ExtensionUpdateState.UPDATE_AVAILABLE; + } + return ExtensionUpdateState.UP_TO_DATE; + } + } catch (error) { + debugLogger.error( + `Failed to check for updates for extension "${installMetadata.source}": ${getErrorMessage(error)}`, + ); + return ExtensionUpdateState.ERROR; + } +} + +export type GitHubDownloadResult = + | { + tagName?: string; + type: 'git' | 'github-release'; + success: false; + failureReason: + | 'failed to fetch release data' + | 'no release data' + | 'no release asset found' + | 'failed to download asset' + | 'failed to extract asset' + | 'unknown'; + errorMessage: string; + } + | { + tagName?: string; + type: 'git' | 'github-release'; + success: true; + }; +export async function downloadFromGitHubRelease( + installMetadata: ExtensionInstallMetadata, + destination: string, + githubRepoInfo: GithubRepoInfo, +): Promise { + const { ref, allowPreRelease: preRelease } = installMetadata; + const { owner, repo } = githubRepoInfo; + let releaseData: GithubReleaseData | null = null; + + try { + try { + releaseData = await fetchReleaseFromGithub(owner, repo, ref, preRelease); + if (!releaseData) { + return { + failureReason: 'no release data', + success: false, + type: 'github-release', + errorMessage: `No release data found for ${owner}/${repo} at tag ${ref}`, + }; + } + } catch (error) { + return { + failureReason: 'failed to fetch release data', + success: false, + type: 'github-release', + errorMessage: `Failed to fetch release data for ${owner}/${repo} at tag ${ref}: ${getErrorMessage(error)}`, + }; + } + + const asset = findReleaseAsset(releaseData.assets); + let archiveUrl: string | undefined; + let isTar = false; + let isZip = false; + let fileName: string | undefined; + + if (asset) { + archiveUrl = asset.url; + fileName = asset.name; + } else { + if (releaseData.tarball_url) { + archiveUrl = releaseData.tarball_url; + isTar = true; + } else if (releaseData.zipball_url) { + archiveUrl = releaseData.zipball_url; + isZip = true; + } + } + if (!archiveUrl) { + return { + failureReason: 'no release asset found', + success: false, + type: 'github-release', + tagName: releaseData.tag_name, + errorMessage: `No assets found for release with tag ${releaseData.tag_name}`, + }; + } + if (!fileName) { + fileName = path.basename(new URL(archiveUrl).pathname); + } + let downloadedAssetPath = path.join(destination, fileName); + if (isTar && !downloadedAssetPath.endsWith('.tar.gz')) { + downloadedAssetPath += '.tar.gz'; + } else if (isZip && !downloadedAssetPath.endsWith('.zip')) { + downloadedAssetPath += '.zip'; + } + + try { + // GitHub API requires different Accept headers for different types of downloads: + // 1. Binary Assets (e.g. release artifacts): Require 'application/octet-stream' to return the raw content. + // 2. Source Tarballs (e.g. /tarball/{ref}): Require 'application/vnd.github+json' (or similar) to return + // a 302 Redirect to the actual download location (codeload.github.com). + // Sending 'application/octet-stream' for tarballs results in a 415 Unsupported Media Type error. + const headers = { + ...(asset + ? { Accept: 'application/octet-stream' } + : { Accept: 'application/vnd.github+json' }), + }; + await downloadFile(archiveUrl, downloadedAssetPath, { headers }); + } catch (error) { + return { + failureReason: 'failed to download asset', + success: false, + type: 'github-release', + tagName: releaseData.tag_name, + errorMessage: `Failed to download asset from ${archiveUrl}: ${getErrorMessage(error)}`, + }; + } + + try { + await extractFile(downloadedAssetPath, destination); + } catch (error) { + return { + failureReason: 'failed to extract asset', + success: false, + type: 'github-release', + tagName: releaseData.tag_name, + errorMessage: `Failed to extract asset from ${downloadedAssetPath}: ${getErrorMessage(error)}`, + }; + } + + // For regular github releases, the repository is put inside of a top level + // directory. In this case we should see exactly two file in the destination + // dir, the archive and the directory. If we see that, validate that the + // dir has a gemini extension configuration file and then move all files + // from the directory up one level into the destination directory. + const entries = await fs.promises.readdir(destination, { + withFileTypes: true, + }); + if (entries.length === 2) { + const lonelyDir = entries.find((entry) => entry.isDirectory()); + if ( + lonelyDir && + fs.existsSync( + path.join(destination, lonelyDir.name, EXTENSIONS_CONFIG_FILENAME), + ) + ) { + const dirPathToExtract = path.join(destination, lonelyDir.name); + const extractedDirFiles = await fs.promises.readdir(dirPathToExtract); + for (const file of extractedDirFiles) { + await fs.promises.rename( + path.join(dirPathToExtract, file), + path.join(destination, file), + ); + } + await fs.promises.rmdir(dirPathToExtract); + } + } + + await fs.promises.unlink(downloadedAssetPath); + return { + tagName: releaseData.tag_name, + type: 'github-release', + success: true, + }; + } catch (error) { + return { + failureReason: 'unknown', + success: false, + type: 'github-release', + tagName: releaseData?.tag_name, + errorMessage: `Failed to download release from ${installMetadata.source}: ${getErrorMessage(error)}`, + }; + } +} + +interface GithubReleaseData { + assets: Asset[]; + tag_name: string; + tarball_url?: string; + zipball_url?: string; +} + +interface Asset { + name: string; + url: string; +} + +export function findReleaseAsset(assets: Asset[]): Asset | undefined { + const platform = os.platform(); + const arch = os.arch(); + + const platformArchPrefix = `${platform}.${arch}.`; + const platformPrefix = `${platform}.`; + + // Check for platform + architecture specific asset + const platformArchAsset = assets.find((asset) => + asset.name.toLowerCase().startsWith(platformArchPrefix), + ); + if (platformArchAsset) { + return platformArchAsset; + } + + // Check for platform specific asset + const platformAsset = assets.find((asset) => + asset.name.toLowerCase().startsWith(platformPrefix), + ); + if (platformAsset) { + return platformAsset; + } + + // Check for generic asset if only one is available + const genericAsset = assets.find( + (asset) => + !asset.name.toLowerCase().includes('darwin') && + !asset.name.toLowerCase().includes('linux') && + !asset.name.toLowerCase().includes('win32'), + ); + if (assets.length === 1) { + return genericAsset; + } + + return undefined; +} + +export interface DownloadOptions { + headers?: Record; +} + +export async function downloadFile( + url: string, + dest: string, + options?: DownloadOptions, + redirectCount: number = 0, +): Promise { + const headers: Record = { + 'User-agent': 'gemini-cli', + Accept: 'application/octet-stream', + ...options?.headers, + }; + const token = getGitHubToken(); + if (token) { + headers['Authorization'] = `token ${token}`; + } + + return new Promise((resolve, reject) => { + https + .get(url, { headers }, (res) => { + if (res.statusCode === 302 || res.statusCode === 301) { + if (redirectCount >= 10) { + return reject(new Error('Too many redirects')); + } + + if (!res.headers.location) { + return reject( + new Error('Redirect response missing Location header'), + ); + } + downloadFile(res.headers.location, dest, options, redirectCount + 1) + .then(resolve) + .catch(reject); + return; + } + if (res.statusCode !== 200) { + return reject( + new Error(`Request failed with status code ${res.statusCode}`), + ); + } + const file = fs.createWriteStream(dest); + res.pipe(file); + file.on('finish', () => file.close(resolve as () => void)); + }) + .on('error', reject); + }); +} + +export async function extractFile(file: string, dest: string): Promise { + if (file.endsWith('.tar.gz')) { + await tar.x({ + file, + cwd: dest, + }); + } else if (file.endsWith('.zip')) { + await extract(file, { dir: dest }); + } else { + throw new Error(`Unsupported file extension for extraction: ${file}`); + } +} diff --git a/packages/cli/src/config/extensions/github_fetch.test.ts b/packages/cli/src/config/extensions/github_fetch.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c1eca8c9c7f56068d4503abb436af0bb5f2403d7 --- /dev/null +++ b/packages/cli/src/config/extensions/github_fetch.test.ts @@ -0,0 +1,201 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import * as https from 'node:https'; +import { EventEmitter } from 'node:events'; +import { fetchJson, getGitHubToken } from './github_fetch.js'; +import type { ClientRequest, IncomingMessage } from 'node:http'; + +vi.mock('node:https'); + +describe('getGitHubToken', () => { + const originalToken = process.env['GITHUB_TOKEN']; + + afterEach(() => { + if (originalToken) { + process.env['GITHUB_TOKEN'] = originalToken; + } else { + delete process.env['GITHUB_TOKEN']; + } + }); + + it('should return the token if GITHUB_TOKEN is set', () => { + process.env['GITHUB_TOKEN'] = 'test-token'; + expect(getGitHubToken()).toBe('test-token'); + }); + + it('should return undefined if GITHUB_TOKEN is not set', () => { + delete process.env['GITHUB_TOKEN']; + expect(getGitHubToken()).toBeUndefined(); + }); +}); + +describe('fetchJson', () => { + const getMock = vi.mocked(https.get); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should fetch and parse JSON successfully', async () => { + getMock.mockImplementationOnce((_url, _options, callback) => { + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 200; + (callback as (res: IncomingMessage) => void)(res); + res.emit('data', Buffer.from('{"foo":')); + res.emit('data', Buffer.from('"bar"}')); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + await expect(fetchJson('https://example.com/data.json')).resolves.toEqual({ + foo: 'bar', + }); + }); + + it('should handle redirects (301 and 302)', async () => { + // Test 302 + getMock.mockImplementationOnce((_url, _options, callback) => { + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 302; + res.headers = { location: 'https://example.com/final' }; + res.resume = vi.fn(); + (callback as (res: IncomingMessage) => void)(res); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + getMock.mockImplementationOnce((url, _options, callback) => { + expect(url).toBe('https://example.com/final'); + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 200; + (callback as (res: IncomingMessage) => void)(res); + res.emit('data', Buffer.from('{"success": true}')); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + + await expect(fetchJson('https://example.com/redirect')).resolves.toEqual({ + success: true, + }); + + // Test 301 + getMock.mockImplementationOnce((_url, _options, callback) => { + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 301; + res.headers = { location: 'https://example.com/final-permanent' }; + res.resume = vi.fn(); + (callback as (res: IncomingMessage) => void)(res); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + getMock.mockImplementationOnce((url, _options, callback) => { + expect(url).toBe('https://example.com/final-permanent'); + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 200; + (callback as (res: IncomingMessage) => void)(res); + res.emit('data', Buffer.from('{"permanent": true}')); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + + await expect( + fetchJson('https://example.com/redirect-perm'), + ).resolves.toEqual({ permanent: true }); + }); + + it('should reject on non-200/30x status code', async () => { + getMock.mockImplementationOnce((_url, _options, callback) => { + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 404; + (callback as (res: IncomingMessage) => void)(res); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + + await expect(fetchJson('https://example.com/error')).rejects.toThrow( + 'Request failed with status code 404', + ); + }); + + it('should reject on request error', async () => { + const error = new Error('Network error'); + getMock.mockImplementationOnce(() => { + const req = new EventEmitter() as ClientRequest; + req.emit('error', error); + return req; + }); + + await expect(fetchJson('https://example.com/error')).rejects.toThrow( + 'Network error', + ); + }); + + describe('with GITHUB_TOKEN', () => { + const originalToken = process.env['GITHUB_TOKEN']; + + beforeEach(() => { + process.env['GITHUB_TOKEN'] = 'my-secret-token'; + }); + + afterEach(() => { + if (originalToken) { + process.env['GITHUB_TOKEN'] = originalToken; + } else { + delete process.env['GITHUB_TOKEN']; + } + }); + + it('should include Authorization header if token is present', async () => { + getMock.mockImplementationOnce((_url, options, callback) => { + expect(options.headers).toEqual({ + 'User-Agent': 'gemini-cli', + Authorization: 'token my-secret-token', + }); + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 200; + (callback as (res: IncomingMessage) => void)(res); + res.emit('data', Buffer.from('{"foo": "bar"}')); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + await expect(fetchJson('https://api.github.com/user')).resolves.toEqual({ + foo: 'bar', + }); + }); + }); + + describe('without GITHUB_TOKEN', () => { + const originalToken = process.env['GITHUB_TOKEN']; + + beforeEach(() => { + delete process.env['GITHUB_TOKEN']; + }); + + afterEach(() => { + if (originalToken) { + process.env['GITHUB_TOKEN'] = originalToken; + } + }); + + it('should not include Authorization header if token is not present', async () => { + getMock.mockImplementationOnce((_url, options, callback) => { + expect(options.headers).toEqual({ + 'User-Agent': 'gemini-cli', + }); + const res = new EventEmitter() as IncomingMessage; + res.statusCode = 200; + (callback as (res: IncomingMessage) => void)(res); + res.emit('data', Buffer.from('{"foo": "bar"}')); + res.emit('end'); + return new EventEmitter() as ClientRequest; + }); + + await expect(fetchJson('https://api.github.com/user')).resolves.toEqual({ + foo: 'bar', + }); + }); + }); +}); diff --git a/packages/cli/src/config/extensions/github_fetch.ts b/packages/cli/src/config/extensions/github_fetch.ts new file mode 100644 index 0000000000000000000000000000000000000000..7afd235605c3a0c02870039eef401b9feb8bacaa --- /dev/null +++ b/packages/cli/src/config/extensions/github_fetch.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as https from 'node:https'; + +export function getGitHubToken(): string | undefined { + return process.env['GITHUB_TOKEN']; +} + +export async function fetchJson( + url: string, + redirectCount: number = 0, +): Promise { + const headers: { 'User-Agent': string; Authorization?: string } = { + 'User-Agent': 'gemini-cli', + }; + const token = getGitHubToken(); + if (token) { + headers.Authorization = `token ${token}`; + } + return new Promise((resolve, reject) => { + https + .get(url, { headers }, (res) => { + if (res.statusCode === 302 || res.statusCode === 301) { + if (redirectCount >= 10) { + return reject(new Error('Too many redirects')); + } + if (!res.headers.location) { + return reject(new Error('No location header in redirect response')); + } + res.resume(); + fetchJson( + new URL(res.headers.location, url).toString(), + redirectCount + 1, + ) + .then(resolve) + .catch(reject); + return; + } + if (res.statusCode !== 200) { + return reject( + new Error(`Request failed with status code ${res.statusCode}`), + ); + } + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const data = Buffer.concat(chunks).toString(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + resolve(JSON.parse(data) as T); + }); + }) + .on('error', reject); + }); +} diff --git a/packages/cli/src/config/extensions/storage.test.ts b/packages/cli/src/config/extensions/storage.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d55e0ca0d7f25a429acdf36acc138ab58a6e0431 --- /dev/null +++ b/packages/cli/src/config/extensions/storage.test.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ExtensionStorage } from './storage.js'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { + EXTENSION_SETTINGS_FILENAME, + EXTENSIONS_CONFIG_FILENAME, +} from './variables.js'; +import { Storage } from '@google/gemini-cli-core'; + +vi.mock('node:os'); +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + promises: { + ...actual.promises, + mkdtemp: vi.fn(), + }, + }; +}); +vi.mock('@google/gemini-cli-core'); + +describe('ExtensionStorage', () => { + const mockHomeDir = '/mock/home'; + const extensionName = 'test-extension'; + let storage: ExtensionStorage; + + beforeEach(() => { + vi.mocked(os.homedir).mockReturnValue(mockHomeDir); + vi.mocked(Storage).mockImplementation( + () => + ({ + getExtensionsDir: () => + path.join(mockHomeDir, '.gemini', 'extensions'), + }) as any, // eslint-disable-line @typescript-eslint/no-explicit-any + ); + storage = new ExtensionStorage(extensionName); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return the correct extension directory', () => { + const expectedDir = path.join( + mockHomeDir, + '.gemini', + 'extensions', + extensionName, + ); + expect(storage.getExtensionDir()).toBe(expectedDir); + }); + + it('should return the correct config path', () => { + const expectedPath = path.join( + mockHomeDir, + '.gemini', + 'extensions', + extensionName, + EXTENSIONS_CONFIG_FILENAME, // EXTENSIONS_CONFIG_FILENAME + ); + expect(storage.getConfigPath()).toBe(expectedPath); + }); + + it('should return the correct env file path', () => { + const expectedPath = path.join( + mockHomeDir, + '.gemini', + 'extensions', + extensionName, + EXTENSION_SETTINGS_FILENAME, // EXTENSION_SETTINGS_FILENAME + ); + expect(storage.getEnvFilePath()).toBe(expectedPath); + }); + + it('should return the correct user extensions directory', () => { + const expectedDir = path.join(mockHomeDir, '.gemini', 'extensions'); + expect(ExtensionStorage.getUserExtensionsDir()).toBe(expectedDir); + }); + + it('should create a temporary directory', async () => { + const mockTmpDir = '/tmp/gemini-extension-123'; + vi.mocked(fs.promises.mkdtemp).mockResolvedValue(mockTmpDir); + vi.mocked(os.tmpdir).mockReturnValue('/tmp'); + + const result = await ExtensionStorage.createTmpDir(); + + expect(fs.promises.mkdtemp).toHaveBeenCalledWith( + path.join('/tmp', 'gemini-extension'), + ); + expect(result).toBe(mockTmpDir); + }); +}); diff --git a/packages/cli/src/config/extensions/storage.ts b/packages/cli/src/config/extensions/storage.ts new file mode 100644 index 0000000000000000000000000000000000000000..c1cb147e24707633f90aab6a709af6c2f87d8c60 --- /dev/null +++ b/packages/cli/src/config/extensions/storage.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import { + EXTENSION_SETTINGS_FILENAME, + EXTENSIONS_CONFIG_FILENAME, +} from './variables.js'; +import { Storage, homedir } from '@google/gemini-cli-core'; + +export class ExtensionStorage { + private readonly extensionName: string; + + constructor(extensionName: string) { + this.extensionName = extensionName; + } + + getExtensionDir(): string { + return path.join( + ExtensionStorage.getUserExtensionsDir(), + this.extensionName, + ); + } + + getConfigPath(): string { + return path.join(this.getExtensionDir(), EXTENSIONS_CONFIG_FILENAME); + } + + getEnvFilePath(): string { + return path.join(this.getExtensionDir(), EXTENSION_SETTINGS_FILENAME); + } + + static getUserExtensionsDir(): string { + return new Storage(homedir()).getExtensionsDir(); + } + + static async createTmpDir(): Promise { + return fs.promises.mkdtemp(path.join(os.tmpdir(), 'gemini-extension')); + } +} diff --git a/packages/cli/src/config/extensions/update.test.ts b/packages/cli/src/config/extensions/update.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a0a959bebd32fd0e3c78817a93465aa0da699a09 --- /dev/null +++ b/packages/cli/src/config/extensions/update.test.ts @@ -0,0 +1,480 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + updateExtension, + updateAllUpdatableExtensions, + checkForAllExtensionUpdates, +} from './update.js'; +import { + ExtensionUpdateState, + type ExtensionUpdateStatus, +} from '../../ui/state/extensions.js'; +import { ExtensionStorage } from './storage.js'; +import { type ExtensionManager, copyExtension } from '../extension-manager.js'; +import { checkForExtensionUpdate } from './github.js'; +import { loadInstallMetadata } from '../extension.js'; +import * as fs from 'node:fs'; +import { + type GeminiCLIExtension, + type ExtensionInstallMetadata, + IntegrityDataStatus, +} from '@google/gemini-cli-core'; + +vi.mock('./storage.js', () => ({ + ExtensionStorage: { + createTmpDir: vi.fn(), + }, +})); + +vi.mock('../extension-manager.js', () => ({ + copyExtension: vi.fn(), + // We don't need to mock the class implementation if we pass a mock instance +})); + +vi.mock('./github.js', () => ({ + checkForExtensionUpdate: vi.fn(), +})); + +vi.mock('../extension.js', () => ({ + loadInstallMetadata: vi.fn(), +})); + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + promises: { + ...actual.promises, + rm: vi.fn(), + }, + }; +}); + +describe('Extension Update Logic', () => { + let mockExtensionManager: ExtensionManager; + let mockDispatch: ReturnType; + const mockExtension: GeminiCLIExtension = { + name: 'test-extension', + version: '1.0.0', + path: '/path/to/extension', + } as GeminiCLIExtension; + + beforeEach(() => { + vi.clearAllMocks(); + mockExtensionManager = { + loadExtensionConfig: vi.fn().mockResolvedValue({ + name: 'test-extension', + version: '1.0.0', + }), + installOrUpdateExtension: vi.fn().mockResolvedValue({ + ...mockExtension, + version: '1.1.0', + }), + verifyExtensionIntegrity: vi + .fn() + .mockResolvedValue(IntegrityDataStatus.VERIFIED), + storeExtensionIntegrity: vi.fn().mockResolvedValue(undefined), + } as unknown as ExtensionManager; + mockDispatch = vi.fn(); + + // Default mock behaviors + vi.mocked(ExtensionStorage.createTmpDir).mockResolvedValue('/tmp/mock-dir'); + vi.mocked(loadInstallMetadata).mockReturnValue({ + source: 'https://example.com/repo.git', + type: 'git', + }); + }); + + describe('updateExtension', () => { + it('should return undefined if state is already UPDATING', async () => { + const result = await updateExtension( + mockExtension, + mockExtensionManager, + ExtensionUpdateState.UPDATING, + mockDispatch, + ); + expect(result).toBeUndefined(); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('should throw error and set state to ERROR if install metadata type is unknown', async () => { + vi.mocked(loadInstallMetadata).mockReturnValue({ + type: undefined, + } as unknown as ExtensionInstallMetadata); + + await expect( + updateExtension( + mockExtension, + mockExtensionManager, + ExtensionUpdateState.UPDATE_AVAILABLE, + mockDispatch, + ), + ).rejects.toThrow('type is unknown'); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: mockExtension.name, + state: ExtensionUpdateState.UPDATING, + }, + }); + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: mockExtension.name, + state: ExtensionUpdateState.ERROR, + }, + }); + }); + + it('should throw error and set state to UP_TO_DATE if extension is linked', async () => { + vi.mocked(loadInstallMetadata).mockReturnValue({ + type: 'link', + source: '', + }); + + await expect( + updateExtension( + mockExtension, + mockExtensionManager, + ExtensionUpdateState.UPDATE_AVAILABLE, + mockDispatch, + ), + ).rejects.toThrow('Extension is linked'); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: mockExtension.name, + state: ExtensionUpdateState.UP_TO_DATE, + }, + }); + }); + + it('should successfully update extension and set state to UPDATED_NEEDS_RESTART by default', async () => { + vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue( + Promise.resolve({ + name: 'test-extension', + version: '1.0.0', + }), + ); + vi.mocked( + mockExtensionManager.installOrUpdateExtension, + ).mockResolvedValue({ + ...mockExtension, + version: '1.1.0', + }); + + const result = await updateExtension( + mockExtension, + mockExtensionManager, + ExtensionUpdateState.UPDATE_AVAILABLE, + mockDispatch, + ); + + expect(mockExtensionManager.installOrUpdateExtension).toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: mockExtension.name, + state: ExtensionUpdateState.UPDATED_NEEDS_RESTART, + }, + }); + expect(result).toEqual({ + name: 'test-extension', + originalVersion: '1.0.0', + updatedVersion: '1.1.0', + }); + expect(fs.promises.rm).toHaveBeenCalledWith('/tmp/mock-dir', { + recursive: true, + force: true, + }); + }); + + it('should migrate source if migratedTo is set and an update is available', async () => { + vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue( + Promise.resolve({ + name: 'test-extension', + version: '1.0.0', + }), + ); + vi.mocked( + mockExtensionManager.installOrUpdateExtension, + ).mockResolvedValue({ + ...mockExtension, + version: '1.1.0', + }); + vi.mocked(checkForExtensionUpdate).mockResolvedValue( + ExtensionUpdateState.UPDATE_AVAILABLE, + ); + + const extensionWithMigratedTo = { + ...mockExtension, + migratedTo: 'https://new-source.com/repo.git', + }; + + await updateExtension( + extensionWithMigratedTo, + mockExtensionManager, + ExtensionUpdateState.UPDATE_AVAILABLE, + mockDispatch, + ); + + expect(checkForExtensionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + installMetadata: expect.objectContaining({ + source: 'https://new-source.com/repo.git', + }), + }), + mockExtensionManager, + ); + + expect( + mockExtensionManager.installOrUpdateExtension, + ).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'https://new-source.com/repo.git', + }), + expect.anything(), + ); + }); + + it('should set state to UPDATED if enableExtensionReloading is true', async () => { + vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue( + Promise.resolve({ + name: 'test-extension', + version: '1.0.0', + }), + ); + vi.mocked( + mockExtensionManager.installOrUpdateExtension, + ).mockResolvedValue({ + ...mockExtension, + version: '1.1.0', + }); + + await updateExtension( + mockExtension, + mockExtensionManager, + ExtensionUpdateState.UPDATE_AVAILABLE, + mockDispatch, + true, // enableExtensionReloading + ); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: mockExtension.name, + state: ExtensionUpdateState.UPDATED, + }, + }); + }); + + it('should rollback and set state to ERROR if installation fails', async () => { + vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue( + Promise.resolve({ + name: 'test-extension', + version: '1.0.0', + }), + ); + vi.mocked( + mockExtensionManager.installOrUpdateExtension, + ).mockRejectedValue(new Error('Install failed')); + + await expect( + updateExtension( + mockExtension, + mockExtensionManager, + ExtensionUpdateState.UPDATE_AVAILABLE, + mockDispatch, + ), + ).rejects.toThrow('Updated extension not found after installation'); + + expect(copyExtension).toHaveBeenCalledWith( + '/tmp/mock-dir', + mockExtension.path, + ); + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: mockExtension.name, + state: ExtensionUpdateState.ERROR, + }, + }); + expect(fs.promises.rm).toHaveBeenCalled(); + }); + + describe('Integrity Verification', () => { + it('should fail update with security alert if integrity is invalid', async () => { + vi.mocked( + mockExtensionManager.verifyExtensionIntegrity, + ).mockResolvedValue(IntegrityDataStatus.INVALID); + + await expect( + updateExtension( + mockExtension, + mockExtensionManager, + ExtensionUpdateState.UPDATE_AVAILABLE, + mockDispatch, + ), + ).rejects.toThrow( + 'Extension test-extension cannot be updated. Extension integrity cannot be verified.', + ); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: mockExtension.name, + state: ExtensionUpdateState.ERROR, + }, + }); + }); + + it('should establish trust on first update if integrity data is missing', async () => { + vi.mocked( + mockExtensionManager.verifyExtensionIntegrity, + ).mockResolvedValue(IntegrityDataStatus.MISSING); + + await updateExtension( + mockExtension, + mockExtensionManager, + ExtensionUpdateState.UPDATE_AVAILABLE, + mockDispatch, + ); + + // Verify updateExtension delegates to installOrUpdateExtension, + // which is responsible for establishing trust internally. + expect( + mockExtensionManager.installOrUpdateExtension, + ).toHaveBeenCalled(); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: mockExtension.name, + state: ExtensionUpdateState.UPDATED_NEEDS_RESTART, + }, + }); + }); + + it('should throw if integrity manager throws', async () => { + vi.mocked( + mockExtensionManager.verifyExtensionIntegrity, + ).mockRejectedValue(new Error('Verification failed')); + + await expect( + updateExtension( + mockExtension, + mockExtensionManager, + ExtensionUpdateState.UPDATE_AVAILABLE, + mockDispatch, + ), + ).rejects.toThrow( + 'Extension test-extension cannot be updated. Verification failed', + ); + }); + }); + }); + + describe('updateAllUpdatableExtensions', () => { + it('should update all extensions with UPDATE_AVAILABLE status', async () => { + const extensions: GeminiCLIExtension[] = [ + { ...mockExtension, name: 'ext1' }, + { ...mockExtension, name: 'ext2' }, + { ...mockExtension, name: 'ext3' }, + ]; + const extensionsState = new Map([ + ['ext1', { status: ExtensionUpdateState.UPDATE_AVAILABLE }], + ['ext2', { status: ExtensionUpdateState.UP_TO_DATE }], + ['ext3', { status: ExtensionUpdateState.UPDATE_AVAILABLE }], + ]); + + vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue( + Promise.resolve({ + name: 'ext', + version: '1.0.0', + }), + ); + vi.mocked( + mockExtensionManager.installOrUpdateExtension, + ).mockResolvedValue({ ...mockExtension, version: '1.1.0' }); + + const results = await updateAllUpdatableExtensions( + extensions, + extensionsState as Map, + mockExtensionManager, + mockDispatch, + ); + + expect(results).toHaveLength(2); + expect(results.map((r) => r.name)).toEqual(['ext1', 'ext3']); + expect( + mockExtensionManager.installOrUpdateExtension, + ).toHaveBeenCalledTimes(2); + }); + }); + + describe('checkForAllExtensionUpdates', () => { + it('should dispatch BATCH_CHECK_START and BATCH_CHECK_END', async () => { + await checkForAllExtensionUpdates([], mockExtensionManager, mockDispatch); + + expect(mockDispatch).toHaveBeenCalledWith({ type: 'BATCH_CHECK_START' }); + expect(mockDispatch).toHaveBeenCalledWith({ type: 'BATCH_CHECK_END' }); + }); + + it('should set state to NOT_UPDATABLE if no install metadata', async () => { + const extensions: GeminiCLIExtension[] = [ + { ...mockExtension, installMetadata: undefined }, + ]; + + await checkForAllExtensionUpdates( + extensions, + mockExtensionManager, + mockDispatch, + ); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: mockExtension.name, + state: ExtensionUpdateState.NOT_UPDATABLE, + }, + }); + }); + + it('should check for updates and update state', async () => { + const extensions: GeminiCLIExtension[] = [ + { ...mockExtension, installMetadata: { type: 'git', source: '...' } }, + ]; + vi.mocked(checkForExtensionUpdate).mockResolvedValue( + ExtensionUpdateState.UPDATE_AVAILABLE, + ); + + await checkForAllExtensionUpdates( + extensions, + mockExtensionManager, + mockDispatch, + ); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: mockExtension.name, + state: ExtensionUpdateState.CHECKING_FOR_UPDATES, + }, + }); + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_STATE', + payload: { + name: mockExtension.name, + state: ExtensionUpdateState.UPDATE_AVAILABLE, + }, + }); + }); + }); +}); diff --git a/packages/cli/src/config/extensions/update.ts b/packages/cli/src/config/extensions/update.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d751fd9c5e76addce2b2d15942420a3fd1ce5e2 --- /dev/null +++ b/packages/cli/src/config/extensions/update.ts @@ -0,0 +1,225 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type ExtensionUpdateAction, + ExtensionUpdateState, + type ExtensionUpdateStatus, +} from '../../ui/state/extensions.js'; +import { loadInstallMetadata } from '../extension.js'; +import { checkForExtensionUpdate } from './github.js'; +import { + debugLogger, + getErrorMessage, + type GeminiCLIExtension, + IntegrityDataStatus, +} from '@google/gemini-cli-core'; +import * as fs from 'node:fs'; +import { copyExtension, type ExtensionManager } from '../extension-manager.js'; +import { ExtensionStorage } from './storage.js'; + +export interface ExtensionUpdateInfo { + name: string; + originalVersion: string; + updatedVersion: string; +} + +export async function updateExtension( + extension: GeminiCLIExtension, + extensionManager: ExtensionManager, + currentState: ExtensionUpdateState, + dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void, + enableExtensionReloading?: boolean, +): Promise { + if (currentState === ExtensionUpdateState.UPDATING) { + return undefined; + } + dispatchExtensionStateUpdate({ + type: 'SET_STATE', + payload: { name: extension.name, state: ExtensionUpdateState.UPDATING }, + }); + const installMetadata = loadInstallMetadata(extension.path); + + if (!installMetadata?.type) { + dispatchExtensionStateUpdate({ + type: 'SET_STATE', + payload: { name: extension.name, state: ExtensionUpdateState.ERROR }, + }); + throw new Error( + `Extension ${extension.name} cannot be updated, type is unknown.`, + ); + } + + try { + const status = await extensionManager.verifyExtensionIntegrity( + extension.name, + installMetadata, + ); + + if (status === IntegrityDataStatus.INVALID) { + throw new Error('Extension integrity cannot be verified'); + } + } catch (e) { + dispatchExtensionStateUpdate({ + type: 'SET_STATE', + payload: { name: extension.name, state: ExtensionUpdateState.ERROR }, + }); + throw new Error( + `Extension ${extension.name} cannot be updated. ${getErrorMessage(e)}. To fix this, reinstall the extension.`, + ); + } + + if (installMetadata?.type === 'link') { + dispatchExtensionStateUpdate({ + type: 'SET_STATE', + payload: { name: extension.name, state: ExtensionUpdateState.UP_TO_DATE }, + }); + throw new Error(`Extension is linked so does not need to be updated`); + } + + if (extension.migratedTo) { + const migratedState = await checkForExtensionUpdate( + { + ...extension, + installMetadata: { ...installMetadata, source: extension.migratedTo }, + migratedTo: undefined, + }, + extensionManager, + ); + if ( + migratedState === ExtensionUpdateState.UPDATE_AVAILABLE || + migratedState === ExtensionUpdateState.UP_TO_DATE + ) { + installMetadata.source = extension.migratedTo; + } + } + + const originalVersion = extension.version; + + const tempDir = await ExtensionStorage.createTmpDir(); + try { + const previousExtensionConfig = await extensionManager.loadExtensionConfig( + extension.path, + ); + let updatedExtension: GeminiCLIExtension; + try { + updatedExtension = await extensionManager.installOrUpdateExtension( + installMetadata, + previousExtensionConfig, + ); + } catch (e) { + dispatchExtensionStateUpdate({ + type: 'SET_STATE', + payload: { name: extension.name, state: ExtensionUpdateState.ERROR }, + }); + throw new Error( + `Updated extension not found after installation, got error:\n${e}`, + ); + } + const updatedVersion = updatedExtension.version; + dispatchExtensionStateUpdate({ + type: 'SET_STATE', + payload: { + name: extension.name, + state: enableExtensionReloading + ? ExtensionUpdateState.UPDATED + : ExtensionUpdateState.UPDATED_NEEDS_RESTART, + }, + }); + return { + name: extension.name, + originalVersion, + updatedVersion, + }; + } catch (e) { + debugLogger.error( + `Error updating extension, rolling back. ${getErrorMessage(e)}`, + ); + dispatchExtensionStateUpdate({ + type: 'SET_STATE', + payload: { name: extension.name, state: ExtensionUpdateState.ERROR }, + }); + await copyExtension(tempDir, extension.path); + throw e; + } finally { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } +} + +export async function updateAllUpdatableExtensions( + extensions: GeminiCLIExtension[], + extensionsState: Map, + extensionManager: ExtensionManager, + dispatch: (action: ExtensionUpdateAction) => void, + enableExtensionReloading?: boolean, +): Promise { + const results = await Promise.all( + extensions + .filter( + (extension) => + extensionsState.get(extension.name)?.status === + ExtensionUpdateState.UPDATE_AVAILABLE, + ) + .map((extension) => + updateExtension( + extension, + extensionManager, + extensionsState.get(extension.name)!.status, + dispatch, + enableExtensionReloading, + ), + ), + ); + return results.filter( + (updateInfo): updateInfo is ExtensionUpdateInfo => !!updateInfo, + ); +} + +export interface ExtensionUpdateCheckResult { + state: ExtensionUpdateState; + error?: string; +} + +export async function checkForAllExtensionUpdates( + extensions: GeminiCLIExtension[], + extensionManager: ExtensionManager, + dispatch: (action: ExtensionUpdateAction) => void, +): Promise { + dispatch({ type: 'BATCH_CHECK_START' }); + try { + const promises: Array> = []; + for (const extension of extensions) { + if (!extension.installMetadata) { + dispatch({ + type: 'SET_STATE', + payload: { + name: extension.name, + state: ExtensionUpdateState.NOT_UPDATABLE, + }, + }); + continue; + } + dispatch({ + type: 'SET_STATE', + payload: { + name: extension.name, + state: ExtensionUpdateState.CHECKING_FOR_UPDATES, + }, + }); + promises.push( + checkForExtensionUpdate(extension, extensionManager).then((state) => + dispatch({ + type: 'SET_STATE', + payload: { name: extension.name, state }, + }), + ), + ); + } + await Promise.all(promises); + } finally { + dispatch({ type: 'BATCH_CHECK_END' }); + } +} diff --git a/packages/cli/src/config/extensions/variableSchema.ts b/packages/cli/src/config/extensions/variableSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b8abc5021dad49bddb50bb70d8cf1df7fa90759 --- /dev/null +++ b/packages/cli/src/config/extensions/variableSchema.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface VariableDefinition { + type: 'string'; + description: string; + default?: string; + required?: boolean; +} + +export interface VariableSchema { + [key: string]: VariableDefinition; +} + +const PATH_SEPARATOR_DEFINITION = { + type: 'string', + description: 'The path separator.', +} as const; + +export const VARIABLE_SCHEMA = { + extensionPath: { + type: 'string', + description: 'The path of the extension in the filesystem.', + }, + workspacePath: { + type: 'string', + description: 'The absolute path of the current workspace.', + }, + '/': PATH_SEPARATOR_DEFINITION, + pathSeparator: PATH_SEPARATOR_DEFINITION, +} as const; diff --git a/packages/cli/src/config/extensions/variables.test.ts b/packages/cli/src/config/extensions/variables.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5f57fe19fe277c444b3357171bd907087254852e --- /dev/null +++ b/packages/cli/src/config/extensions/variables.test.ts @@ -0,0 +1,153 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { expect, describe, it } from 'vitest'; +import { + hydrateString, + recursivelyHydrateStrings, + validateVariables, + type VariableContext, +} from './variables.js'; + +describe('validateVariables', () => { + it('should not throw if all required variables are present', () => { + const schema = { + extensionPath: { type: 'string', description: 'test', required: true }, + } as const; + const context = { extensionPath: 'value' }; + expect(() => validateVariables(context, schema)).not.toThrow(); + }); + + it('should throw if a required variable is missing', () => { + const schema = { + extensionPath: { type: 'string', description: 'test', required: true }, + } as const; + const context = {}; + expect(() => validateVariables(context, schema)).toThrow( + 'Missing required variable: extensionPath', + ); + }); +}); + +describe('hydrateString', () => { + it('should replace a single variable', () => { + const context = { + extensionPath: 'path/my-extension', + }; + const result = hydrateString('Hello, ${extensionPath}!', context); + expect(result).toBe('Hello, path/my-extension!'); + }); + + it('should replace multiple variables', () => { + const context = { + extensionPath: 'path/my-extension', + workspacePath: '/ws', + }; + const result = hydrateString( + 'Ext: ${extensionPath}, WS: ${workspacePath}', + context, + ); + expect(result).toBe('Ext: path/my-extension, WS: /ws'); + }); + + it('should ignore unknown variables', () => { + const context = { + extensionPath: 'path/my-extension', + }; + const result = hydrateString('Hello, ${unknown}!', context); + expect(result).toBe('Hello, ${unknown}!'); + }); + + it('should handle null and undefined context values', () => { + const context: VariableContext = { + extensionPath: undefined, + }; + const result = hydrateString( + 'Ext: ${extensionPath}, WS: ${workspacePath}', + context, + ); + expect(result).toBe('Ext: ${extensionPath}, WS: ${workspacePath}'); + }); +}); + +describe('recursivelyHydrateStrings', () => { + const context = { + extensionPath: 'path/my-extension', + workspacePath: '/ws', + }; + + it('should hydrate strings in a flat object', () => { + const obj = { + a: 'Hello, ${workspacePath}', + b: 'Hi, ${extensionPath}', + }; + const result = recursivelyHydrateStrings(obj, context); + expect(result).toEqual({ + a: 'Hello, /ws', + b: 'Hi, path/my-extension', + }); + }); + + it('should hydrate strings in an array', () => { + const arr = ['${workspacePath}', '${extensionPath}']; + const result = recursivelyHydrateStrings(arr, context); + expect(result).toEqual(['/ws', 'path/my-extension']); + }); + + it('should hydrate strings in a nested object', () => { + const obj = { + a: 'Hello, ${workspacePath}', + b: { + c: 'Hi, ${extensionPath}', + d: ['${workspacePath}/foo'], + }, + }; + const result = recursivelyHydrateStrings(obj, context); + expect(result).toEqual({ + a: 'Hello, /ws', + b: { + c: 'Hi, path/my-extension', + d: ['/ws/foo'], + }, + }); + }); + + it('should not modify non-string values', () => { + const obj = { + a: 123, + b: true, + c: null, + }; + const result = recursivelyHydrateStrings(obj, context); + expect(result).toEqual(obj); + }); + + it('should not allow prototype pollution via __proto__', () => { + const payload = JSON.parse('{"__proto__": {"polluted": "yes"}}'); + const result = recursivelyHydrateStrings(payload, context); + + expect(result.polluted).toBeUndefined(); + expect(Object.prototype.hasOwnProperty.call(result, 'polluted')).toBe( + false, + ); + }); + + it('should not allow prototype pollution via constructor', () => { + const payload = JSON.parse( + '{"constructor": {"prototype": {"polluted": "yes"}}}', + ); + const result = recursivelyHydrateStrings(payload, context); + + expect(result.polluted).toBeUndefined(); + }); + + it('should not allow prototype pollution via prototype', () => { + const payload = JSON.parse('{"prototype": {"polluted": "yes"}}'); + const result = recursivelyHydrateStrings(payload, context); + + expect(result.polluted).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/config/extensions/variables.ts b/packages/cli/src/config/extensions/variables.ts new file mode 100644 index 0000000000000000000000000000000000000000..5f90610dd948238d070239ae8dd83077a65b9ebe --- /dev/null +++ b/packages/cli/src/config/extensions/variables.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import { type VariableSchema, VARIABLE_SCHEMA } from './variableSchema.js'; +import { GEMINI_DIR } from '@google/gemini-cli-core'; + +/** + * Represents a set of keys that will be considered invalid while unmarshalling + * JSON in recursivelyHydrateStrings. + */ +const UNMARSHALL_KEY_IGNORE_LIST: Set = new Set([ + '__proto__', + 'constructor', + 'prototype', +]); + +export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); +export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json'; +export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json'; +export const EXTENSION_SETTINGS_FILENAME = '.env'; + +export type JsonObject = { [key: string]: JsonValue }; +export type JsonArray = JsonValue[]; +export type JsonValue = + | string + | number + | boolean + | null + | JsonObject + | JsonArray; + +export type VariableContext = { + [key: string]: string | undefined; +}; + +export function validateVariables( + variables: VariableContext, + schema: VariableSchema, +) { + for (const key in schema) { + const definition = schema[key]; + if (definition.required && !variables[key]) { + throw new Error(`Missing required variable: ${key}`); + } + } +} + +export function hydrateString(str: string, context: VariableContext): string { + validateVariables(context, VARIABLE_SCHEMA); + const regex = /\${(.*?)}/g; + return str.replace(regex, (match, key) => { + const val = context[key]; + return val == null ? match : String(val); + }); +} + +export function recursivelyHydrateStrings( + obj: T, + values: VariableContext, +): T { + if (typeof obj === 'string') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return hydrateString(obj, values) as unknown as T; + } + if (Array.isArray(obj)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return (obj as unknown[]).map((item) => + recursivelyHydrateStrings(item, values), + ) as unknown as T; + } + if (typeof obj === 'object' && obj !== null) { + const newObj: Record = {}; + for (const key in obj) { + if ( + !UNMARSHALL_KEY_IGNORE_LIST.has(key) && + Object.prototype.hasOwnProperty.call(obj, key) + ) { + newObj[key] = recursivelyHydrateStrings( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (obj as Record)[key], + values, + ); + } + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return newObj as T; + } + return obj; +} diff --git a/packages/cli/src/config/mcp/index.ts b/packages/cli/src/config/mcp/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..555f52071eb114d4a94f4b25f622631601287f77 --- /dev/null +++ b/packages/cli/src/config/mcp/index.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export { + McpServerEnablementManager, + canLoadServer, + normalizeServerId, + isInSettingsList, + type McpServerEnablementState, + type McpServerEnablementConfig, + type McpServerDisplayState, + type EnablementCallbacks, + type ServerLoadResult, +} from './mcpServerEnablement.js'; diff --git a/packages/cli/src/config/mcp/mcpServerEnablement.test.ts b/packages/cli/src/config/mcp/mcpServerEnablement.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..12b483d59d3d57e6854812cf588727e4c26b0c3d --- /dev/null +++ b/packages/cli/src/config/mcp/mcpServerEnablement.test.ts @@ -0,0 +1,189 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + Storage: { + // eslint-disable-next-line @typescript-eslint/no-misused-spread + ...actual.Storage, + getGlobalGeminiDir: () => '/virtual-home/.gemini', + }, + }; +}); + +import { + McpServerEnablementManager, + canLoadServer, + normalizeServerId, + isInSettingsList, + type EnablementCallbacks, +} from './mcpServerEnablement.js'; + +let inMemoryFs: Record = {}; + +function createMockEnablement( + sessionDisabled: boolean, + fileEnabled: boolean, +): EnablementCallbacks { + return { + isSessionDisabled: () => sessionDisabled, + isFileEnabled: () => Promise.resolve(fileEnabled), + }; +} + +function setupFsMocks(): void { + vi.spyOn(fs, 'readFile').mockImplementation(async (filePath) => { + const content = inMemoryFs[filePath.toString()]; + if (content === undefined) { + const error = new Error(`ENOENT: ${filePath}`); + (error as NodeJS.ErrnoException).code = 'ENOENT'; + throw error; + } + return content; + }); + vi.spyOn(fs, 'writeFile').mockImplementation(async (filePath, data) => { + inMemoryFs[filePath.toString()] = data.toString(); + }); + vi.spyOn(fs, 'mkdir').mockImplementation(async () => undefined); +} + +describe('McpServerEnablementManager', () => { + let manager: McpServerEnablementManager; + + beforeEach(() => { + inMemoryFs = {}; + setupFsMocks(); + McpServerEnablementManager.resetInstance(); + manager = McpServerEnablementManager.getInstance(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + McpServerEnablementManager.resetInstance(); + }); + + it('should enable/disable servers with persistence', async () => { + expect(await manager.isFileEnabled('server')).toBe(true); + await manager.disable('server'); + expect(await manager.isFileEnabled('server')).toBe(false); + await manager.enable('server'); + expect(await manager.isFileEnabled('server')).toBe(true); + }); + + it('should handle session disable separately', async () => { + manager.disableForSession('server'); + expect(manager.isSessionDisabled('server')).toBe(true); + expect(await manager.isFileEnabled('server')).toBe(true); + expect(await manager.isEffectivelyEnabled('server')).toBe(false); + manager.clearSessionDisable('server'); + expect(await manager.isEffectivelyEnabled('server')).toBe(true); + }); + + it('should be case-insensitive', async () => { + await manager.disable('PlayWright'); + expect(await manager.isFileEnabled('playwright')).toBe(false); + }); + + it('should return correct display state', async () => { + await manager.disable('file-disabled'); + manager.disableForSession('session-disabled'); + + expect(await manager.getDisplayState('enabled')).toEqual({ + enabled: true, + isSessionDisabled: false, + isPersistentDisabled: false, + }); + expect( + (await manager.getDisplayState('file-disabled')).isPersistentDisabled, + ).toBe(true); + expect( + (await manager.getDisplayState('session-disabled')).isSessionDisabled, + ).toBe(true); + }); + + it('should share session state across getInstance calls', () => { + const instance1 = McpServerEnablementManager.getInstance(); + const instance2 = McpServerEnablementManager.getInstance(); + + instance1.disableForSession('test-server'); + + expect(instance2.isSessionDisabled('test-server')).toBe(true); + expect(instance1).toBe(instance2); + }); +}); + +describe('canLoadServer', () => { + it('blocks when admin has disabled MCP', async () => { + const result = await canLoadServer('s', { adminMcpEnabled: false }); + expect(result.blockType).toBe('admin'); + }); + + it('blocks when server is not in allowlist', async () => { + const result = await canLoadServer('s', { + adminMcpEnabled: true, + allowedList: ['other'], + }); + expect(result.blockType).toBe('allowlist'); + }); + + it('blocks when server is in excludelist', async () => { + const result = await canLoadServer('s', { + adminMcpEnabled: true, + excludedList: ['s'], + }); + expect(result.blockType).toBe('excludelist'); + }); + + it('blocks when server is session-disabled', async () => { + const result = await canLoadServer('s', { + adminMcpEnabled: true, + enablement: createMockEnablement(true, true), + }); + expect(result.blockType).toBe('session'); + }); + + it('blocks when server is file-disabled', async () => { + const result = await canLoadServer('s', { + adminMcpEnabled: true, + enablement: createMockEnablement(false, false), + }); + expect(result.blockType).toBe('enablement'); + }); + + it('allows when admin MCP is enabled and no restrictions', async () => { + const result = await canLoadServer('s', { adminMcpEnabled: true }); + expect(result.allowed).toBe(true); + }); + + it('allows when server passes all checks', async () => { + const result = await canLoadServer('s', { + adminMcpEnabled: true, + allowedList: ['s'], + enablement: createMockEnablement(false, true), + }); + expect(result.allowed).toBe(true); + }); +}); + +describe('helper functions', () => { + it('normalizeServerId lowercases and trims', () => { + expect(normalizeServerId(' PlayWright ')).toBe('playwright'); + }); + + it('isInSettingsList supports ext: backward compat', () => { + expect(isInSettingsList('playwright', ['playwright']).found).toBe(true); + expect(isInSettingsList('ext:github:mcp', ['mcp']).found).toBe(true); + expect( + isInSettingsList('ext:github:mcp', ['mcp']).deprecationWarning, + ).toBeTruthy(); + }); +}); diff --git a/packages/cli/src/config/mcp/mcpServerEnablement.ts b/packages/cli/src/config/mcp/mcpServerEnablement.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd6f2df2aeecae7872638c28cf8f62632a8f5a20 --- /dev/null +++ b/packages/cli/src/config/mcp/mcpServerEnablement.ts @@ -0,0 +1,387 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { Storage, coreEvents } from '@google/gemini-cli-core'; + +/** + * Stored in JSON file - represents persistent enablement state. + */ +export interface McpServerEnablementState { + enabled: boolean; +} + +/** + * File config format - map of server ID to enablement state. + */ +export interface McpServerEnablementConfig { + [serverId: string]: McpServerEnablementState; +} + +/** + * For UI display - combines file and session state. + */ +export interface McpServerDisplayState { + /** Effective state (considering session override) */ + enabled: boolean; + /** True if disabled via --session flag */ + isSessionDisabled: boolean; + /** True if disabled in file */ + isPersistentDisabled: boolean; +} + +/** + * Callback types for enablement checks (passed from CLI to core). + */ +export interface EnablementCallbacks { + isSessionDisabled: (serverId: string) => boolean; + isFileEnabled: (serverId: string) => Promise; +} + +/** + * Result of canLoadServer check. + */ +export interface ServerLoadResult { + allowed: boolean; + reason?: string; + blockType?: 'admin' | 'allowlist' | 'excludelist' | 'session' | 'enablement'; +} + +/** + * Normalize a server ID to canonical lowercase form. + */ +export function normalizeServerId(serverId: string): string { + return serverId.toLowerCase().trim(); +} + +/** + * Check if a server ID is in a settings list (with backward compatibility). + * Handles case-insensitive matching and plain name fallback for ext: servers. + */ +export function isInSettingsList( + serverId: string, + list: string[], +): { found: boolean; deprecationWarning?: string } { + const normalizedId = normalizeServerId(serverId); + const normalizedList = list.map(normalizeServerId); + + // Exact canonical match + if (normalizedList.includes(normalizedId)) { + return { found: true }; + } + + // Backward compat: for ext: servers, check if plain name matches + if (normalizedId.startsWith('ext:')) { + const plainName = normalizedId.split(':').pop(); + if (plainName && normalizedList.includes(plainName)) { + return { + found: true, + deprecationWarning: + `Settings reference '${plainName}' matches extension server '${serverId}'. ` + + `Update your settings to use the full identifier '${serverId}' instead.`, + }; + } + } + + return { found: false }; +} + +/** + * Single source of truth for whether a server can be loaded. + * Used by: isAllowedMcpServer(), connectServer(), CLI handlers, slash handlers. + * + * Uses callbacks instead of direct enablementManager reference to keep + * packages/core independent of packages/cli. + */ +export async function canLoadServer( + serverId: string, + config: { + adminMcpEnabled: boolean; + allowedList?: string[]; + excludedList?: string[]; + enablement?: EnablementCallbacks; + }, +): Promise { + const normalizedId = normalizeServerId(serverId); + + // 1. Admin kill switch + if (!config.adminMcpEnabled) { + return { + allowed: false, + reason: + 'MCP servers are disabled by administrator. Check admin settings or contact your admin.', + blockType: 'admin', + }; + } + + // 2. Allowlist check + if (config.allowedList !== undefined) { + const { found, deprecationWarning } = isInSettingsList( + normalizedId, + config.allowedList, + ); + if (deprecationWarning) { + coreEvents.emitFeedback('warning', deprecationWarning); + } + if (!found) { + return { + allowed: false, + reason: `Server '${serverId}' is not in mcp.allowed list. Add it to settings.json mcp.allowed array to enable.`, + blockType: 'allowlist', + }; + } + } + + // 3. Excludelist check + if (config.excludedList) { + const { found, deprecationWarning } = isInSettingsList( + normalizedId, + config.excludedList, + ); + if (deprecationWarning) { + coreEvents.emitFeedback('warning', deprecationWarning); + } + if (found) { + return { + allowed: false, + reason: `Server '${serverId}' is blocked by mcp.excluded. Remove it from settings.json mcp.excluded array to enable.`, + blockType: 'excludelist', + }; + } + } + + // 4. Session disable check (before file-based enablement) + if (config.enablement?.isSessionDisabled(normalizedId)) { + return { + allowed: false, + reason: `Server '${serverId}' is disabled for this session. Run 'gemini mcp enable ${serverId} --session' to clear.`, + blockType: 'session', + }; + } + + // 5. File-based enablement check + if ( + config.enablement && + !(await config.enablement.isFileEnabled(normalizedId)) + ) { + return { + allowed: false, + reason: `Server '${serverId}' is disabled. Run 'gemini mcp enable ${serverId}' to enable.`, + blockType: 'enablement', + }; + } + + return { allowed: true }; +} + +const MCP_ENABLEMENT_FILENAME = 'mcp-server-enablement.json'; + +/** + * McpServerEnablementManager + * + * Manages the enabled/disabled state of MCP servers. + * Uses a simplified format compared to ExtensionEnablementManager. + * Supports both persistent (file) and session-only (in-memory) states. + * + * NOTE: Use getInstance() to get the singleton instance. This ensures + * session state (sessionDisabled Set) is shared across all code paths. + */ +export class McpServerEnablementManager { + private static instance: McpServerEnablementManager | null = null; + + private readonly configFilePath: string; + private readonly configDir: string; + private readonly sessionDisabled = new Set(); + + /** + * Get the singleton instance. + */ + static getInstance(): McpServerEnablementManager { + if (!McpServerEnablementManager.instance) { + McpServerEnablementManager.instance = new McpServerEnablementManager(); + } + return McpServerEnablementManager.instance; + } + + /** + * Reset the singleton instance (for testing only). + */ + static resetInstance(): void { + McpServerEnablementManager.instance = null; + } + + constructor() { + this.configDir = Storage.getGlobalGeminiDir(); + this.configFilePath = path.join(this.configDir, MCP_ENABLEMENT_FILENAME); + } + + /** + * Check if server is enabled in FILE (persistent config only). + * Does NOT include session state. + */ + async isFileEnabled(serverName: string): Promise { + const config = await this.readConfig(); + const state = config[normalizeServerId(serverName)]; + return state?.enabled ?? true; + } + + /** + * Check if server is session-disabled. + */ + isSessionDisabled(serverName: string): boolean { + return this.sessionDisabled.has(normalizeServerId(serverName)); + } + + /** + * Check effective enabled state (combines file + session). + * Convenience method; canLoadServer() uses separate callbacks for granular blockType. + */ + async isEffectivelyEnabled(serverName: string): Promise { + if (this.isSessionDisabled(serverName)) { + return false; + } + return this.isFileEnabled(serverName); + } + + /** + * Enable a server persistently. + * Removes the server from config file (defaults to enabled). + */ + async enable(serverName: string): Promise { + const normalizedName = normalizeServerId(serverName); + const config = await this.readConfig(); + + if (normalizedName in config) { + delete config[normalizedName]; + await this.writeConfig(config); + } + } + + /** + * Disable a server persistently. + * Adds server to config file with enabled: false. + */ + async disable(serverName: string): Promise { + const config = await this.readConfig(); + config[normalizeServerId(serverName)] = { enabled: false }; + await this.writeConfig(config); + } + + /** + * Disable a server for current session only (in-memory). + */ + disableForSession(serverName: string): void { + this.sessionDisabled.add(normalizeServerId(serverName)); + } + + /** + * Clear session disable for a server. + */ + clearSessionDisable(serverName: string): void { + this.sessionDisabled.delete(normalizeServerId(serverName)); + } + + /** + * Get display state for a specific server (for UI). + */ + async getDisplayState(serverName: string): Promise { + const isSessionDisabled = this.isSessionDisabled(serverName); + const isPersistentDisabled = !(await this.isFileEnabled(serverName)); + + return { + enabled: !isSessionDisabled && !isPersistentDisabled, + isSessionDisabled, + isPersistentDisabled, + }; + } + + /** + * Get all display states (for UI listing). + */ + async getAllDisplayStates( + serverIds: string[], + ): Promise> { + const result: Record = {}; + for (const serverId of serverIds) { + result[normalizeServerId(serverId)] = + await this.getDisplayState(serverId); + } + return result; + } + + /** + * Get enablement callbacks for passing to core. + */ + getEnablementCallbacks(): EnablementCallbacks { + return { + isSessionDisabled: (id) => this.isSessionDisabled(id), + isFileEnabled: (id) => this.isFileEnabled(id), + }; + } + + /** + * Auto-enable any disabled MCP servers by name. + * Returns server names that were actually re-enabled. + */ + async autoEnableServers(serverNames: string[]): Promise { + const enabledServers: string[] = []; + + for (const serverName of serverNames) { + const normalizedName = normalizeServerId(serverName); + const state = await this.getDisplayState(normalizedName); + + let wasDisabled = false; + if (state.isPersistentDisabled) { + await this.enable(normalizedName); + wasDisabled = true; + } + if (state.isSessionDisabled) { + this.clearSessionDisable(normalizedName); + wasDisabled = true; + } + + if (wasDisabled) { + enabledServers.push(serverName); + } + } + + return enabledServers; + } + + /** + * Read config from file asynchronously. + */ + private async readConfig(): Promise { + try { + const content = await fs.readFile(this.configFilePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return JSON.parse(content) as McpServerEnablementConfig; + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) { + return {}; + } + coreEvents.emitFeedback( + 'error', + 'Failed to read MCP server enablement config.', + error, + ); + return {}; + } + } + + /** + * Write config to file asynchronously. + */ + private async writeConfig(config: McpServerEnablementConfig): Promise { + await fs.mkdir(this.configDir, { recursive: true }); + await fs.writeFile(this.configFilePath, JSON.stringify(config, null, 2)); + } +} diff --git a/packages/cli/src/test-utils/fixtures/simple.responses b/packages/cli/src/test-utils/fixtures/simple.responses new file mode 100644 index 0000000000000000000000000000000000000000..1612ab928ae7d5a58b36608b923f0c4d6adc0769 --- /dev/null +++ b/packages/cli/src/test-utils/fixtures/simple.responses @@ -0,0 +1 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hello! How can I help you today?"}],"role":"model"},"finishReason":"STOP"}]}]} diff --git a/packages/cli/src/test-utils/fixtures/steering.responses b/packages/cli/src/test-utils/fixtures/steering.responses new file mode 100644 index 0000000000000000000000000000000000000000..6d843010f18350a9d9e3b8f168decc617ffc6b71 --- /dev/null +++ b/packages/cli/src/test-utils/fixtures/steering.responses @@ -0,0 +1,3 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"Starting a long task. First, I'll list the files."},{"functionCall":{"name":"list_directory","args":{"dir_path":"."}}}]},"finishReason":"STOP"}]}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"I see the files. Since you want me to focus on .txt files, I will read file1.txt."},{"functionCall":{"name":"read_file","args":{"file_path":"file1.txt"}}}]},"finishReason":"STOP"}]}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"I have read file1.txt. Task complete."}]},"finishReason":"STOP"}]}]} diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..12d01fac4cc00a74e9050666a1b96fc4ba6e6e1a --- /dev/null +++ b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx @@ -0,0 +1,174 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { ApiAuthDialog } from './ApiAuthDialog.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { + useTextBuffer, + type TextBuffer, +} from '../components/shared/text-buffer.js'; +import { clearApiKey } from '@google/gemini-cli-core'; + +// Mocks +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + clearApiKey: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock('../hooks/useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + +vi.mock('../components/shared/text-buffer.js', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('../components/shared/text-buffer.js') + >(); + return { + ...actual, + useTextBuffer: vi.fn(), + }; +}); + +vi.mock('../contexts/UIStateContext.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useUIState: vi.fn(() => ({ + terminalWidth: 80, + })), + }; +}); + +const mockedUseKeypress = useKeypress as Mock; +const mockedUseTextBuffer = useTextBuffer as Mock; + +describe('ApiAuthDialog', () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + let mockBuffer: TextBuffer; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('GEMINI_API_KEY', ''); + mockBuffer = { + text: '', + lines: [''], + cursor: [0, 0], + visualCursor: [0, 0], + viewportVisualLines: [''], + handleInput: vi.fn(), + setText: vi.fn((newText) => { + mockBuffer.text = newText; + mockBuffer.viewportVisualLines = [newText]; + }), + } as unknown as TextBuffer; + mockedUseTextBuffer.mockReturnValue(mockBuffer); + }); + + it('renders correctly', async () => { + const { lastFrame, unmount } = await renderWithProviders( + , + ); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('renders with a defaultValue', async () => { + const { unmount } = await renderWithProviders( + , + ); + expect(mockedUseTextBuffer).toHaveBeenCalledWith( + expect.objectContaining({ + initialText: 'test-key', + viewport: expect.objectContaining({ + height: 4, + }), + }), + ); + unmount(); + }); + + it.each([ + { + keyName: 'enter', + sequence: '\r', + expectedCall: onSubmit, + args: ['submitted-key'], + }, + { keyName: 'escape', sequence: '\u001b', expectedCall: onCancel, args: [] }, + ])( + 'calls $expectedCall.name when $keyName is pressed', + async ({ keyName, sequence, expectedCall, args }) => { + mockBuffer.text = 'submitted-key'; // Set for the onSubmit case + const { unmount } = await renderWithProviders( + , + ); + // calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler) + // calls[1] is the TextInput's useKeypress (typing handler) + const keypressHandler = mockedUseKeypress.mock.calls[1][0]; + + keypressHandler({ + name: keyName, + shift: false, + alt: false, + ctrl: false, + cmd: false, + sequence, + }); + + expect(expectedCall).toHaveBeenCalledWith(...args); + unmount(); + }, + ); + + it('displays an error message', async () => { + const { lastFrame, unmount } = await renderWithProviders( + , + ); + + expect(lastFrame()).toContain('Invalid API Key'); + unmount(); + }); + + it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => { + const { unmount } = await renderWithProviders( + , + ); + // Call 0 is ApiAuthDialog (isActive: true) + // Call 1 is TextInput (isActive: true, priority: true) + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + + keypressHandler({ + name: 'c', + shift: false, + ctrl: true, + cmd: false, + }); + + await waitFor(() => { + expect(clearApiKey).toHaveBeenCalled(); + expect(mockBuffer.setText).toHaveBeenCalledWith(''); + }); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e5e9474ef3326bd1aa37e482e89afc23f409ab56 --- /dev/null +++ b/packages/cli/src/ui/auth/ApiAuthDialog.tsx @@ -0,0 +1,150 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useRef, useEffect } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { TextInput } from '../components/shared/TextInput.js'; +import { useTextBuffer } from '../components/shared/text-buffer.js'; +import { useUIState } from '../contexts/UIStateContext.js'; +import { clearApiKey, debugLogger } from '@google/gemini-cli-core'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { Command } from '../key/keyMatchers.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; + +interface ApiAuthDialogProps { + onSubmit: (apiKey: string) => void; + onCancel: () => void; + error?: string | null; + defaultValue?: string; +} + +export function ApiAuthDialog({ + onSubmit, + onCancel, + error, + defaultValue = '', +}: ApiAuthDialogProps): React.JSX.Element { + const keyMatchers = useKeyMatchers(); + const { terminalWidth } = useUIState(); + const viewportWidth = terminalWidth - 8; + + const pendingPromise = useRef<{ cancel: () => void } | null>(null); + + useEffect( + () => () => { + pendingPromise.current?.cancel(); + }, + [], + ); + + const initialApiKey = defaultValue; + + const buffer = useTextBuffer({ + initialText: initialApiKey || '', + initialCursorOffset: initialApiKey?.length || 0, + viewport: { + width: viewportWidth, + height: 4, + }, + inputFilter: (text) => + text.replace(/[^a-zA-Z0-9_.-]/g, '').replace(/[\r\n]/g, ''), + singleLine: true, + }); + + const handleSubmit = (value: string) => { + onSubmit(value); + }; + + const handleClear = () => { + pendingPromise.current?.cancel(); + + let isCancelled = false; + const wrappedPromise = new Promise((resolve, reject) => { + clearApiKey().then( + () => !isCancelled && resolve(), + (error) => !isCancelled && reject(error), + ); + }); + + pendingPromise.current = { + cancel: () => { + isCancelled = true; + }, + }; + + return wrappedPromise + .then(() => { + buffer.setText(''); + }) + .catch((err) => { + debugLogger.debug('Failed to clear API key:', err); + }); + }; + + useKeypress( + (key) => { + if (keyMatchers[Command.CLEAR_INPUT](key)) { + void handleClear(); + return true; + } + return false; + }, + { isActive: true }, + ); + + return ( + + + Enter Gemini API Key + + + + Please enter your Gemini API key. It will be securely stored in your + system keychain. + + + You can get an API key from{' '} + + https://aistudio.google.com/app/apikey + + + + + + + + + {error && ( + + {error} + + )} + + + (Press Enter to submit, Esc to cancel, Ctrl+C to clear stored key) + + + + ); +} diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..40ec0b301daf574ea0b4275a7d0aef06fd7afc4a --- /dev/null +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -0,0 +1,458 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act } from 'react'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import { AuthDialog } from './AuthDialog.js'; +import { AuthType, type Config, debugLogger } from '@google/gemini-cli-core'; +import type { LoadedSettings } from '../../config/settings.js'; +import { AuthState } from '../types.js'; +import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { validateAuthMethodWithSettings } from './useAuth.js'; +import { runExitCleanup } from '../../utils/cleanup.js'; +import { Text } from 'ink'; +import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js'; + +// Mocks +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + clearCachedCredentialFile: vi.fn(), + }; +}); + +vi.mock('../../utils/cleanup.js', () => ({ + runExitCleanup: vi.fn(), +})); + +vi.mock('./useAuth.js', () => ({ + validateAuthMethodWithSettings: vi.fn(), +})); + +vi.mock('../hooks/useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + +vi.mock('../components/shared/RadioButtonSelect.js', () => ({ + RadioButtonSelect: vi.fn(({ items, initialIndex }) => ( + <> + {items.map((item: { value: string; label: string }, index: number) => ( + + {index === initialIndex ? '(selected)' : '(not selected)'}{' '} + {item.label} + + ))} + + )), +})); + +const mockedUseKeypress = useKeypress as Mock; +const mockedRadioButtonSelect = RadioButtonSelect as Mock; +const mockedValidateAuthMethod = validateAuthMethodWithSettings as Mock; +const mockedRunExitCleanup = runExitCleanup as Mock; + +describe('AuthDialog', () => { + let props: { + config: Config; + settings: LoadedSettings; + setAuthState: (state: AuthState) => void; + authError: string | null; + onAuthError: (error: string | null) => void; + setAuthContext: (context: { requiresRestart?: boolean }) => void; + }; + beforeEach(() => { + vi.resetAllMocks(); + vi.stubEnv('CLOUD_SHELL', undefined as unknown as string); + vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', undefined as unknown as string); + vi.stubEnv('GEMINI_DEFAULT_AUTH_TYPE', undefined as unknown as string); + vi.stubEnv('GEMINI_API_KEY', undefined as unknown as string); + + props = { + config: { + isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false), + } as unknown as Config, + settings: { + merged: { + security: { + auth: {}, + }, + }, + setValue: vi.fn(), + } as unknown as LoadedSettings, + setAuthState: vi.fn(), + authError: null, + onAuthError: vi.fn(), + setAuthContext: vi.fn(), + }; + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe('Environment Variable Effects on Auth Options', () => { + const cloudShellLabel = 'Use Cloud Shell user credentials'; + const metadataServerLabel = + 'Use metadata server application default credentials'; + const computeAdcItem = (label: string) => ({ + label, + value: AuthType.COMPUTE_ADC, + key: AuthType.COMPUTE_ADC, + }); + + it.each([ + { + env: { CLOUD_SHELL: 'true' }, + shouldContain: [computeAdcItem(cloudShellLabel)], + shouldNotContain: [computeAdcItem(metadataServerLabel)], + desc: 'in Cloud Shell', + }, + { + env: { GEMINI_CLI_USE_COMPUTE_ADC: 'true' }, + shouldContain: [computeAdcItem(metadataServerLabel)], + shouldNotContain: [computeAdcItem(cloudShellLabel)], + desc: 'with GEMINI_CLI_USE_COMPUTE_ADC', + }, + { + env: {}, + shouldContain: [], + shouldNotContain: [ + computeAdcItem(cloudShellLabel), + computeAdcItem(metadataServerLabel), + ], + desc: 'by default', + }, + ])( + 'correctly shows/hides COMPUTE_ADC options $desc', + async ({ env, shouldContain, shouldNotContain }) => { + for (const [key, value] of Object.entries(env)) { + vi.stubEnv(key, value as string); + } + const { unmount } = await renderWithProviders( + , + ); + const items = mockedRadioButtonSelect.mock.calls[0][0].items; + for (const item of shouldContain) { + expect(items).toContainEqual(item); + } + for (const item of shouldNotContain) { + expect(items).not.toContainEqual(item); + } + unmount(); + }, + ); + }); + + it('filters auth types when enforcedType is set', async () => { + props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; + const { unmount } = await renderWithProviders(); + const items = mockedRadioButtonSelect.mock.calls[0][0].items; + expect(items).toHaveLength(1); + expect(items[0].value).toBe(AuthType.USE_GEMINI); + unmount(); + }); + + it('sets initial index to 0 when enforcedType is set', async () => { + props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; + const { unmount } = await renderWithProviders(); + const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0]; + expect(initialIndex).toBe(0); + unmount(); + }); + + describe('Initial Auth Type Selection', () => { + it.each([ + { + setup: () => { + props.settings.merged.security.auth.selectedType = + AuthType.USE_VERTEX_AI; + }, + expected: AuthType.USE_VERTEX_AI, + desc: 'from settings', + }, + { + setup: () => { + vi.stubEnv('GEMINI_DEFAULT_AUTH_TYPE', AuthType.USE_GEMINI); + }, + expected: AuthType.USE_GEMINI, + desc: 'from GEMINI_DEFAULT_AUTH_TYPE env var', + }, + { + setup: () => { + vi.stubEnv('GEMINI_API_KEY', 'test-key'); + }, + expected: AuthType.USE_GEMINI, + desc: 'from GEMINI_API_KEY env var', + }, + { + setup: () => {}, + expected: AuthType.LOGIN_WITH_GOOGLE, + desc: 'defaults to Sign in with Google', + }, + ])('selects initial auth type $desc', async ({ setup, expected }) => { + setup(); + const { unmount } = await renderWithProviders(); + const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0]; + expect(items[initialIndex].value).toBe(expected); + unmount(); + }); + }); + + describe('handleAuthSelect', () => { + it('calls onAuthError if validation fails', async () => { + mockedValidateAuthMethod.mockResolvedValue('Invalid method'); + const { unmount } = await renderWithProviders(); + const { onSelect: handleAuthSelect } = + mockedRadioButtonSelect.mock.calls[0][0]; + await handleAuthSelect(AuthType.USE_GEMINI); + + expect(mockedValidateAuthMethod).toHaveBeenCalledWith( + AuthType.USE_GEMINI, + props.settings, + ); + expect(props.onAuthError).toHaveBeenCalledWith('Invalid method'); + expect(props.settings.setValue).not.toHaveBeenCalled(); + unmount(); + }); + + it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => { + mockedValidateAuthMethod.mockResolvedValue(null); + const { unmount } = await renderWithProviders(); + const { onSelect: handleAuthSelect } = + mockedRadioButtonSelect.mock.calls[0][0]; + await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE); + + expect(props.setAuthContext).toHaveBeenCalledWith({ + requiresRestart: true, + }); + unmount(); + }); + + it('sets auth context with requiresRestart: true for USE_VERTEX_AI in Cloud Shell', async () => { + vi.stubEnv('CLOUD_SHELL', 'true'); + mockedValidateAuthMethod.mockResolvedValue(null); + const { unmount } = await renderWithProviders(); + const { onSelect: handleAuthSelect } = + mockedRadioButtonSelect.mock.calls[0][0]; + await handleAuthSelect(AuthType.USE_VERTEX_AI); + + expect(props.setAuthContext).toHaveBeenCalledWith({ + requiresRestart: true, + }); + unmount(); + }); + + it('sets auth context with empty object for USE_VERTEX_AI outside Cloud Shell', async () => { + vi.stubEnv('CLOUD_SHELL', ''); + mockedValidateAuthMethod.mockResolvedValue(null); + const { unmount } = await renderWithProviders(); + const { onSelect: handleAuthSelect } = + mockedRadioButtonSelect.mock.calls[0][0]; + await handleAuthSelect(AuthType.USE_VERTEX_AI); + + expect(props.setAuthContext).toHaveBeenCalledWith({}); + unmount(); + }); + + it('sets auth context with empty object for other auth types', async () => { + mockedValidateAuthMethod.mockResolvedValue(null); + const { unmount } = await renderWithProviders(); + const { onSelect: handleAuthSelect } = + mockedRadioButtonSelect.mock.calls[0][0]; + await handleAuthSelect(AuthType.USE_GEMINI); + + expect(props.setAuthContext).toHaveBeenCalledWith({}); + unmount(); + }); + + it('always shows API key dialog even when env var is present', async () => { + mockedValidateAuthMethod.mockResolvedValue(null); + vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env'); + // props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup + + const { unmount } = await renderWithProviders(); + const { onSelect: handleAuthSelect } = + mockedRadioButtonSelect.mock.calls[0][0]; + await handleAuthSelect(AuthType.USE_GEMINI); + + expect(props.setAuthState).toHaveBeenCalledWith( + AuthState.AwaitingApiKeyInput, + ); + unmount(); + }); + + it('always shows API key dialog even when env var is empty string', async () => { + mockedValidateAuthMethod.mockResolvedValue(null); + vi.stubEnv('GEMINI_API_KEY', ''); // Empty string + // props.settings.merged.security.auth.selectedType is undefined here + + const { unmount } = await renderWithProviders(); + const { onSelect: handleAuthSelect } = + mockedRadioButtonSelect.mock.calls[0][0]; + await handleAuthSelect(AuthType.USE_GEMINI); + + expect(props.setAuthState).toHaveBeenCalledWith( + AuthState.AwaitingApiKeyInput, + ); + unmount(); + }); + + it('shows API key dialog on initial setup if no env var is present', async () => { + mockedValidateAuthMethod.mockResolvedValue(null); + // process.env['GEMINI_API_KEY'] is not set + // props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup + + const { unmount } = await renderWithProviders(); + const { onSelect: handleAuthSelect } = + mockedRadioButtonSelect.mock.calls[0][0]; + await handleAuthSelect(AuthType.USE_GEMINI); + + expect(props.setAuthState).toHaveBeenCalledWith( + AuthState.AwaitingApiKeyInput, + ); + unmount(); + }); + + it('always shows API key dialog on re-auth even if env var is present', async () => { + mockedValidateAuthMethod.mockResolvedValue(null); + vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env'); + // Simulate switching from a different auth method (e.g., Google Login → API key) + props.settings.merged.security.auth.selectedType = + AuthType.LOGIN_WITH_GOOGLE; + + const { unmount } = await renderWithProviders(); + const { onSelect: handleAuthSelect } = + mockedRadioButtonSelect.mock.calls[0][0]; + await handleAuthSelect(AuthType.USE_GEMINI); + + expect(props.setAuthState).toHaveBeenCalledWith( + AuthState.AwaitingApiKeyInput, + ); + unmount(); + }); + + it('exits process for Sign in with Google when browser is suppressed', async () => { + vi.useFakeTimers(); + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + const logSpy = vi.spyOn(debugLogger, 'log').mockImplementation(() => {}); + vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true); + mockedValidateAuthMethod.mockResolvedValue(null); + + const { unmount } = await renderWithProviders(); + const { onSelect: handleAuthSelect } = + mockedRadioButtonSelect.mock.calls[0][0]; + await act(async () => { + await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE); + await vi.runAllTimersAsync(); + }); + + expect(mockedRunExitCleanup).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE); + + exitSpy.mockRestore(); + logSpy.mockRestore(); + vi.useRealTimers(); + unmount(); + }); + }); + + it('displays authError when provided', async () => { + props.authError = 'Something went wrong'; + const { lastFrame, unmount } = await renderWithProviders( + , + ); + expect(lastFrame()).toContain('Something went wrong'); + unmount(); + }); + + describe('useKeypress', () => { + it.each([ + { + desc: 'does nothing on escape if authError is present', + setup: () => { + props.authError = 'Some error'; + }, + expectations: (p: typeof props) => { + expect(p.onAuthError).not.toHaveBeenCalled(); + expect(p.setAuthState).not.toHaveBeenCalled(); + }, + }, + { + desc: 'calls onAuthError on escape if no auth method is set', + setup: () => { + props.settings.merged.security.auth.selectedType = undefined; + }, + expectations: (p: typeof props) => { + expect(p.onAuthError).toHaveBeenCalledWith( + 'You must select an auth method to proceed. Press Ctrl+C twice to exit.', + ); + }, + }, + { + desc: 'calls setAuthState(Unauthenticated) on escape if auth method is set', + setup: () => { + props.settings.merged.security.auth.selectedType = + AuthType.USE_GEMINI; + }, + expectations: (p: typeof props) => { + expect(p.setAuthState).toHaveBeenCalledWith( + AuthState.Unauthenticated, + ); + expect(p.settings.setValue).not.toHaveBeenCalled(); + }, + }, + ])('$desc', async ({ setup, expectations }) => { + setup(); + const { unmount } = await renderWithProviders(); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + keypressHandler({ name: 'escape' }); + expectations(props); + unmount(); + }); + }); + + describe('Snapshots', () => { + it('renders correctly with default props', async () => { + const { lastFrame, unmount } = await renderWithProviders( + , + ); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('renders correctly with auth error', async () => { + props.authError = 'Something went wrong'; + const { lastFrame, unmount } = await renderWithProviders( + , + ); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('renders correctly with enforced auth type', async () => { + props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; + const { lastFrame, unmount } = await renderWithProviders( + , + ); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + }); +}); diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e5b08e6cc2db9d974257f09431d1bebc16f3b62b --- /dev/null +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -0,0 +1,261 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useCallback, useState } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; +import { + SettingScope, + type LoadableSettingScope, + type LoadedSettings, +} from '../../config/settings.js'; +import { + AuthType, + clearCachedCredentialFile, + type Config, +} from '@google/gemini-cli-core'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { AuthState } from '../types.js'; +import { validateAuthMethodWithSettings } from './useAuth.js'; +import { relaunchApp } from '../../utils/processUtils.js'; + +interface AuthDialogProps { + config: Config; + settings: LoadedSettings; + setAuthState: (state: AuthState) => void; + authError: string | null; + onAuthError: (error: string | null) => void; + setAuthContext: (context: { requiresRestart?: boolean }) => void; +} + +export function AuthDialog({ + config, + settings, + setAuthState, + authError, + onAuthError, + setAuthContext, +}: AuthDialogProps): React.JSX.Element { + const [exiting, setExiting] = useState(false); + let items = [ + { + label: 'Sign in with Google', + value: AuthType.LOGIN_WITH_GOOGLE, + key: AuthType.LOGIN_WITH_GOOGLE, + }, + ...(process.env['CLOUD_SHELL'] === 'true' + ? [ + { + label: 'Use Cloud Shell user credentials', + value: AuthType.COMPUTE_ADC, + key: AuthType.COMPUTE_ADC, + }, + ] + : process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true' + ? [ + { + label: 'Use metadata server application default credentials', + value: AuthType.COMPUTE_ADC, + key: AuthType.COMPUTE_ADC, + }, + ] + : []), + { + label: 'Use Gemini API Key', + value: AuthType.USE_GEMINI, + key: AuthType.USE_GEMINI, + }, + { + label: 'Vertex AI', + value: AuthType.USE_VERTEX_AI, + key: AuthType.USE_VERTEX_AI, + }, + ]; + + if (settings.merged.security.auth.enforcedType) { + items = items.filter( + (item) => item.value === settings.merged.security.auth.enforcedType, + ); + } + + let defaultAuthType: AuthType | null = null; + const defaultAuthTypeEnv = process.env['GEMINI_DEFAULT_AUTH_TYPE']; + if ( + defaultAuthTypeEnv && + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + Object.values(AuthType).includes(defaultAuthTypeEnv as AuthType) + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + defaultAuthType = defaultAuthTypeEnv as AuthType; + } + + let initialAuthIndex = items.findIndex((item) => { + if (settings.merged.security.auth.selectedType) { + return item.value === settings.merged.security.auth.selectedType; + } + + if (defaultAuthType) { + return item.value === defaultAuthType; + } + + if (process.env['GEMINI_API_KEY']) { + return item.value === AuthType.USE_GEMINI; + } + + return item.value === AuthType.LOGIN_WITH_GOOGLE; + }); + if (settings.merged.security.auth.enforcedType) { + initialAuthIndex = 0; + } + + const onSelect = useCallback( + async (authType: AuthType | undefined, scope: LoadableSettingScope) => { + if (exiting) { + return; + } + if (authType) { + const needsRestart = + authType === AuthType.LOGIN_WITH_GOOGLE || + (authType === AuthType.USE_VERTEX_AI && + process.env['CLOUD_SHELL'] === 'true'); + + if (needsRestart) { + setAuthContext({ requiresRestart: true }); + } else { + setAuthContext({}); + } + await clearCachedCredentialFile(); + + settings.setValue(scope, 'security.auth.selectedType', authType); + if ( + authType === AuthType.LOGIN_WITH_GOOGLE && + config.isBrowserLaunchSuppressed() + ) { + setExiting(true); + setTimeout(relaunchApp, 100); + return; + } + + if (authType === AuthType.USE_GEMINI) { + // Always show the API key input dialog so the user can + // explicitly enter or confirm their key, regardless of + // whether GEMINI_API_KEY env var or a stored key exists. + setAuthState(AuthState.AwaitingApiKeyInput); + return; + } + } + setAuthState(AuthState.Unauthenticated); + }, + [settings, config, setAuthState, exiting, setAuthContext], + ); + + const handleAuthSelect = async (authMethod: AuthType) => { + const error = await validateAuthMethodWithSettings( + authMethod, + settings, + ).catch((e) => (e instanceof Error ? e.message : String(e))); + if (error) { + onAuthError(error); + } else { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + onSelect(authMethod, SettingScope.User); + } + }; + + useKeypress( + (key) => { + if (key.name === 'escape') { + // Prevent exit if there is an error message. + // This means they user is not authenticated yet. + if (authError) { + return true; + } + if (settings.merged.security.auth.selectedType === undefined) { + // Prevent exiting if no auth method is set + onAuthError( + 'You must select an auth method to proceed. Press Ctrl+C twice to exit.', + ); + return true; + } + // eslint-disable-next-line @typescript-eslint/no-floating-promises + onSelect(undefined, SettingScope.User); + return true; + } + return false; + }, + { isActive: true }, + ); + + if (exiting) { + return ( + + + Logging in with Google... Restarting Gemini CLI to continue. + + + ); + } + + return ( + + ? + + + Get started + + + + How would you like to authenticate for this project? + + + + { + onAuthError(null); + }} + /> + + {authError && ( + + {authError} + + )} + + (Use Enter to select) + + + + Terms of Services and Privacy Notice for Gemini CLI + + + + + {'https://geminicli.com/docs/resources/tos-privacy/'} + + + + + ); +} diff --git a/packages/cli/src/ui/auth/AuthInProgress.test.tsx b/packages/cli/src/ui/auth/AuthInProgress.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a387fcb6f393a78a27bc884b813279296b0b7c00 --- /dev/null +++ b/packages/cli/src/ui/auth/AuthInProgress.test.tsx @@ -0,0 +1,127 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render } from '../../test-utils/render.js'; +import { act } from 'react'; +import { AuthInProgress } from './AuthInProgress.js'; +import { useKeypress, type Key } from '../hooks/useKeypress.js'; +import { debugLogger } from '@google/gemini-cli-core'; + +// Mock dependencies +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + debugLogger: { + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + }; +}); + +vi.mock('../hooks/useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + +vi.mock('../components/CliSpinner.js', () => ({ + CliSpinner: () => '[Spinner]', +})); + +describe('AuthInProgress', () => { + const onTimeout = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.mocked(debugLogger.error).mockImplementation((...args) => { + if ( + // eslint-disable-next-line no-restricted-syntax + typeof args[0] === 'string' && + args[0].includes('was not wrapped in act') + ) { + return; + } + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders initial state with spinner', async () => { + const { lastFrame, unmount } = await render( + , + ); + expect(lastFrame()).toContain('[Spinner] Waiting for authentication...'); + expect(lastFrame()).toContain('Press Esc or Ctrl+C to cancel'); + unmount(); + }); + + it('calls onTimeout when ESC is pressed', async () => { + const { waitUntilReady, unmount } = await render( + , + ); + const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0]; + + await act(async () => { + keypressHandler({ name: 'escape' } as unknown as Key); + }); + // Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act + await act(async () => { + await waitUntilReady(); + }); + + expect(onTimeout).toHaveBeenCalled(); + unmount(); + }); + + it('calls onTimeout when Ctrl+C is pressed', async () => { + const { waitUntilReady, unmount } = await render( + , + ); + const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0]; + + await act(async () => { + keypressHandler({ name: 'c', ctrl: true } as unknown as Key); + }); + await waitUntilReady(); + + expect(onTimeout).toHaveBeenCalled(); + unmount(); + }); + + it('calls onTimeout and shows timeout message after 3 minutes', async () => { + const { lastFrame, waitUntilReady, unmount } = await render( + , + ); + + await act(async () => { + vi.advanceTimersByTime(180000); + }); + await waitUntilReady(); + + expect(onTimeout).toHaveBeenCalled(); + expect(lastFrame()).toContain('Authentication timed out'); + unmount(); + }); + + it('clears timer on unmount', async () => { + const { unmount } = await render(); + + await act(async () => { + unmount(); + }); + + await act(async () => { + vi.advanceTimersByTime(180000); + }); + expect(onTimeout).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/auth/AuthInProgress.tsx b/packages/cli/src/ui/auth/AuthInProgress.tsx new file mode 100644 index 0000000000000000000000000000000000000000..03d609c44415603c003a3c8beb369181392aff75 --- /dev/null +++ b/packages/cli/src/ui/auth/AuthInProgress.tsx @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useState, useEffect } from 'react'; +import { Box, Text } from 'ink'; +import { CliSpinner } from '../components/CliSpinner.js'; +import { theme } from '../semantic-colors.js'; +import { useKeypress } from '../hooks/useKeypress.js'; + +interface AuthInProgressProps { + onTimeout: () => void; +} + +export function AuthInProgress({ + onTimeout, +}: AuthInProgressProps): React.JSX.Element { + const [timedOut, setTimedOut] = useState(false); + + useKeypress( + (key) => { + if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { + onTimeout(); + } + }, + { isActive: true }, + ); + + useEffect(() => { + const timer = setTimeout(() => { + setTimedOut(true); + onTimeout(); + }, 180000); + + return () => clearTimeout(timer); + }, [onTimeout]); + + return ( + + {timedOut ? ( + + Authentication timed out. Please try again. + + ) : ( + + + Waiting for authentication... (Press Esc + or Ctrl+C to cancel) + + + )} + + ); +} diff --git a/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx b/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4b5d44e6d591f3f600b0732fdfb3d331eb66fe9c --- /dev/null +++ b/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx @@ -0,0 +1,231 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import { BannedAccountDialog } from './BannedAccountDialog.js'; +import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { + openBrowserSecurely, + shouldLaunchBrowser, +} from '@google/gemini-cli-core'; +import { Text } from 'ink'; +import { runExitCleanup } from '../../utils/cleanup.js'; +import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + openBrowserSecurely: vi.fn(), + shouldLaunchBrowser: vi.fn().mockReturnValue(true), + }; +}); + +vi.mock('../../utils/cleanup.js', () => ({ + runExitCleanup: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../hooks/useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + +vi.mock('../components/shared/RadioButtonSelect.js', () => ({ + RadioButtonSelect: vi.fn(({ items }) => ( + <> + {items.map((item: { value: string; label: string }) => ( + {item.label} + ))} + + )), +})); + +const mockedRadioButtonSelect = RadioButtonSelect as Mock; +const mockedUseKeypress = useKeypress as Mock; +const mockedOpenBrowser = openBrowserSecurely as Mock; +const mockedShouldLaunchBrowser = shouldLaunchBrowser as Mock; +const mockedRunExitCleanup = runExitCleanup as Mock; + +const DEFAULT_SUSPENSION_INFO: AccountSuspensionInfo = { + message: + 'This service has been disabled in this account for violation of Terms of Service. Please submit an appeal to continue using this product.', + appealUrl: 'https://example.com/appeal', + appealLinkText: 'Appeal Here', +}; + +describe('BannedAccountDialog', () => { + let onExit: Mock; + let onChangeAuth: Mock; + + beforeEach(() => { + vi.resetAllMocks(); + mockedShouldLaunchBrowser.mockReturnValue(true); + mockedOpenBrowser.mockResolvedValue(undefined); + mockedRunExitCleanup.mockResolvedValue(undefined); + onExit = vi.fn(); + onChangeAuth = vi.fn(); + }); + + it('renders the suspension message from accountSuspensionInfo', async () => { + const { lastFrame, unmount } = await renderWithProviders( + , + ); + const frame = lastFrame(); + expect(frame).toContain('Account Suspended'); + expect(frame).toContain('violation of Terms of Service'); + expect(frame).toContain('Escape to exit'); + unmount(); + }); + + it('renders menu options with appeal link text from response', async () => { + const { unmount } = await renderWithProviders( + , + ); + const items = mockedRadioButtonSelect.mock.calls[0][0].items; + expect(items).toHaveLength(3); + expect(items[0].label).toBe('Appeal Here'); + expect(items[1].label).toBe('Change authentication'); + expect(items[2].label).toBe('Exit'); + unmount(); + }); + + it('hides form option when no appealUrl is provided', async () => { + const infoWithoutUrl: AccountSuspensionInfo = { + message: 'Account suspended.', + }; + const { unmount } = await renderWithProviders( + , + ); + const items = mockedRadioButtonSelect.mock.calls[0][0].items; + expect(items).toHaveLength(2); + expect(items[0].label).toBe('Change authentication'); + expect(items[1].label).toBe('Exit'); + unmount(); + }); + + it('uses default label when appealLinkText is not provided', async () => { + const infoWithoutLinkText: AccountSuspensionInfo = { + message: 'Account suspended.', + appealUrl: 'https://example.com/appeal', + }; + const { unmount } = await renderWithProviders( + , + ); + const items = mockedRadioButtonSelect.mock.calls[0][0].items; + expect(items[0].label).toBe('Open the Google Form'); + unmount(); + }); + + it('opens browser when appeal option is selected', async () => { + const { unmount } = await renderWithProviders( + , + ); + const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; + await onSelect('open_form'); + expect(mockedOpenBrowser).toHaveBeenCalledWith( + 'https://example.com/appeal', + ); + expect(onExit).not.toHaveBeenCalled(); + unmount(); + }); + + it('shows URL when browser cannot be launched', async () => { + mockedShouldLaunchBrowser.mockReturnValue(false); + const { lastFrame, unmount } = await renderWithProviders( + , + ); + const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; + onSelect('open_form'); + await waitFor(() => { + expect(lastFrame()).toContain('Please open this URL in a browser'); + }); + expect(mockedOpenBrowser).not.toHaveBeenCalled(); + unmount(); + }); + + it('calls onExit when "Exit" is selected', async () => { + const { unmount } = await renderWithProviders( + , + ); + const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; + await onSelect('exit'); + expect(mockedRunExitCleanup).toHaveBeenCalled(); + expect(onExit).toHaveBeenCalled(); + unmount(); + }); + + it('calls onChangeAuth when "Change authentication" is selected', async () => { + const { unmount } = await renderWithProviders( + , + ); + const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; + onSelect('change_auth'); + expect(onChangeAuth).toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); + unmount(); + }); + + it('exits on escape key', async () => { + const { unmount } = await renderWithProviders( + , + ); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + const result = keypressHandler({ name: 'escape' }); + expect(result).toBe(true); + unmount(); + }); + + it('renders snapshot correctly', async () => { + const { lastFrame, unmount } = await renderWithProviders( + , + ); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/auth/BannedAccountDialog.tsx b/packages/cli/src/ui/auth/BannedAccountDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e051ba082be881423ea627225da2f4a2b1ce0d4f --- /dev/null +++ b/packages/cli/src/ui/auth/BannedAccountDialog.tsx @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { + openBrowserSecurely, + shouldLaunchBrowser, +} from '@google/gemini-cli-core'; +import { runExitCleanup } from '../../utils/cleanup.js'; +import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js'; + +interface BannedAccountDialogProps { + accountSuspensionInfo: AccountSuspensionInfo; + onExit: () => void; + onChangeAuth: () => void; +} + +export function BannedAccountDialog({ + accountSuspensionInfo, + onExit, + onChangeAuth, +}: BannedAccountDialogProps): React.JSX.Element { + const [errorMessage, setErrorMessage] = useState(null); + + const appealUrl = accountSuspensionInfo.appealUrl; + const appealLinkText = + accountSuspensionInfo.appealLinkText ?? 'Open the Google Form'; + + const items = useMemo(() => { + const menuItems = []; + if (appealUrl) { + menuItems.push({ + label: appealLinkText, + value: 'open_form' as const, + key: 'open_form', + }); + } + menuItems.push( + { + label: 'Change authentication', + value: 'change_auth' as const, + key: 'change_auth', + }, + { + label: 'Exit', + value: 'exit' as const, + key: 'exit', + }, + ); + return menuItems; + }, [appealUrl, appealLinkText]); + + useKeypress( + (key) => { + if (key.name === 'escape') { + void handleExit(); + return true; + } + return false; + }, + { isActive: true }, + ); + + const handleExit = useCallback(async () => { + await runExitCleanup(); + onExit(); + }, [onExit]); + + const handleSelect = useCallback( + async (choice: string) => { + if (choice === 'open_form' && appealUrl) { + if (!shouldLaunchBrowser()) { + setErrorMessage(`Please open this URL in a browser: ${appealUrl}`); + return; + } + + try { + await openBrowserSecurely(appealUrl); + } catch { + setErrorMessage(`Failed to open browser. Please visit: ${appealUrl}`); + } + } else if (choice === 'change_auth') { + onChangeAuth(); + } else { + await handleExit(); + } + }, + [handleExit, onChangeAuth, appealUrl], + ); + + return ( + + + Error: Account Suspended + + + + {accountSuspensionInfo.message} + + + {appealUrl && ( + <> + + Appeal URL: + + + [{appealUrl}] + + + )} + + {errorMessage && ( + + {errorMessage} + + )} + + + void handleSelect(choice)} + /> + + + + Escape to exit + + + ); +} diff --git a/packages/cli/src/ui/auth/LoginRestartDialog.test.tsx b/packages/cli/src/ui/auth/LoginRestartDialog.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3c5f22109e72620c709bc712adadc27078d7cce4 --- /dev/null +++ b/packages/cli/src/ui/auth/LoginRestartDialog.test.tsx @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { LoginRestartDialog } from './LoginRestartDialog.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { runExitCleanup } from '../../utils/cleanup.js'; +import { + RELAUNCH_EXIT_CODE, + _resetRelaunchStateForTesting, +} from '../../utils/processUtils.js'; +import { type Config } from '@google/gemini-cli-core'; + +// Mocks +vi.mock('../hooks/useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + +vi.mock('../../utils/cleanup.js', () => ({ + runExitCleanup: vi.fn(), +})); + +const mockedUseKeypress = useKeypress as Mock; +const mockedRunExitCleanup = runExitCleanup as Mock; + +describe('LoginRestartDialog', () => { + const onDismiss = vi.fn(); + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + + const mockConfig = { + getRemoteAdminSettings: vi.fn(), + } as unknown as Config; + + beforeEach(() => { + vi.clearAllMocks(); + exitSpy.mockClear(); + vi.useRealTimers(); + _resetRelaunchStateForTesting(); + }); + + it('renders correctly with default message', async () => { + const { lastFrame, unmount } = await render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('renders correctly with custom message', async () => { + const { lastFrame, unmount } = await render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('calls onDismiss when escape is pressed', async () => { + const { unmount } = await render( + , + ); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + + keypressHandler({ + name: 'escape', + shift: false, + ctrl: false, + cmd: false, + sequence: '\u001b', + }); + + expect(onDismiss).toHaveBeenCalledTimes(1); + unmount(); + }); + + it.each(['r', 'R'])( + 'calls runExitCleanup and process.exit when %s is pressed', + async (keyName) => { + vi.useFakeTimers(); + + const { unmount } = await render( + , + ); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + + keypressHandler({ + name: keyName, + shift: false, + ctrl: false, + cmd: false, + sequence: keyName, + }); + + // Advance timers to trigger the setTimeout callback + await vi.runAllTimersAsync(); + + expect(mockedRunExitCleanup).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE); + + vi.useRealTimers(); + unmount(); + }, + ); +}); diff --git a/packages/cli/src/ui/auth/LoginRestartDialog.tsx b/packages/cli/src/ui/auth/LoginRestartDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..324f461ed1e10de8c6b3d2d89e81a3c257b4724b --- /dev/null +++ b/packages/cli/src/ui/auth/LoginRestartDialog.tsx @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type Config } from '@google/gemini-cli-core'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { relaunchApp } from '../../utils/processUtils.js'; + +interface LoginRestartDialogProps { + onDismiss: () => void; + config: Config; + message?: string; +} + +export const LoginRestartDialog = ({ + onDismiss, + config, + message, +}: LoginRestartDialogProps) => { + useKeypress( + (key) => { + if (key.name === 'escape') { + onDismiss(); + return true; + } else if (key.name === 'r' || key.name === 'R') { + setTimeout(async () => { + if (process.send) { + const remoteSettings = config.getRemoteAdminSettings(); + if (remoteSettings) { + process.send({ + type: 'admin-settings-update', + settings: remoteSettings, + }); + } + } + await relaunchApp(); + }, 100); + return true; + } + return false; + }, + { isActive: true }, + ); + + const displayMessage = + message ?? + "You've successfully signed in with Google. Gemini CLI needs to be restarted."; + + return ( + + {displayMessage} + + Press R to restart, or Esc to choose a different authentication method. + + + ); +}; diff --git a/packages/cli/src/ui/auth/useAuth.test.tsx b/packages/cli/src/ui/auth/useAuth.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d512846ee2f360f2f580c84c0a887ee0f2445769 --- /dev/null +++ b/packages/cli/src/ui/auth/useAuth.test.tsx @@ -0,0 +1,335 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act } from 'react'; +import { renderHook } from '../../test-utils/render.js'; +import { useAuthCommand, validateAuthMethodWithSettings } from './useAuth.js'; +import { + AuthType, + type Config, + ProjectIdRequiredError, +} from '@google/gemini-cli-core'; +import { AuthState } from '../types.js'; +import type { LoadedSettings } from '../../config/settings.js'; + +// Mock dependencies +const mockLoadApiKey = vi.fn(); +const mockValidateAuthMethod = vi.fn(); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadApiKey: () => mockLoadApiKey(), + }; +}); + +vi.mock('../../config/auth.js', () => ({ + validateAuthMethod: (authType: AuthType) => mockValidateAuthMethod(authType), +})); + +describe('useAuth', () => { + beforeEach(() => { + vi.resetAllMocks(); + delete process.env['GEMINI_API_KEY']; + delete process.env['GEMINI_DEFAULT_AUTH_TYPE']; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('validateAuthMethodWithSettings', () => { + it('should return error if auth type is enforced and does not match', async () => { + const settings = { + merged: { + security: { + auth: { + enforcedType: AuthType.LOGIN_WITH_GOOGLE, + }, + }, + }, + } as LoadedSettings; + + const error = await validateAuthMethodWithSettings( + AuthType.USE_GEMINI, + settings, + ); + expect(error).toContain('Authentication is enforced to be oauth'); + }); + + it('should return null if useExternal is true', async () => { + const settings = { + merged: { + security: { + auth: { + useExternal: true, + }, + }, + }, + } as LoadedSettings; + + const error = await validateAuthMethodWithSettings( + AuthType.LOGIN_WITH_GOOGLE, + settings, + ); + expect(error).toBeNull(); + }); + + it('should return null if authType is USE_GEMINI', async () => { + const settings = { + merged: { + security: { + auth: {}, + }, + }, + } as LoadedSettings; + + const error = await validateAuthMethodWithSettings( + AuthType.USE_GEMINI, + settings, + ); + expect(error).toBeNull(); + }); + + it('should call validateAuthMethod for other auth types', async () => { + const settings = { + merged: { + security: { + auth: {}, + }, + }, + } as LoadedSettings; + + mockValidateAuthMethod.mockResolvedValue('Validation Error'); + const error = await validateAuthMethodWithSettings( + AuthType.LOGIN_WITH_GOOGLE, + settings, + ); + expect(error).toBe('Validation Error'); + expect(mockValidateAuthMethod).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + }); + }); + + describe('useAuthCommand', () => { + const mockConfig = { + refreshAuth: vi.fn(), + } as unknown as Config; + + const createSettings = (selectedType?: AuthType) => + ({ + merged: { + security: { + auth: { + selectedType, + }, + }, + }, + }) as LoadedSettings; + + let deferredRefreshAuth: { + resolve: () => void; + reject: (e: Error) => void; + }; + + beforeEach(() => { + vi.mocked(mockConfig.refreshAuth).mockImplementation( + () => + new Promise((resolve, reject) => { + deferredRefreshAuth = { resolve, reject }; + }), + ); + }); + + it('should initialize with Unauthenticated state', async () => { + const { result } = await renderHook(() => + useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), + ); + // Because we defer refreshAuth, the initial state is safely caught here + expect(result.current.authState).toBe(AuthState.Unauthenticated); + + await act(async () => { + deferredRefreshAuth.resolve(); + }); + + expect(result.current.authState).toBe(AuthState.Authenticated); + }); + + it('should set error if no auth type is selected and no env key', async () => { + const { result } = await renderHook(() => + useAuthCommand(createSettings(undefined), mockConfig), + ); + + // This happens synchronously, no deferred promise + expect(result.current.authError).toBe( + 'No authentication method selected.', + ); + expect(result.current.authState).toBe(AuthState.Updating); + }); + + it('should set error if no auth type is selected but env key exists', async () => { + process.env['GEMINI_API_KEY'] = 'env-key'; + const { result } = await renderHook(() => + useAuthCommand(createSettings(undefined), mockConfig), + ); + + expect(result.current.authError).toContain( + 'Existing API key detected (GEMINI_API_KEY)', + ); + expect(result.current.authState).toBe(AuthState.Updating); + }); + + it('should transition to AwaitingApiKeyInput if USE_GEMINI and no key found', async () => { + let deferredLoadKey: { resolve: (k: string | null) => void }; + mockLoadApiKey.mockImplementation( + () => + new Promise((resolve) => { + deferredLoadKey = { resolve }; + }), + ); + + const { result } = await renderHook(() => + useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), + ); + + await act(async () => { + deferredLoadKey.resolve(null); + }); + + expect(result.current.authState).toBe(AuthState.AwaitingApiKeyInput); + }); + + it('should authenticate if USE_GEMINI and key is found', async () => { + let deferredLoadKey: { resolve: (k: string | null) => void }; + mockLoadApiKey.mockImplementation( + () => + new Promise((resolve) => { + deferredLoadKey = { resolve }; + }), + ); + + const { result } = await renderHook(() => + useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), + ); + + await act(async () => { + deferredLoadKey.resolve('stored-key'); + }); + + await act(async () => { + deferredRefreshAuth.resolve(); + }); + + expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_GEMINI); + expect(result.current.authState).toBe(AuthState.Authenticated); + expect(result.current.apiKeyDefaultValue).toBe('stored-key'); + }); + + it('should authenticate if USE_GEMINI and env key is found', async () => { + process.env['GEMINI_API_KEY'] = 'env-key'; + + const { result } = await renderHook(() => + useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), + ); + + await act(async () => { + deferredRefreshAuth.resolve(); + }); + + expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_GEMINI); + expect(result.current.authState).toBe(AuthState.Authenticated); + expect(result.current.apiKeyDefaultValue).toBe('env-key'); + }); + + it('should prioritize env key over stored key when both are present', async () => { + process.env['GEMINI_API_KEY'] = 'env-key'; + + const { result } = await renderHook(() => + useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), + ); + + await act(async () => { + deferredRefreshAuth.resolve(); + }); + + expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_GEMINI); + expect(result.current.authState).toBe(AuthState.Authenticated); + expect(result.current.apiKeyDefaultValue).toBe('env-key'); + }); + + it('should set error if validation fails', async () => { + mockValidateAuthMethod.mockResolvedValue('Validation Failed'); + const { result } = await renderHook(() => + useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), + ); + + expect(result.current.authError).toBe('Validation Failed'); + expect(result.current.authState).toBe(AuthState.Updating); + }); + + it('should set error if GEMINI_DEFAULT_AUTH_TYPE is invalid', async () => { + process.env['GEMINI_DEFAULT_AUTH_TYPE'] = 'INVALID_TYPE'; + const { result } = await renderHook(() => + useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), + ); + + expect(result.current.authError).toContain( + 'Invalid value for GEMINI_DEFAULT_AUTH_TYPE', + ); + expect(result.current.authState).toBe(AuthState.Updating); + }); + + it('should authenticate successfully for valid auth type', async () => { + const { result } = await renderHook(() => + useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), + ); + + await act(async () => { + deferredRefreshAuth.resolve(); + }); + + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(result.current.authState).toBe(AuthState.Authenticated); + expect(result.current.authError).toBeNull(); + }); + + it('should handle refreshAuth failure', async () => { + const { result } = await renderHook(() => + useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), + ); + + await act(async () => { + deferredRefreshAuth.reject(new Error('Auth Failed')); + }); + + expect(result.current.authError).toContain('Failed to sign in'); + expect(result.current.authState).toBe(AuthState.Updating); + }); + + it('should handle ProjectIdRequiredError without "Failed to login" prefix', async () => { + const projectIdError = new ProjectIdRequiredError(); + const { result } = await renderHook(() => + useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), + ); + + await act(async () => { + deferredRefreshAuth.reject(projectIdError); + }); + + expect(result.current.authError).toBe( + 'This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID env var. See https://goo.gle/gemini-cli-auth-docs#workspace-gca', + ); + expect(result.current.authError).not.toContain('Failed to login'); + expect(result.current.authState).toBe(AuthState.Updating); + }); + }); +}); diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts new file mode 100644 index 0000000000000000000000000000000000000000..caa9ed2c4b8b20f460100a362fcde8154b6d0107 --- /dev/null +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useCallback } from 'react'; +import type { LoadedSettings } from '../../config/settings.js'; +import { + AuthType, + type Config, + loadApiKey, + debugLogger, + isAccountSuspendedError, + ProjectIdRequiredError, +} from '@google/gemini-cli-core'; +import { getErrorMessage } from '@google/gemini-cli-core'; +import { AuthState } from '../types.js'; +import { validateAuthMethod } from '../../config/auth.js'; + +export async function validateAuthMethodWithSettings( + authType: AuthType, + settings: LoadedSettings, +): Promise { + const enforcedType = settings.merged.security.auth.enforcedType; + if (enforcedType && enforcedType !== authType) { + return `Authentication is enforced to be ${enforcedType}, but you are currently using ${authType}.`; + } + if (settings.merged.security.auth.useExternal) { + return null; + } + // If using Gemini API key, we don't validate it here as we might need to prompt for it. + if (authType === AuthType.USE_GEMINI) { + return null; + } + return validateAuthMethod(authType); +} + +import type { AccountSuspensionInfo } from '../contexts/UIStateContext.js'; + +export const useAuthCommand = ( + settings: LoadedSettings, + config: Config, + initialAuthError: string | null = null, + initialAccountSuspensionInfo: AccountSuspensionInfo | null = null, +) => { + const [authState, setAuthState] = useState( + initialAuthError ? AuthState.Updating : AuthState.Unauthenticated, + ); + + const [authError, setAuthError] = useState(initialAuthError); + const [accountSuspensionInfo, setAccountSuspensionInfo] = + useState(initialAccountSuspensionInfo); + const [apiKeyDefaultValue, setApiKeyDefaultValue] = useState< + string | undefined + >(undefined); + + const onAuthError = useCallback( + (error: string | null) => { + setAuthError(error); + if (error) { + setAuthState(AuthState.Updating); + } + }, + [setAuthError, setAuthState], + ); + + const reloadApiKey = useCallback(async () => { + const envKey = process.env['GEMINI_API_KEY']; + if (envKey !== undefined) { + setApiKeyDefaultValue(envKey); + return envKey; + } + + const storedKey = (await loadApiKey()) ?? ''; + setApiKeyDefaultValue(storedKey); + return storedKey; + }, []); + + useEffect(() => { + if (authState === AuthState.AwaitingApiKeyInput) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + reloadApiKey(); + } + }, [authState, reloadApiKey]); + + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + (async () => { + if (authState !== AuthState.Unauthenticated) { + return; + } + + const authType = settings.merged.security.auth.selectedType; + if (!authType) { + if (process.env['GEMINI_API_KEY']) { + onAuthError( + 'Existing API key detected (GEMINI_API_KEY). Select "Gemini API Key" option to use it.', + ); + } else { + onAuthError('No authentication method selected.'); + } + return; + } + + if (authType === AuthType.USE_GEMINI) { + const key = await reloadApiKey(); // Use the unified function + if (!key) { + setAuthState(AuthState.AwaitingApiKeyInput); + return; + } + } + + const error = await validateAuthMethodWithSettings( + authType, + settings, + ).catch((e: unknown) => getErrorMessage(e)); + + if (error) { + onAuthError(error); + return; + } + + const defaultAuthType = process.env['GEMINI_DEFAULT_AUTH_TYPE']; + if ( + defaultAuthType && + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + !Object.values(AuthType).includes(defaultAuthType as AuthType) + ) { + onAuthError( + `Invalid value for GEMINI_DEFAULT_AUTH_TYPE: "${defaultAuthType}". ` + + `Valid values are: ${Object.values(AuthType).join(', ')}.`, + ); + return; + } + + try { + await config.refreshAuth(authType); + + debugLogger.log(`Authenticated via "${authType}".`); + setAuthError(null); + setAuthState(AuthState.Authenticated); + } catch (e) { + const suspendedError = isAccountSuspendedError(e); + if (suspendedError) { + setAccountSuspensionInfo({ + message: suspendedError.message, + appealUrl: suspendedError.appealUrl, + appealLinkText: suspendedError.appealLinkText, + }); + } else if (e instanceof ProjectIdRequiredError) { + // OAuth succeeded but account setup requires project ID + // Show the error message directly without "Failed to login" prefix + onAuthError(getErrorMessage(e)); + } else { + onAuthError(`Failed to sign in. Message: ${getErrorMessage(e)}`); + } + } + })(); + }, [ + settings, + config, + authState, + setAuthState, + setAuthError, + onAuthError, + reloadApiKey, + ]); + + return { + authState, + setAuthState, + authError, + onAuthError, + apiKeyDefaultValue, + reloadApiKey, + accountSuspensionInfo, + setAccountSuspensionInfo, + }; +}; diff --git a/packages/cli/src/ui/components/AboutBox.test.tsx b/packages/cli/src/ui/components/AboutBox.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9115ca31c1e54924a4eaa6430e3eb0f58fab4217 --- /dev/null +++ b/packages/cli/src/ui/components/AboutBox.test.tsx @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../test-utils/render.js'; +import { AboutBox } from './AboutBox.js'; +import { describe, it, expect, vi } from 'vitest'; + +// Mock GIT_COMMIT_INFO +vi.mock('../../generated/git-commit.js', () => ({ + GIT_COMMIT_INFO: 'mock-commit-hash', +})); + +describe('AboutBox', () => { + const defaultProps = { + cliVersion: '1.0.0', + osVersion: 'macOS', + sandboxEnv: 'default', + modelVersion: 'gemini-pro', + selectedAuthType: 'oauth', + gcpProject: '', + ideClient: '', + }; + + it('renders with required props', async () => { + const { lastFrame, unmount } = await renderWithProviders( + , + ); + const output = lastFrame(); + expect(output).toContain('About Gemini CLI'); + expect(output).toContain('1.0.0'); + expect(output).toContain('mock-commit-hash'); + expect(output).toContain('gemini-pro'); + expect(output).toContain('default'); + expect(output).toContain('macOS'); + expect(output).toContain('Signed in with Google'); + unmount(); + }); + + it.each([ + ['gcpProject', 'my-project', 'GCP Project'], + ['ideClient', 'vscode', 'IDE Client'], + ['tier', 'Enterprise', 'Tier'], + ])('renders optional prop %s', async (prop, value, label) => { + const props = { ...defaultProps, [prop]: value }; + const { lastFrame, unmount } = await renderWithProviders( + , + ); + const output = lastFrame(); + expect(output).toContain(label); + expect(output).toContain(value); + unmount(); + }); + + it('renders Auth Method with email when userEmail is provided', async () => { + const props = { ...defaultProps, userEmail: 'test@example.com' }; + const { lastFrame, unmount } = await renderWithProviders( + , + ); + const output = lastFrame(); + expect(output).toContain('Signed in with Google (test@example.com)'); + unmount(); + }); + + it('renders Auth Method correctly when not oauth', async () => { + const props = { ...defaultProps, selectedAuthType: 'api-key' }; + const { lastFrame, unmount } = await renderWithProviders( + , + ); + const output = lastFrame(); + expect(output).toContain('api-key'); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/AboutBox.tsx b/packages/cli/src/ui/components/AboutBox.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aa5fd44c57a28036f1354378dd4762df3706a9c8 --- /dev/null +++ b/packages/cli/src/ui/components/AboutBox.tsx @@ -0,0 +1,164 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; +import { useSettings } from '../contexts/SettingsContext.js'; +import { getDisplayString } from '@google/gemini-cli-core'; + +interface AboutBoxProps { + cliVersion: string; + osVersion: string; + sandboxEnv: string; + modelVersion: string; + selectedAuthType: string; + gcpProject: string; + ideClient: string; + userEmail?: string; + tier?: string; +} + +export const AboutBox: React.FC = ({ + cliVersion, + osVersion, + sandboxEnv, + modelVersion, + selectedAuthType, + gcpProject, + ideClient, + userEmail, + tier, +}) => { + const settings = useSettings(); + const showUserIdentity = settings.merged.ui.showUserIdentity; + + return ( + + + + About Gemini CLI + + + + + + CLI Version + + + + {cliVersion} + + + {GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) && ( + + + + Git Commit + + + + {GIT_COMMIT_INFO} + + + )} + + + + Model + + + + + {getDisplayString(modelVersion)} + + + + + + + Sandbox + + + + {sandboxEnv} + + + + + + OS + + + + {osVersion} + + + {showUserIdentity && ( + + + + Auth Method + + + + + {selectedAuthType.startsWith('oauth') + ? userEmail + ? `Signed in with Google (${userEmail})` + : 'Signed in with Google' + : selectedAuthType} + + + + )} + {showUserIdentity && tier && ( + + + + Tier + + + + {tier} + + + )} + {gcpProject && ( + + + + GCP Project + + + + {gcpProject} + + + )} + {ideClient && ( + + + + IDE Client + + + + {ideClient} + + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx b/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..76a36fe4dc9a68864f5413f285a3d2ba6bc412c6 --- /dev/null +++ b/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../test-utils/render.js'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { act } from 'react'; +import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js'; + +const handleRestartMock = vi.fn(); + +describe('AdminSettingsChangedDialog', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders correctly', async () => { + const { lastFrame } = await renderWithProviders( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('restarts on "r" key press', async () => { + const { stdin } = await renderWithProviders( + , + { + uiActions: { + handleRestart: handleRestartMock, + }, + }, + ); + + act(() => { + stdin.write('r'); + }); + + expect(handleRestartMock).toHaveBeenCalled(); + }); + + it.each(['r', 'R'])('restarts on "%s" key press', async (key) => { + const { stdin } = await renderWithProviders( + , + { + uiActions: { + handleRestart: handleRestartMock, + }, + }, + ); + + act(() => { + stdin.write(key); + }); + + expect(handleRestartMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx b/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dda414129422b706f2a2a639601c2e58ae0031ee --- /dev/null +++ b/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { useUIActions } from '../contexts/UIActionsContext.js'; +import { Command } from '../key/keyMatchers.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; + +export const AdminSettingsChangedDialog = () => { + const keyMatchers = useKeyMatchers(); + const { handleRestart } = useUIActions(); + + useKeypress( + (key) => { + if (keyMatchers[Command.RESTART_APP](key)) { + handleRestart(); + return true; + } + return false; + }, + { isActive: true }, + ); + + const message = + 'Admin settings have changed. Please restart the session to apply new settings.'; + + return ( + + + {message} Press 'r' to restart, or 'Ctrl+C' twice to + exit. + + + ); +}; diff --git a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2c6ea454db3b1043f6b93b1fde1bd32a840f70f8 --- /dev/null +++ b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx @@ -0,0 +1,347 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act } from 'react'; +import { AgentConfigDialog } from './AgentConfigDialog.js'; +import { LoadedSettings, SettingScope } from '../../config/settings.js'; +import type { AgentDefinition } from '@google/gemini-cli-core'; + +enum TerminalKeys { + ENTER = '\u000D', + TAB = '\t', + UP_ARROW = '\u001B[A', + DOWN_ARROW = '\u001B[B', + ESCAPE = '\u001B', +} + +const createMockSettings = ( + userSettings = {}, + workspaceSettings = {}, +): LoadedSettings => { + const settings = new LoadedSettings( + { + settings: { ui: { customThemes: {} }, mcpServers: {}, agents: {} }, + originalSettings: { + ui: { customThemes: {} }, + mcpServers: {}, + agents: {}, + }, + path: '/system/settings.json', + }, + { + settings: {}, + originalSettings: {}, + path: '/system/system-defaults.json', + }, + { + settings: { + ui: { customThemes: {} }, + mcpServers: {}, + agents: { overrides: {} }, + ...userSettings, + }, + originalSettings: { + ui: { customThemes: {} }, + mcpServers: {}, + agents: { overrides: {} }, + ...userSettings, + }, + path: '/user/settings.json', + }, + { + settings: { + ui: { customThemes: {} }, + mcpServers: {}, + agents: { overrides: {} }, + ...workspaceSettings, + }, + originalSettings: { + ui: { customThemes: {} }, + mcpServers: {}, + agents: { overrides: {} }, + ...workspaceSettings, + }, + path: '/workspace/settings.json', + }, + true, + [], + ); + + // Mock setValue + settings.setValue = vi.fn(); + + return settings; +}; + +const createMockAgentDefinition = ( + overrides: Partial = {}, +): AgentDefinition => + ({ + name: 'test-agent', + displayName: 'Test Agent', + description: 'A test agent for testing', + kind: 'local', + modelConfig: { + model: 'inherit', + generateContentConfig: { + temperature: 1.0, + }, + }, + runConfig: { + maxTimeMinutes: 5, + maxTurns: 10, + }, + experimental: false, + ...overrides, + }) as AgentDefinition; + +describe('AgentConfigDialog', () => { + let mockOnClose: ReturnType; + let mockOnSave: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockOnClose = vi.fn(); + mockOnSave = vi.fn(); + }); + + const renderDialog = async ( + settings: LoadedSettings, + definition: AgentDefinition = createMockAgentDefinition(), + ) => { + const result = await renderWithProviders( + , + { settings, uiState: { mainAreaWidth: 100 } }, + ); + return result; + }; + + describe('rendering', () => { + it('should render the dialog with title', async () => { + const settings = createMockSettings(); + const { lastFrame, unmount } = await renderDialog(settings); + expect(lastFrame()).toContain('Configure: Test Agent'); + unmount(); + }); + + it('should render all configuration fields', async () => { + const settings = createMockSettings(); + const { lastFrame, unmount } = await renderDialog(settings); + const frame = lastFrame(); + + expect(frame).toContain('Enabled'); + expect(frame).toContain('Model'); + expect(frame).toContain('Temperature'); + expect(frame).toContain('Top P'); + expect(frame).toContain('Top K'); + expect(frame).toContain('Max Output Tokens'); + expect(frame).toContain('Max Time (minutes)'); + expect(frame).toContain('Max Turns'); + unmount(); + }); + + it('should render scope selector', async () => { + const settings = createMockSettings(); + const { lastFrame, unmount } = await renderDialog(settings); + + expect(lastFrame()).toContain('Apply To'); + expect(lastFrame()).toContain('User Settings'); + expect(lastFrame()).toContain('Workspace Settings'); + unmount(); + }); + + it('should render help text', async () => { + const settings = createMockSettings(); + const { lastFrame, unmount } = await renderDialog(settings); + + expect(lastFrame()).toContain('Use Enter to select'); + expect(lastFrame()).toContain('Tab to change focus'); + expect(lastFrame()).toContain('Esc to close'); + unmount(); + }); + }); + + describe('keyboard navigation', () => { + it('should close dialog on Escape', async () => { + const settings = createMockSettings(); + const { stdin, waitUntilReady, unmount } = await renderDialog(settings); + + await act(async () => { + stdin.write(TerminalKeys.ESCAPE); + }); + // Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act + await act(async () => { + await waitUntilReady(); + }); + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalled(); + }); + unmount(); + }); + + it('should navigate down with arrow key', async () => { + const settings = createMockSettings(); + const { lastFrame, stdin, waitUntilReady, unmount } = + await renderDialog(settings); + + // Initially first item (Enabled) should be active + expect(lastFrame()).toContain('●'); + + // Press down arrow + await act(async () => { + stdin.write(TerminalKeys.DOWN_ARROW); + }); + await waitUntilReady(); + + await waitFor(() => { + // Model field should now be highlighted + expect(lastFrame()).toContain('Model'); + }); + unmount(); + }); + + it('should switch focus with Tab', async () => { + const settings = createMockSettings(); + const { lastFrame, stdin, waitUntilReady, unmount } = + await renderDialog(settings); + + // Initially settings section is focused + expect(lastFrame()).toContain('> Configure: Test Agent'); + + // Press Tab to switch to scope selector + await act(async () => { + stdin.write(TerminalKeys.TAB); + }); + await waitUntilReady(); + + await waitFor(() => { + expect(lastFrame()).toContain('> Apply To'); + }); + unmount(); + }); + }); + + describe('boolean toggle', () => { + it('should toggle enabled field on Enter', async () => { + const settings = createMockSettings(); + const { stdin, waitUntilReady, unmount } = await renderDialog(settings); + + // Press Enter to toggle the first field (Enabled) + await act(async () => { + stdin.write(TerminalKeys.ENTER); + }); + await waitUntilReady(); + + await waitFor(() => { + expect(settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'agents.overrides.test-agent.enabled', + false, // Toggles from true (default) to false + ); + expect(mockOnSave).toHaveBeenCalled(); + }); + unmount(); + }); + }); + + describe('default values', () => { + it('should show values from agent definition as defaults', async () => { + const definition = createMockAgentDefinition({ + modelConfig: { + model: 'gemini-2.0-flash', + generateContentConfig: { + temperature: 0.7, + }, + }, + runConfig: { + maxTimeMinutes: 10, + maxTurns: 20, + }, + }); + const settings = createMockSettings(); + const { lastFrame, unmount } = await renderDialog(settings, definition); + const frame = lastFrame(); + + expect(frame).toContain('gemini-2.0-flash'); + expect(frame).toContain('0.7'); + expect(frame).toContain('10'); + expect(frame).toContain('20'); + unmount(); + }); + + it('should show experimental agents as disabled by default', async () => { + const definition = createMockAgentDefinition({ + experimental: true, + }); + const settings = createMockSettings(); + const { lastFrame, unmount } = await renderDialog(settings, definition); + + // Experimental agents default to disabled + expect(lastFrame()).toContain('false'); + unmount(); + }); + }); + + describe('existing overrides', () => { + it('should show existing override values with * indicator', async () => { + const settings = createMockSettings({ + agents: { + overrides: { + 'test-agent': { + enabled: false, + modelConfig: { + model: 'custom-model', + }, + }, + }, + }, + }); + const { lastFrame, unmount } = await renderDialog(settings); + const frame = lastFrame(); + + // Should show the overridden values + expect(frame).toContain('custom-model'); + expect(frame).toContain('false'); + unmount(); + }); + it('should respond to availableTerminalHeight and truncate list', async () => { + const settings = createMockSettings(); + // Agent config has about 6 base items + 2 per tool + // Render with very small height (20) + const { lastFrame, unmount } = await renderWithProviders( + , + { settings, uiState: { mainAreaWidth: 100 } }, + ); + await waitFor(() => + expect(lastFrame()).toContain('Configure: Test Agent'), + ); + + const frame = lastFrame(); + // At height 20, it should be heavily truncated and show '▼' + expect(frame).toContain('▼'); + unmount(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/AgentConfigDialog.tsx b/packages/cli/src/ui/components/AgentConfigDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6b24d4d5eebc8635a6edbd3a68f78a0da8a1027a --- /dev/null +++ b/packages/cli/src/ui/components/AgentConfigDialog.tsx @@ -0,0 +1,430 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { + SettingScope, + type LoadableSettingScope, + type LoadedSettings, +} from '../../config/settings.js'; +import type { AgentDefinition, AgentOverride } from '@google/gemini-cli-core'; +import { getCachedStringWidth } from '../utils/textUtils.js'; +import { + BaseSettingsDialog, + type SettingsDialogItem, +} from './shared/BaseSettingsDialog.js'; +import { getNestedValue, isRecord } from '../../utils/settingsUtils.js'; + +/** + * Configuration field definition for agent settings + */ +interface AgentConfigField { + key: string; + label: string; + description: string; + type: 'boolean' | 'number' | 'string'; + path: string[]; // Path within AgentOverride, e.g., ['modelConfig', 'generateContentConfig', 'temperature'] + defaultValue: boolean | number | string | undefined; +} + +/** + * Agent configuration fields + */ +const AGENT_CONFIG_FIELDS: AgentConfigField[] = [ + { + key: 'enabled', + label: 'Enabled', + description: 'Enable or disable this agent', + type: 'boolean', + path: ['enabled'], + defaultValue: true, + }, + { + key: 'model', + label: 'Model', + description: "Model to use (e.g., 'gemini-2.0-flash' or 'inherit')", + type: 'string', + path: ['modelConfig', 'model'], + defaultValue: 'inherit', + }, + { + key: 'temperature', + label: 'Temperature', + description: 'Sampling temperature (0.0 to 2.0)', + type: 'number', + path: ['modelConfig', 'generateContentConfig', 'temperature'], + defaultValue: undefined, + }, + { + key: 'topP', + label: 'Top P', + description: 'Nucleus sampling parameter (0.0 to 1.0)', + type: 'number', + path: ['modelConfig', 'generateContentConfig', 'topP'], + defaultValue: undefined, + }, + { + key: 'topK', + label: 'Top K', + description: 'Top-K sampling parameter', + type: 'number', + path: ['modelConfig', 'generateContentConfig', 'topK'], + defaultValue: undefined, + }, + { + key: 'maxOutputTokens', + label: 'Max Output Tokens', + description: 'Maximum number of tokens to generate', + type: 'number', + path: ['modelConfig', 'generateContentConfig', 'maxOutputTokens'], + defaultValue: undefined, + }, + { + key: 'maxTimeMinutes', + label: 'Max Time (minutes)', + description: 'Maximum execution time in minutes', + type: 'number', + path: ['runConfig', 'maxTimeMinutes'], + defaultValue: undefined, + }, + { + key: 'maxTurns', + label: 'Max Turns', + description: 'Maximum number of conversational turns', + type: 'number', + path: ['runConfig', 'maxTurns'], + defaultValue: undefined, + }, +]; + +interface AgentConfigDialogProps { + agentName: string; + displayName: string; + definition: AgentDefinition; + settings: LoadedSettings; + onClose: () => void; + onSave?: () => void; + /** Available terminal height for dynamic windowing */ + availableTerminalHeight?: number; +} + +/** + * Set a nested value in an object using a path array, creating intermediate objects as needed + */ +function setNestedValue(obj: unknown, path: string[], value: unknown): unknown { + if (!isRecord(obj)) return obj; + + const result = { ...obj }; + let current = result; + + for (let i = 0; i < path.length - 1; i++) { + const key = path[i]; + if (current[key] === undefined || current[key] === null) { + current[key] = {}; + } else if (isRecord(current[key])) { + current[key] = { ...(current[key] as object) }; + } + + const next = current[key]; + if (isRecord(next)) { + current = next; + } else { + // Cannot traverse further through non-objects + return result; + } + } + + const finalKey = path[path.length - 1]; + if (value === undefined) { + delete current[finalKey]; + } else { + current[finalKey] = value; + } + + return result; +} + +/** + * Get the effective default value for a field from the agent definition + */ +function getFieldDefaultFromDefinition( + field: AgentConfigField, + definition: AgentDefinition, +): unknown { + if (definition.kind !== 'local') return field.defaultValue; + + if (field.key === 'enabled') { + return !definition.experimental; // Experimental agents default to disabled + } + if (field.key === 'model') { + return definition.modelConfig?.model ?? 'inherit'; + } + if (field.key === 'temperature') { + return definition.modelConfig?.generateContentConfig?.temperature; + } + if (field.key === 'topP') { + return definition.modelConfig?.generateContentConfig?.topP; + } + if (field.key === 'topK') { + return definition.modelConfig?.generateContentConfig?.topK; + } + if (field.key === 'maxOutputTokens') { + return definition.modelConfig?.generateContentConfig?.maxOutputTokens; + } + if (field.key === 'maxTimeMinutes') { + return definition.runConfig?.maxTimeMinutes; + } + if (field.key === 'maxTurns') { + return definition.runConfig?.maxTurns; + } + + return field.defaultValue; +} + +export function AgentConfigDialog({ + agentName, + displayName, + definition, + settings, + onClose, + onSave, + availableTerminalHeight, +}: AgentConfigDialogProps): React.JSX.Element { + // Scope selector state (User by default) + const [selectedScope, setSelectedScope] = useState( + SettingScope.User, + ); + + // Pending override state for the selected scope + const [pendingOverride, setPendingOverride] = useState(() => { + const scopeSettings = settings.forScope(selectedScope).settings; + const existingOverride = scopeSettings.agents?.overrides?.[agentName]; + return existingOverride ? structuredClone(existingOverride) : {}; + }); + + // Track which fields have been modified + const [modifiedFields, setModifiedFields] = useState>(new Set()); + + // Update pending override when scope changes + useEffect(() => { + const scopeSettings = settings.forScope(selectedScope).settings; + const existingOverride = scopeSettings.agents?.overrides?.[agentName]; + setPendingOverride( + existingOverride ? structuredClone(existingOverride) : {}, + ); + setModifiedFields(new Set()); + }, [selectedScope, settings, agentName]); + + /** + * Save a specific field value to settings + */ + const saveFieldValue = useCallback( + (fieldKey: string, path: string[], value: unknown) => { + // Guard against prototype pollution + if (['__proto__', 'constructor', 'prototype'].includes(agentName)) { + return; + } + // Build the full settings path for agent override + // e.g., agents.overrides..modelConfig.generateContentConfig.temperature + const settingsPath = ['agents', 'overrides', agentName, ...path].join( + '.', + ); + settings.setValue(selectedScope, settingsPath, value); + onSave?.(); + }, + [settings, selectedScope, agentName, onSave], + ); + + // Calculate max label width + const maxLabelWidth = useMemo(() => { + let max = 0; + for (const field of AGENT_CONFIG_FIELDS) { + const lWidth = getCachedStringWidth(field.label); + const dWidth = getCachedStringWidth(field.description); + max = Math.max(max, lWidth, dWidth); + } + return max; + }, []); + + // Generate items for BaseSettingsDialog + const items: SettingsDialogItem[] = useMemo( + () => + AGENT_CONFIG_FIELDS.map((field) => { + const currentValue = getNestedValue(pendingOverride, field.path); + const defaultValue = getFieldDefaultFromDefinition(field, definition); + const effectiveValue = + currentValue !== undefined ? currentValue : defaultValue; + + let displayValue: string; + if (field.type === 'boolean') { + displayValue = effectiveValue ? 'true' : 'false'; + } else if (effectiveValue !== undefined && effectiveValue !== null) { + displayValue = String(effectiveValue); + } else { + displayValue = '(default)'; + } + + // Add * if modified + const isModified = + modifiedFields.has(field.key) || currentValue !== undefined; + if (isModified && currentValue !== undefined) { + displayValue += '*'; + } + + // Get raw value for edit mode + const rawValue = + currentValue !== undefined ? currentValue : effectiveValue; + + return { + key: field.key, + label: field.label, + description: field.description, + type: field.type, + displayValue, + isGreyedOut: currentValue === undefined, + scopeMessage: undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + rawValue: rawValue as string | number | boolean | undefined, + }; + }), + [pendingOverride, definition, modifiedFields], + ); + + const maxItemsToShow = 8; + + // Handle scope changes + const handleScopeChange = useCallback((scope: LoadableSettingScope) => { + setSelectedScope(scope); + }, []); + + // Handle toggle for boolean fields + const handleItemToggle = useCallback( + (key: string, _item: SettingsDialogItem) => { + const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key); + if (!field || field.type !== 'boolean') return; + + const currentValue = getNestedValue(pendingOverride, field.path); + const defaultValue = getFieldDefaultFromDefinition(field, definition); + const effectiveValue = + currentValue !== undefined ? currentValue : defaultValue; + const newValue = !effectiveValue; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const newOverride = setNestedValue( + pendingOverride, + field.path, + newValue, + ) as AgentOverride; + setPendingOverride(newOverride); + setModifiedFields((prev) => new Set(prev).add(key)); + + // Save the field value to settings + saveFieldValue(field.key, field.path, newValue); + }, + [pendingOverride, definition, saveFieldValue], + ); + + // Handle edit commit for string/number fields + const handleEditCommit = useCallback( + (key: string, newValue: string, _item: SettingsDialogItem) => { + const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key); + if (!field) return; + + let parsed: string | number | undefined; + if (field.type === 'number') { + if (newValue.trim() === '') { + // Empty means clear the override + parsed = undefined; + } else { + const numParsed = Number(newValue.trim()); + if (Number.isNaN(numParsed)) { + // Invalid number; don't save + return; + } + parsed = numParsed; + } + } else { + // For strings, empty means clear the override + parsed = newValue.trim() === '' ? undefined : newValue; + } + + // Update pending override locally + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const newOverride = setNestedValue( + pendingOverride, + field.path, + parsed, + ) as AgentOverride; + + setPendingOverride(newOverride); + setModifiedFields((prev) => new Set(prev).add(key)); + + // Save the field value to settings + saveFieldValue(field.key, field.path, parsed); + }, + [pendingOverride, saveFieldValue], + ); + + // Handle clear/reset - reset to default value (removes override) + const handleItemClear = useCallback( + (key: string, _item: SettingsDialogItem) => { + const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key); + if (!field) return; + + // Remove the override (set to undefined) + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const newOverride = setNestedValue( + pendingOverride, + field.path, + undefined, + ) as AgentOverride; + + setPendingOverride(newOverride); + setModifiedFields((prev) => { + const updated = new Set(prev); + updated.delete(key); + return updated; + }); + + // Save as undefined to remove the override + saveFieldValue(field.key, field.path, undefined); + }, + [pendingOverride, saveFieldValue], + ); + + return ( + 0 + ? { + content: ( + + Changes saved automatically. + + ), + height: 1, + } + : undefined + } + /> + ); +} diff --git a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..571e0d36d367f379213039b3583dc20da31790ce --- /dev/null +++ b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx @@ -0,0 +1,231 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + renderWithProviders, + persistentStateMock, +} from '../../test-utils/render.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AlternateBufferQuittingDisplay } from './AlternateBufferQuittingDisplay.js'; +import type { HistoryItem, HistoryItemWithoutId } from '../types.js'; +import { Text } from 'ink'; +import { CoreToolCallStatus } from '@google/gemini-cli-core'; + +vi.mock('../utils/terminalSetup.js', () => ({ + getTerminalProgram: () => null, +})); + +vi.mock('../contexts/AppContext.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useAppContext: () => ({ + version: '0.10.0', + }), + }; +}); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getMCPServerStatus: vi.fn(), + }; +}); + +vi.mock('../GeminiRespondingSpinner.js', () => ({ + GeminiRespondingSpinner: () => Spinner, +})); + +const mockHistory: HistoryItem[] = [ + { + id: 1, + type: 'tool_group', + tools: [ + { + callId: 'call1', + name: 'tool1', + description: 'Description for tool 1', + status: CoreToolCallStatus.Success, + resultDisplay: undefined, + confirmationDetails: undefined, + }, + ], + }, + { + id: 2, + type: 'tool_group', + tools: [ + { + callId: 'call2', + name: 'tool2', + description: 'Description for tool 2', + status: CoreToolCallStatus.Success, + resultDisplay: undefined, + confirmationDetails: undefined, + }, + ], + }, +]; + +const mockPendingHistoryItems: HistoryItemWithoutId[] = [ + { + type: 'tool_group', + tools: [ + { + callId: 'call3', + name: 'tool3', + description: 'Description for tool 3', + status: CoreToolCallStatus.Scheduled, + resultDisplay: undefined, + confirmationDetails: undefined, + }, + ], + }, +]; + +describe('AlternateBufferQuittingDisplay', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + const baseUIState = { + terminalWidth: 80, + mainAreaWidth: 80, + slashCommands: [], + activePtyId: undefined, + embeddedShellFocused: false, + renderMarkdown: false, + bannerData: { + defaultText: '', + warningText: '', + }, + }; + + it('renders with active and pending tool messages', async () => { + persistentStateMock.setData({ tipsShown: 0 }); + const { lastFrame, unmount } = await renderWithProviders( + , + { + uiState: { + ...baseUIState, + history: mockHistory, + pendingHistoryItems: mockPendingHistoryItems, + }, + }, + ); + expect(lastFrame()).toMatchSnapshot('with_history_and_pending'); + unmount(); + }); + + it('renders with empty history and no pending items', async () => { + persistentStateMock.setData({ tipsShown: 0 }); + const { lastFrame, unmount } = await renderWithProviders( + , + { + uiState: { + ...baseUIState, + history: [], + pendingHistoryItems: [], + }, + }, + ); + expect(lastFrame()).toMatchSnapshot('empty'); + unmount(); + }); + + it('renders with history but no pending items', async () => { + persistentStateMock.setData({ tipsShown: 0 }); + const { lastFrame, unmount } = await renderWithProviders( + , + { + uiState: { + ...baseUIState, + history: mockHistory, + pendingHistoryItems: [], + }, + }, + ); + expect(lastFrame()).toMatchSnapshot('with_history_no_pending'); + unmount(); + }); + + it('renders with pending items but no history', async () => { + persistentStateMock.setData({ tipsShown: 0 }); + const { lastFrame, unmount } = await renderWithProviders( + , + { + uiState: { + ...baseUIState, + history: [], + pendingHistoryItems: mockPendingHistoryItems, + }, + }, + ); + expect(lastFrame()).toMatchSnapshot('with_pending_no_history'); + unmount(); + }); + + it('renders with a tool awaiting confirmation', async () => { + persistentStateMock.setData({ tipsShown: 0 }); + const pendingHistoryItems: HistoryItemWithoutId[] = [ + { + type: 'tool_group', + tools: [ + { + callId: 'call4', + name: 'confirming_tool', + description: 'Confirming tool description', + status: CoreToolCallStatus.AwaitingApproval, + resultDisplay: undefined, + confirmationDetails: { + type: 'info', + title: 'Confirm Tool', + prompt: 'Confirm this action?', + }, + }, + ], + }, + ]; + const { lastFrame, unmount } = await renderWithProviders( + , + { + uiState: { + ...baseUIState, + history: [], + pendingHistoryItems, + }, + }, + ); + const output = lastFrame(); + expect(output).toContain('Action Required (was prompted):'); + expect(output).toContain('confirming_tool'); + expect(output).toContain('Confirming tool description'); + expect(output).toMatchSnapshot('with_confirming_tool'); + unmount(); + }); + + it('renders with user and gemini messages', async () => { + persistentStateMock.setData({ tipsShown: 0 }); + const history: HistoryItem[] = [ + { id: 1, type: 'user', text: 'Hello Gemini' }, + { id: 2, type: 'gemini', text: 'Hello User!' }, + ]; + const { lastFrame, unmount } = await renderWithProviders( + , + { + uiState: { + ...baseUIState, + history, + pendingHistoryItems: [], + }, + }, + ); + expect(lastFrame()).toMatchSnapshot('with_user_gemini_messages'); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7b81875c7b95dad5f7a11da113bd55acf6c35e9f --- /dev/null +++ b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.tsx @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { useUIState } from '../contexts/UIStateContext.js'; +import { AppHeader } from './AppHeader.js'; +import { HistoryItemDisplay } from './HistoryItemDisplay.js'; +import { QuittingDisplay } from './QuittingDisplay.js'; +import { useAppContext } from '../contexts/AppContext.js'; +import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js'; +import { useConfirmingTool } from '../hooks/useConfirmingTool.js'; +import { ToolStatusIndicator, ToolInfo } from './messages/ToolShared.js'; +import { theme } from '../semantic-colors.js'; + +export const AlternateBufferQuittingDisplay = () => { + const { version } = useAppContext(); + const uiState = useUIState(); + + const confirmingTool = useConfirmingTool(); + const showPromptedTool = confirmingTool !== null; + + // We render the entire chat history and header here to ensure that the + // conversation history is visible to the user after the app quits and the + // user exits alternate buffer mode. + // Our version of Ink is clever and will render a final frame outside of + // the alternate buffer on app exit. + return ( + + + {uiState.history.map((h) => ( + + ))} + {uiState.pendingHistoryItems.map((item, i) => ( + + ))} + {showPromptedTool && ( + + + Action Required (was prompted): + + + + + + + )} + + + ); +}; diff --git a/packages/cli/src/ui/components/AnsiOutput.test.tsx b/packages/cli/src/ui/components/AnsiOutput.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..04d6ccb0d9098a2859b48f2da7bb5a1bfe39ee1d --- /dev/null +++ b/packages/cli/src/ui/components/AnsiOutput.test.tsx @@ -0,0 +1,186 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { AnsiOutputText } from './AnsiOutput.js'; +import type { AnsiOutput, AnsiToken } from '@google/gemini-cli-core'; + +// Helper to create a valid AnsiToken with default values +const createAnsiToken = (overrides: Partial): AnsiToken => ({ + text: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + isUninitialized: false, + fg: '#ffffff', + bg: '#000000', + ...overrides, +}); + +describe('', () => { + it('renders a simple AnsiOutput object correctly', async () => { + const data: AnsiOutput = [ + [ + createAnsiToken({ text: 'Hello, ' }), + createAnsiToken({ text: 'world!' }), + ], + ]; + const { lastFrame, unmount } = await render( + , + ); + expect(lastFrame().trim()).toBe('Hello, world!'); + unmount(); + }); + + // Note: ink-testing-library doesn't render styles, so we can only check the text. + // We are testing that it renders without crashing. + it.each([ + { style: { bold: true }, text: 'Bold' }, + { style: { italic: true }, text: 'Italic' }, + { style: { underline: true }, text: 'Underline' }, + { style: { dim: true }, text: 'Dim' }, + { style: { inverse: true }, text: 'Inverse' }, + ])('correctly applies style $text', async ({ style, text }) => { + const data: AnsiOutput = [[createAnsiToken({ text, ...style })]]; + const { lastFrame, unmount } = await render( + , + ); + expect(lastFrame().trim()).toBe(text); + unmount(); + }); + + it.each([ + { color: { fg: '#ff0000' }, text: 'Red FG' }, + { color: { bg: '#0000ff' }, text: 'Blue BG' }, + { color: { fg: '#00ff00', bg: '#ff00ff' }, text: 'Green FG Magenta BG' }, + ])('correctly applies color $text', async ({ color, text }) => { + const data: AnsiOutput = [[createAnsiToken({ text, ...color })]]; + const { lastFrame, unmount } = await render( + , + ); + expect(lastFrame().trim()).toBe(text); + unmount(); + }); + + it('handles empty lines and empty tokens', async () => { + const data: AnsiOutput = [ + [createAnsiToken({ text: 'First line' })], + [], + [createAnsiToken({ text: 'Third line' })], + [createAnsiToken({ text: '' })], + ]; + const { lastFrame, unmount } = await render( + , + ); + const output = lastFrame(); + expect(output).toBeDefined(); + const lines = output.split('\n'); + expect(lines[0].trim()).toBe('First line'); + expect(lines[1].trim()).toBe(''); + expect(lines[2].trim()).toBe('Third line'); + unmount(); + }); + + it('respects the availableTerminalHeight prop and slices the lines correctly', async () => { + const data: AnsiOutput = [ + [createAnsiToken({ text: 'Line 1' })], + [createAnsiToken({ text: 'Line 2' })], + [createAnsiToken({ text: 'Line 3' })], + [createAnsiToken({ text: 'Line 4' })], + ]; + const { lastFrame, unmount } = await render( + , + ); + const output = lastFrame(); + expect(output).not.toContain('Line 1'); + expect(output).not.toContain('Line 2'); + expect(output).toContain('Line 3'); + expect(output).toContain('Line 4'); + unmount(); + }); + + it('respects the maxLines prop and slices the lines correctly', async () => { + const data: AnsiOutput = [ + [createAnsiToken({ text: 'Line 1' })], + [createAnsiToken({ text: 'Line 2' })], + [createAnsiToken({ text: 'Line 3' })], + [createAnsiToken({ text: 'Line 4' })], + ]; + const { lastFrame, unmount } = await render( + , + ); + const output = lastFrame(); + expect(output).not.toContain('Line 1'); + expect(output).not.toContain('Line 2'); + expect(output).toContain('Line 3'); + expect(output).toContain('Line 4'); + unmount(); + }); + + it('prioritizes maxLines over availableTerminalHeight if maxLines is smaller', async () => { + const data: AnsiOutput = [ + [createAnsiToken({ text: 'Line 1' })], + [createAnsiToken({ text: 'Line 2' })], + [createAnsiToken({ text: 'Line 3' })], + [createAnsiToken({ text: 'Line 4' })], + ]; + // availableTerminalHeight=3, maxLines=2 => show 2 lines + const { lastFrame, unmount } = await render( + , + ); + const output = lastFrame(); + expect(output).not.toContain('Line 2'); + expect(output).toContain('Line 3'); + expect(output).toContain('Line 4'); + unmount(); + }); + + it('renders a large AnsiOutput object without crashing', async () => { + const largeData: AnsiOutput = []; + for (let i = 0; i < 1000; i++) { + largeData.push([createAnsiToken({ text: `Line ${i}` })]); + } + const { lastFrame, unmount } = await render( + , + ); + // We are just checking that it renders something without crashing. + expect(lastFrame()).toBeDefined(); + unmount(); + }); + + describe('robustness', () => { + it('does NOT crash when data is undefined', async () => { + const { lastFrame, unmount } = await render( + , + ); + expect(lastFrame({ allowEmpty: true }).trim()).toBe(''); + unmount(); + }); + + it('does NOT crash when data is an object but not an array', async () => { + const { lastFrame, unmount } = await render( + , + ); + expect(lastFrame({ allowEmpty: true }).trim()).toBe(''); + unmount(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/AnsiOutput.tsx b/packages/cli/src/ui/components/AnsiOutput.tsx new file mode 100644 index 0000000000000000000000000000000000000000..617740d4add61751a0fe09c990bcfdd1f7d4ec19 --- /dev/null +++ b/packages/cli/src/ui/components/AnsiOutput.tsx @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import type { AnsiLine, AnsiOutput, AnsiToken } from '@google/gemini-cli-core'; + +const DEFAULT_HEIGHT = 24; + +interface AnsiOutputProps { + data: AnsiOutput; + availableTerminalHeight?: number; + width: number; + maxLines?: number; + disableTruncation?: boolean; +} + +export const AnsiOutputText: React.FC = ({ + data, + availableTerminalHeight, + width, + maxLines, + disableTruncation, +}) => { + const availableHeightLimit = + availableTerminalHeight && availableTerminalHeight > 0 + ? availableTerminalHeight + : undefined; + + const numLinesRetained = + availableHeightLimit !== undefined && maxLines !== undefined + ? Math.min(availableHeightLimit, maxLines) + : (availableHeightLimit ?? maxLines ?? DEFAULT_HEIGHT); + + const lastLines = Array.isArray(data) + ? disableTruncation + ? data + : numLinesRetained === 0 + ? [] + : data.slice(-numLinesRetained) + : []; + return ( + + {(lastLines as AnsiLine[]).map((line: AnsiLine, lineIndex: number) => ( + + + + ))} + + ); +}; + +export const AnsiLineText: React.FC<{ line: AnsiLine }> = ({ line }) => ( + + {line.length > 0 + ? line.map((token: AnsiToken, tokenIndex: number) => ( + + {token.text} + + )) + : null} + +); diff --git a/packages/cli/src/ui/components/AppHeader.test.tsx b/packages/cli/src/ui/components/AppHeader.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2fab08c8e64a62ded632b51fa8b0aa06e424d049 --- /dev/null +++ b/packages/cli/src/ui/components/AppHeader.test.tsx @@ -0,0 +1,292 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + renderWithProviders, + persistentStateMock, +} from '../../test-utils/render.js'; +import type { LoadedSettings } from '../../config/settings.js'; +import { AppHeader } from './AppHeader.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { makeFakeConfig } from '@google/gemini-cli-core'; +import crypto from 'node:crypto'; +import { _clearSessionBannersForTest } from '../hooks/useBanner.js'; + +vi.mock('../utils/terminalSetup.js', () => ({ + getTerminalProgram: () => null, +})); + +describe('', () => { + beforeEach(() => { + _clearSessionBannersForTest(); + }); + + it('should render the banner with default text', async () => { + const uiState = { + history: [], + bannerData: { + defaultText: 'This is the default banner', + warningText: '', + }, + bannerVisible: true, + }; + + const { lastFrame, unmount } = await renderWithProviders( + , + { + uiState, + }, + ); + + expect(lastFrame()).toContain('This is the default banner'); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('should render the banner with warning text', async () => { + const uiState = { + history: [], + bannerData: { + defaultText: 'This is the default banner', + warningText: 'There are capacity issues', + }, + bannerVisible: true, + }; + + const { lastFrame, unmount } = await renderWithProviders( + , + { + uiState, + }, + ); + + expect(lastFrame()).toContain('There are capacity issues'); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('should not render the banner when no flags are set', async () => { + const uiState = { + history: [], + bannerData: { + defaultText: '', + warningText: '', + }, + }; + + const { lastFrame, unmount } = await renderWithProviders( + , + { + uiState, + }, + ); + + expect(lastFrame()).not.toContain('Banner'); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('should not render the default banner if shown count is 5 or more', async () => { + const uiState = { + history: [], + bannerData: { + defaultText: 'This is the default banner', + warningText: '', + }, + }; + + persistentStateMock.setData({ + defaultBannerShownCount: { + [crypto + .createHash('sha256') + .update(uiState.bannerData.defaultText) + .digest('hex')]: 5, + }, + }); + + const { lastFrame, unmount } = await renderWithProviders( + , + { + uiState, + }, + ); + + expect(lastFrame()).not.toContain('This is the default banner'); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('should increment the version count when default banner is displayed', async () => { + const uiState = { + history: [], + bannerData: { + defaultText: 'This is the default banner', + warningText: '', + }, + }; + + // Set tipsShown to 10 or more to prevent Tips from incrementing its count + // and interfering with the expected persistentState.set call. + persistentStateMock.setData({ tipsShown: 10 }); + + const { unmount } = await renderWithProviders( + , + { + uiState, + }, + ); + + expect(persistentStateMock.set).toHaveBeenCalledWith( + 'defaultBannerShownCount', + { + [crypto + .createHash('sha256') + .update(uiState.bannerData.defaultText) + .digest('hex')]: 1, + }, + ); + unmount(); + }); + + it('should render banner text with unescaped newlines', async () => { + const uiState = { + history: [], + bannerData: { + defaultText: 'First line\\nSecond line', + warningText: '', + }, + bannerVisible: true, + }; + + const { lastFrame, unmount } = await renderWithProviders( + , + { + uiState, + }, + ); + + expect(lastFrame()).not.toContain('First line\\nSecond line'); + unmount(); + }); + + it('should render Tips when tipsShown is less than 10', async () => { + const uiState = { + history: [], + bannerData: { + defaultText: 'First line\\nSecond line', + warningText: '', + }, + bannerVisible: true, + }; + + persistentStateMock.setData({ tipsShown: 5 }); + + const { lastFrame, unmount } = await renderWithProviders( + , + { + uiState, + }, + ); + + expect(lastFrame()).toContain('Tips'); + expect(persistentStateMock.set).toHaveBeenCalledWith('tipsShown', 6); + unmount(); + }); + + it('should NOT render Tips when tipsShown is 10 or more', async () => { + const uiState = { + bannerData: { + defaultText: '', + warningText: '', + }, + }; + + persistentStateMock.setData({ tipsShown: 10 }); + + const { lastFrame, unmount } = await renderWithProviders( + , + { + uiState, + }, + ); + + expect(lastFrame()).not.toContain('Tips'); + unmount(); + }); + + it('should show tips until they have been shown 10 times (persistence flow)', async () => { + persistentStateMock.setData({ tipsShown: 9 }); + + const uiState = { + history: [], + bannerData: { + defaultText: 'First line\\nSecond line', + warningText: '', + }, + bannerVisible: true, + }; + + // First session + const session1 = await renderWithProviders(, { + uiState, + }); + + expect(session1.lastFrame()).toContain('Tips'); + expect(persistentStateMock.get('tipsShown')).toBe(10); + session1.unmount(); + + // Second session - state is persisted in the fake + const session2 = await renderWithProviders( + , + {}, + ); + + expect(session2.lastFrame()).not.toContain('Tips'); + session2.unmount(); + }); + + it('should render the full logo when logged out', async () => { + const mockConfig = makeFakeConfig(); + vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({ + authType: undefined, + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + , + { + config: mockConfig, + uiState: { + terminalWidth: 120, + }, + }, + ); + await waitUntilReady(); + + // Check for block characters from the logo + expect(lastFrame()).toContain('▗█▀▀▜▙'); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('should NOT render Tips when ui.hideTips is true', async () => { + const mockConfig = makeFakeConfig(); + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + , + { + config: mockConfig, + settings: { + merged: { + ui: { hideTips: true }, + }, + } as unknown as LoadedSettings, + }, + ); + await waitUntilReady(); + + expect(lastFrame()).not.toContain('Tips'); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1b3d9b2cfa4fa4e9a757b813c332a502927e2b5c --- /dev/null +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -0,0 +1,173 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { UserIdentity } from './UserIdentity.js'; +import { Tips } from './Tips.js'; +import { useSettings } from '../contexts/SettingsContext.js'; +import { useConfig } from '../contexts/ConfigContext.js'; +import { useUIState } from '../contexts/UIStateContext.js'; +import { Banner } from './Banner.js'; +import { useBanner } from '../hooks/useBanner.js'; +import { useTips } from '../hooks/useTips.js'; +import { theme } from '../semantic-colors.js'; +import { ThemedGradient } from './ThemedGradient.js'; +import { CliSpinner } from './CliSpinner.js'; + +import { isAppleTerminal } from '@google/gemini-cli-core'; + +import { longAsciiLogoCompactText } from './AsciiArt.js'; +import { getAsciiArtWidth } from '../utils/textUtils.js'; + +interface AppHeaderProps { + version: string; + showDetails?: boolean; +} + +const DEFAULT_ICON = `▝▜▄ + ▝▜▄ + ▗▟▀ +▝▀ `; + +/** + * The default Apple Terminal.app adds significant line-height padding between + * rows. This breaks Unicode block-drawing characters that rely on vertical + * adjacency (like half-blocks). This version is perfectly symmetric vertically, + * which makes the padding gaps look like an intentional "scanline" design + * rather than a broken image. + */ +const MAC_TERMINAL_ICON = `▝▜▄ + ▝▜▄ + ▗▟▀ +▗▟▀ `; + +/** + * The horizontal padding (in columns) required for metadata (version, identity, etc.) + * when rendered alongside the ASCII logo. + */ +const LOGO_METADATA_PADDING = 20; + +/** + * The terminal width below which we switch to a narrow/column layout to prevent + * UI elements from wrapping or overlapping. + */ +const NARROW_TERMINAL_BREAKPOINT = 60; + +export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => { + const settings = useSettings(); + const config = useConfig(); + const { + terminalWidth, + bannerData, + bannerVisible, + updateInfo, + isConfigInitialized, + isAuthenticating, + } = useUIState(); + + const { bannerText } = useBanner(bannerData); + const { showTips } = useTips(); + + const authType = config.getContentGeneratorConfig()?.authType; + const loggedOut = isConfigInitialized && !isAuthenticating && !authType; + + const showHeader = !( + settings.merged.ui.hideBanner || config.getScreenReader() + ); + + const ICON = isAppleTerminal() ? MAC_TERMINAL_ICON : DEFAULT_ICON; + + let logoTextArt = ''; + if (loggedOut) { + const widthOfLongLogo = + getAsciiArtWidth(longAsciiLogoCompactText) + LOGO_METADATA_PADDING; + + if (terminalWidth >= widthOfLongLogo) { + logoTextArt = longAsciiLogoCompactText.trim(); + } + } + + // If the terminal is too narrow to fit the icon and metadata (especially long nightly versions) + // side-by-side, we switch to column mode to prevent wrapping. + const isNarrow = terminalWidth < NARROW_TERMINAL_BREAKPOINT; + + const renderLogo = () => ( + + + {ICON} + + {logoTextArt && ( + + {logoTextArt} + + )} + + ); + + const renderMetadata = (isBelow = false) => ( + + {/* Line 1: Gemini CLI vVersion [Updating] */} + + + Gemini CLI + + v{version} + {updateInfo?.isUpdating && ( + + + Updating + + + )} + + + {showDetails && ( + <> + {/* Line 2: Blank */} + + + {/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */} + {settings.merged.ui.showUserIdentity !== false && ( + + )} + + )} + + ); + + const useColumnLayout = !!logoTextArt || isNarrow; + + return ( + + {showHeader && ( + + {renderLogo()} + {useColumnLayout ? ( + {renderMetadata(true)} + ) : ( + renderMetadata(false) + )} + + )} + + {bannerVisible && bannerText && ( + + )} + + {!(settings.merged.ui.hideTips || config.getScreenReader()) && + showTips && } + + ); +}; diff --git a/packages/cli/src/ui/components/AppHeaderIcon.test.tsx b/packages/cli/src/ui/components/AppHeaderIcon.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6b6f0e62108c45f3799f901f7179280d1389bec1 --- /dev/null +++ b/packages/cli/src/ui/components/AppHeaderIcon.test.tsx @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { AppHeader } from './AppHeader.js'; + +// We mock the entire module to control the isAppleTerminal export +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isAppleTerminal: vi.fn(), + }; +}); + +import { isAppleTerminal } from '@google/gemini-cli-core'; + +describe('AppHeader Icon Rendering', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('renders the default icon in standard terminals', async () => { + vi.mocked(isAppleTerminal).mockReturnValue(false); + + const result = await renderWithProviders(); + await result.waitUntilReady(); + + await expect(result).toMatchSvgSnapshot(); + }); + + it('renders the symmetric icon in Apple Terminal', async () => { + vi.mocked(isAppleTerminal).mockReturnValue(true); + + const result = await renderWithProviders(); + await result.waitUntilReady(); + + await expect(result).toMatchSvgSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1b2decbe1636640a9458e1632f8959e81986cd27 --- /dev/null +++ b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { ApprovalModeIndicator } from './ApprovalModeIndicator.js'; +import { describe, it, expect } from 'vitest'; +import { ApprovalMode } from '@google/gemini-cli-core'; + +describe('ApprovalModeIndicator', () => { + it('renders correctly for AUTO_EDIT mode', async () => { + const { lastFrame } = await render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly for AUTO_EDIT mode with plan enabled', async () => { + const { lastFrame } = await render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly for PLAN mode', async () => { + const { lastFrame } = await render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly for YOLO mode', async () => { + const { lastFrame } = await render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly for DEFAULT mode', async () => { + const { lastFrame } = await render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly for DEFAULT mode with plan enabled', async () => { + const { lastFrame } = await render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/ApprovalModeIndicator.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7e8f388c82c194ae02186b3189fcb7f9be1cb192 --- /dev/null +++ b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { ApprovalMode } from '@google/gemini-cli-core'; +import { formatCommand } from '../key/keybindingUtils.js'; +import { Command } from '../key/keyBindings.js'; + +interface ApprovalModeIndicatorProps { + approvalMode: ApprovalMode; + allowPlanMode?: boolean; +} + +export const ApprovalModeIndicator: React.FC = ({ + approvalMode, + allowPlanMode, +}) => { + let textColor = ''; + let textContent = ''; + let subText = ''; + + const cycleHint = formatCommand(Command.CYCLE_APPROVAL_MODE); + const yoloHint = formatCommand(Command.TOGGLE_YOLO); + + switch (approvalMode) { + case ApprovalMode.AUTO_EDIT: + textColor = theme.status.warning; + textContent = 'auto-accept edits'; + subText = allowPlanMode + ? `${cycleHint} to plan` + : `${cycleHint} to manual`; + break; + case ApprovalMode.PLAN: + textColor = theme.status.success; + textContent = 'plan'; + subText = `${cycleHint} to manual`; + break; + case ApprovalMode.YOLO: + textColor = theme.status.error; + textContent = 'YOLO'; + subText = yoloHint; + break; + case ApprovalMode.DEFAULT: + default: + textColor = theme.text.accent; + textContent = ''; + subText = `${cycleHint} to accept edits`; + break; + } + + return ( + + + {textContent ? textContent : null} + {subText ? ( + + {textContent ? ' ' : ''} + {subText} + + ) : null} + + + ); +}; diff --git a/packages/cli/src/ui/components/AsciiArt.ts b/packages/cli/src/ui/components/AsciiArt.ts new file mode 100644 index 0000000000000000000000000000000000000000..40f0eb8296535bb64a66adf7e9dcf200a403ddec --- /dev/null +++ b/packages/cli/src/ui/components/AsciiArt.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export const shortAsciiLogo = ` + █████████ ██████████ ██████ ██████ █████ ██████ █████ █████ + ███░░░░░███░░███░░░░░█░░██████ ██████ ░░███ ░░██████ ░░███ ░░███ + ███ ░░░ ░███ █ ░ ░███░█████░███ ░███ ░███░███ ░███ ░███ +░███ ░██████ ░███░░███ ░███ ░███ ░███░░███░███ ░███ +░███ █████ ░███░░█ ░███ ░░░ ░███ ░███ ░███ ░░██████ ░███ +░░███ ░░███ ░███ ░ █ ░███ ░███ ░███ ░███ ░░█████ ░███ + ░░█████████ ██████████ █████ █████ █████ █████ ░░█████ █████ + ░░░░░░░░░ ░░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ +`; + +export const longAsciiLogo = ` + █████████ ██████████ ██████ ██████ █████ ██████ █████ █████ +███░░░░░███░░███░░░░░█░░██████ █████ ░░███░░██████ ░░███ ░░███ +███ ░░░░░░░ ░███ █ ░ ░███░█████░███ ░███ ░███░███ ░███ ░███ +░███ ░██████ ░███░░███ ░███ ░███ ░███░░███░███ ░███ +░███ █████ ░███░░█ ░███ ░░░ ░███ ░███ ░███ ░░██████ ░███ +░░███ ░░███ ░███ ░ █ ░███ ░███ ░███ ░███ ░░█████ ░███ + ░░█████████ ██████████ █████ █████ █████ █████ ░░████ █████ + ░░░░░░░░░ ░░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░ ░░░░░ +`; + +export const tinyAsciiLogo = ` + ███ █████████ +░░░███ ███░░░░░███ + ░░░███ ███ ░░░ + ░░░███░███ + ███░ ░███ █████ + ███░ ░░███ ░░███ + ███░ ░░█████████ +░░░ ░░░░░░░░░ +`; + +export const shortAsciiLogoCompactText = ` +▟▛▀▀█▖▜█▀▀▜▝██▙▗██▛▝█▛▝██▙ ▜█▘▜█▘ +▐█ ▐█▄▌ █▌▜█▘█▌ █▌ █▌▜▙▐█ ▐█ +▝█▖ ▜█▘▐█ ▘▗ █▌ █▌ █▌ █▌ ▜██ ▐█ + ▝▀▀▀▀ ▀▀▀▀▀▝▀▀ ▝▀▀▝▀▀▝▀▀ ▀▀▘▀▀▘ +`; + +export const longAsciiLogoCompactText = ` +▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛ +█▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌ +▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌ + ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀ +`; + +export const tinyAsciiLogoCompactText = ` +▟▛▀▀█▖ +▐█ +▝█▖ ▜█▘ + ▝▀▀▀▀ +`; diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bb76fd3aeb54b7007ec4fb60f014e4dd43484172 --- /dev/null +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -0,0 +1,1651 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { act } from 'react'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { createMockSettings } from '../../test-utils/settings.js'; +import { makeFakeConfig } from '@google/gemini-cli-core'; +import { waitFor } from '../../test-utils/async.js'; +import { AskUserDialog } from './AskUserDialog.js'; +import { QuestionType, type Question } from '@google/gemini-cli-core'; +import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; + +// Helper to write to stdin with proper act() wrapping +const writeKey = (stdin: { write: (data: string) => void }, key: string) => { + act(() => { + stdin.write(key); + }); +}; + +describe('AskUserDialog', () => { + // Ensure keystrokes appear spaced in time to avoid bufferFastReturn + // converting Enter into Shift+Enter during synchronous test execution. + let mockTime: number; + beforeEach(() => { + mockTime = 0; + vi.spyOn(Date, 'now').mockImplementation(() => (mockTime += 50)); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const authQuestion: Question[] = [ + { + question: 'Which authentication method should we use?', + header: 'Auth', + type: QuestionType.CHOICE, + options: [ + { label: 'OAuth 2.0', description: 'Industry standard, supports SSO' }, + { label: 'JWT tokens', description: 'Stateless, good for APIs' }, + ], + multiSelect: false, + }, + ]; + + it('renders question and options', async () => { + const { lastFrame } = await renderWithProviders( + , + { width: 120 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + describe.each([ + { + name: 'Single Select', + questions: authQuestion, + actions: (stdin: { write: (data: string) => void }) => { + writeKey(stdin, '\r'); + }, + expectedSubmit: { '0': 'OAuth 2.0' }, + }, + { + name: 'Multi-select', + questions: [ + { + question: 'Which features?', + header: 'Features', + type: QuestionType.CHOICE, + options: [ + { label: 'TypeScript', description: '' }, + { label: 'ESLint', description: '' }, + ], + multiSelect: true, + }, + ] as Question[], + actions: (stdin: { write: (data: string) => void }) => { + writeKey(stdin, '\r'); // Toggle TS + writeKey(stdin, '\x1b[B'); // Down + writeKey(stdin, '\r'); // Toggle ESLint + writeKey(stdin, '\x1b[B'); // Down to All of the above + writeKey(stdin, '\x1b[B'); // Down to Other + writeKey(stdin, '\x1b[B'); // Down to Done + writeKey(stdin, '\r'); // Done + }, + expectedSubmit: { '0': 'TypeScript, ESLint' }, + }, + { + name: 'All of the above', + questions: [ + { + question: 'Which features?', + header: 'Features', + type: QuestionType.CHOICE, + options: [ + { label: 'TypeScript', description: '' }, + { label: 'ESLint', description: '' }, + ], + multiSelect: true, + }, + ] as Question[], + actions: (stdin: { write: (data: string) => void }) => { + writeKey(stdin, '\x1b[B'); // Down to ESLint + writeKey(stdin, '\x1b[B'); // Down to All of the above + writeKey(stdin, '\r'); // Toggle All of the above + writeKey(stdin, '\x1b[B'); // Down to Other + writeKey(stdin, '\x1b[B'); // Down to Done + writeKey(stdin, '\r'); // Done + }, + expectedSubmit: { '0': 'TypeScript, ESLint' }, + }, + { + name: 'Text Input', + questions: [ + { + question: 'Name?', + header: 'Name', + type: QuestionType.TEXT, + }, + ] as Question[], + actions: (stdin: { write: (data: string) => void }) => { + for (const char of 'test-app') { + writeKey(stdin, char); + } + writeKey(stdin, '\r'); + }, + expectedSubmit: { '0': 'test-app' }, + }, + ])('Submission: $name', ({ name, questions, actions, expectedSubmit }) => { + it(`submits correct values for ${name}`, async () => { + const onSubmit = vi.fn(); + const { stdin } = await renderWithProviders( + , + { width: 120 }, + ); + + actions(stdin); + + await waitFor(async () => { + expect(onSubmit).toHaveBeenCalledWith(expectedSubmit); + }); + }); + }); + + it('verifies "All of the above" visual state with snapshot', async () => { + const questions = [ + { + question: 'Which features?', + header: 'Features', + type: QuestionType.CHOICE, + options: [ + { label: 'TypeScript', description: '' }, + { label: 'ESLint', description: '' }, + ], + multiSelect: true, + }, + ] as Question[]; + + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 120 }, + ); + + // Navigate to "All of the above" and toggle it + writeKey(stdin, '\x1b[B'); // Down to ESLint + writeKey(stdin, '\x1b[B'); // Down to All of the above + writeKey(stdin, '\r'); // Toggle All of the above + + await waitFor(async () => { + await waitUntilReady(); + // Verify visual state (checkmarks on all options) + expect(lastFrame()).toMatchSnapshot(); + }); + }); + + it('handles custom option in single select with inline typing', async () => { + const onSubmit = vi.fn(); + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 120 }, + ); + + // Move down to custom option + writeKey(stdin, '\x1b[B'); + writeKey(stdin, '\x1b[B'); + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('Enter a custom value'); + }); + + // Type directly (inline) + for (const char of 'API Key') { + writeKey(stdin, char); + } + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('API Key'); + }); + + // Press Enter to submit the custom value + writeKey(stdin, '\r'); + + await waitFor(async () => { + expect(onSubmit).toHaveBeenCalledWith({ '0': 'API Key' }); + }); + }); + + it('supports multi-line input for "Other" option in choice questions', async () => { + const authQuestionWithOther: Question[] = [ + { + question: 'Which authentication method?', + header: 'Auth', + type: QuestionType.CHOICE, + options: [{ label: 'OAuth 2.0', description: '' }], + multiSelect: false, + }, + ]; + + const onSubmit = vi.fn(); + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 120 }, + ); + + // Navigate to "Other" option + writeKey(stdin, '\x1b[B'); // Down to "Other" + + // Type first line + for (const char of 'Line 1') { + writeKey(stdin, char); + } + + // Insert newline using \ + Enter (handled by bufferBackslashEnter) + writeKey(stdin, '\\'); + writeKey(stdin, '\r'); + + // Type second line + for (const char of 'Line 2') { + writeKey(stdin, char); + } + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('Line 1'); + await waitUntilReady(); + expect(lastFrame()).toContain('Line 2'); + }); + + // Press Enter to submit + writeKey(stdin, '\r'); + + await waitFor(async () => { + expect(onSubmit).toHaveBeenCalledWith({ '0': 'Line 1\nLine 2' }); + }); + }); + + describe.each([ + { useAlternateBuffer: true, expectedArrows: true }, + { useAlternateBuffer: false, expectedArrows: true }, + ])( + 'Scroll Arrows (useAlternateBuffer: $useAlternateBuffer)', + ({ useAlternateBuffer, expectedArrows }) => { + it(`shows scroll arrows correctly when useAlternateBuffer is ${useAlternateBuffer}`, async () => { + const questions: Question[] = [ + { + question: 'Choose an option', + header: 'Scroll Test', + type: QuestionType.CHOICE, + options: Array.from({ length: 15 }, (_, i) => ({ + label: `Option ${i + 1}`, + description: `Description ${i + 1}`, + })), + multiSelect: false, + }, + ]; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { + config: makeFakeConfig({ useAlternateBuffer }), + settings: createMockSettings({ ui: { useAlternateBuffer } }), + }, + ); + + await waitFor(async () => { + if (expectedArrows) { + await waitUntilReady(); + expect(lastFrame()).toContain('▲'); + await waitUntilReady(); + expect(lastFrame()).toContain('▼'); + } else { + await waitUntilReady(); + expect(lastFrame()).not.toContain('▲'); + await waitUntilReady(); + expect(lastFrame()).not.toContain('▼'); + } + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + }); + }, + ); + + it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => { + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 120 }, + ); + + // Type a character without navigating down + writeKey(stdin, 'A'); + + await waitFor(async () => { + // Should show the custom input with 'A' + // Placeholder is hidden when text is present + await waitUntilReady(); + expect(lastFrame()).toContain('A'); + await waitUntilReady(); + expect(lastFrame()).toContain('3. A'); + }); + + // Continue typing + writeKey(stdin, 'P'); + writeKey(stdin, 'I'); + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('API'); + }); + }); + + it('shows progress header for multiple questions', async () => { + const multiQuestions: Question[] = [ + { + question: 'Which database should we use?', + header: 'Database', + type: QuestionType.CHOICE, + options: [ + { label: 'PostgreSQL', description: 'Relational database' }, + { label: 'MongoDB', description: 'Document database' }, + ], + multiSelect: false, + }, + { + question: 'Which ORM do you prefer?', + header: 'ORM', + type: QuestionType.CHOICE, + options: [ + { label: 'Prisma', description: 'Type-safe ORM' }, + { label: 'Drizzle', description: 'Lightweight ORM' }, + ], + multiSelect: false, + }, + ]; + + const { lastFrame } = await renderWithProviders( + , + { width: 120 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('hides progress header for single question', async () => { + const { lastFrame } = await renderWithProviders( + , + { width: 120 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('shows keyboard hints', async () => { + const { lastFrame } = await renderWithProviders( + , + { width: 120 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('navigates between questions with arrow keys', async () => { + const multiQuestions: Question[] = [ + { + question: 'Which testing framework?', + header: 'Testing', + type: QuestionType.CHOICE, + options: [{ label: 'Vitest', description: 'Fast unit testing' }], + multiSelect: false, + }, + { + question: 'Which CI provider?', + header: 'CI', + type: QuestionType.CHOICE, + options: [ + { label: 'GitHub Actions', description: 'Built into GitHub' }, + ], + multiSelect: false, + }, + ]; + + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 120 }, + ); + + expect(lastFrame()).toContain('Which testing framework?'); + + writeKey(stdin, '\x1b[C'); // Right arrow + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('Which CI provider?'); + }); + + writeKey(stdin, '\x1b[D'); // Left arrow + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('Which testing framework?'); + }); + }); + + it('preserves answers when navigating back', async () => { + const multiQuestions: Question[] = [ + { + question: 'Which package manager?', + header: 'Package', + type: QuestionType.CHOICE, + options: [{ label: 'pnpm', description: 'Fast, disk efficient' }], + multiSelect: false, + }, + { + question: 'Which bundler?', + header: 'Bundler', + type: QuestionType.CHOICE, + options: [{ label: 'Vite', description: 'Next generation bundler' }], + multiSelect: false, + }, + ]; + + const onSubmit = vi.fn(); + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 120 }, + ); + + // Answer first question (should auto-advance) + writeKey(stdin, '\r'); + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('Which bundler?'); + }); + + // Navigate back + writeKey(stdin, '\x1b[D'); + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('Which package manager?'); + }); + + // Navigate forward + writeKey(stdin, '\x1b[C'); + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('Which bundler?'); + }); + + // Answer second question + writeKey(stdin, '\r'); + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('Review your answers:'); + }); + + // Submit from Review + writeKey(stdin, '\r'); + + await waitFor(async () => { + expect(onSubmit).toHaveBeenCalledWith({ '0': 'pnpm', '1': 'Vite' }); + }); + }); + + it('shows Review tab in progress header for multiple questions', async () => { + const multiQuestions: Question[] = [ + { + question: 'Which framework?', + header: 'Framework', + type: QuestionType.CHOICE, + options: [ + { label: 'React', description: 'Component library' }, + { label: 'Vue', description: 'Progressive framework' }, + ], + multiSelect: false, + }, + { + question: 'Which styling?', + header: 'Styling', + type: QuestionType.CHOICE, + options: [ + { label: 'Tailwind', description: 'Utility-first CSS' }, + { label: 'CSS Modules', description: 'Scoped styles' }, + ], + multiSelect: false, + }, + ]; + + const { lastFrame } = await renderWithProviders( + , + { width: 120 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('allows navigating to Review tab and back', async () => { + const multiQuestions: Question[] = [ + { + question: 'Create tests?', + header: 'Tests', + type: QuestionType.CHOICE, + options: [{ label: 'Yes', description: 'Generate test files' }], + multiSelect: false, + }, + { + question: 'Add documentation?', + header: 'Docs', + type: QuestionType.CHOICE, + options: [{ label: 'Yes', description: 'Generate JSDoc comments' }], + multiSelect: false, + }, + ]; + + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 120 }, + ); + + writeKey(stdin, '\x1b[C'); // Right arrow + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('Add documentation?'); + }); + + writeKey(stdin, '\x1b[C'); // Right arrow to Review + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + + writeKey(stdin, '\x1b[D'); // Left arrow back + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('Add documentation?'); + }); + }); + + it('shows warning for unanswered questions on Review tab', async () => { + const multiQuestions: Question[] = [ + { + question: 'Which license?', + header: 'License', + type: QuestionType.CHOICE, + options: [{ label: 'MIT', description: 'Permissive license' }], + multiSelect: false, + }, + { + question: 'Include README?', + header: 'README', + type: QuestionType.CHOICE, + options: [{ label: 'Yes', description: 'Generate README.md' }], + multiSelect: false, + }, + ]; + + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 120 }, + ); + + // Navigate directly to Review tab without answering + writeKey(stdin, '\x1b[C'); + writeKey(stdin, '\x1b[C'); + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + }); + + it('submits with unanswered questions when user confirms on Review', async () => { + const multiQuestions: Question[] = [ + { + question: 'Target Node version?', + header: 'Node', + type: QuestionType.CHOICE, + options: [{ label: 'Node 20', description: 'LTS version' }], + multiSelect: false, + }, + { + question: 'Enable strict mode?', + header: 'Strict', + type: QuestionType.CHOICE, + options: [{ label: 'Yes', description: 'Strict TypeScript' }], + multiSelect: false, + }, + ]; + + const onSubmit = vi.fn(); + const { stdin } = await renderWithProviders( + , + { width: 120 }, + ); + + // Answer only first question + writeKey(stdin, '\r'); + // Navigate to Review tab + writeKey(stdin, '\x1b[C'); + // Submit + writeKey(stdin, '\r'); + + await waitFor(async () => { + expect(onSubmit).toHaveBeenCalledWith({ '0': 'Node 20' }); + }); + }); + + describe('Text type questions', () => { + it('renders text input for type: "text"', async () => { + const textQuestion: Question[] = [ + { + question: 'What should we name this component?', + header: 'Name', + type: QuestionType.TEXT, + placeholder: 'e.g., UserProfileCard', + }, + ]; + + const { lastFrame } = await renderWithProviders( + , + { width: 120 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('shows default placeholder when none provided', async () => { + const textQuestion: Question[] = [ + { + question: 'Enter the database connection string:', + header: 'Database', + type: QuestionType.TEXT, + }, + ]; + + const { lastFrame } = await renderWithProviders( + , + { width: 120 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('supports backspace in text mode', async () => { + const textQuestion: Question[] = [ + { + question: 'Enter the function name:', + header: 'Function', + type: QuestionType.TEXT, + }, + ]; + + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 120 }, + ); + + for (const char of 'abc') { + writeKey(stdin, char); + } + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('abc'); + }); + + writeKey(stdin, '\x7f'); // Backspace + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('ab'); + await waitUntilReady(); + expect(lastFrame()).not.toContain('abc'); + }); + }); + + it('shows correct keyboard hints for text type', async () => { + const textQuestion: Question[] = [ + { + question: 'Enter the variable name:', + header: 'Variable', + type: QuestionType.TEXT, + }, + ]; + + const { lastFrame } = await renderWithProviders( + , + { width: 120 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('preserves text answer when navigating between questions', async () => { + const mixedQuestions: Question[] = [ + { + question: 'What should we name this hook?', + header: 'Hook', + type: QuestionType.TEXT, + }, + { + question: 'Should it be async?', + header: 'Async', + type: QuestionType.CHOICE, + options: [ + { label: 'Yes', description: 'Use async/await' }, + { label: 'No', description: 'Synchronous hook' }, + ], + multiSelect: false, + }, + ]; + + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 120 }, + ); + + for (const char of 'useAuth') { + writeKey(stdin, char); + } + + writeKey(stdin, '\t'); // Use Tab instead of Right arrow when text input is active + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('Should it be async?'); + }); + + writeKey(stdin, '\x1b[D'); // Left arrow should work when NOT focusing a text input + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('useAuth'); + }); + }); + + it('handles mixed text and choice questions', async () => { + const mixedQuestions: Question[] = [ + { + question: 'What should we name this component?', + header: 'Name', + type: QuestionType.TEXT, + placeholder: 'Enter component name', + }, + { + question: 'Which styling approach?', + header: 'Style', + type: QuestionType.CHOICE, + options: [ + { label: 'CSS Modules', description: 'Scoped CSS' }, + { label: 'Tailwind', description: 'Utility classes' }, + ], + multiSelect: false, + }, + ]; + + const onSubmit = vi.fn(); + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 120 }, + ); + + for (const char of 'DataTable') { + writeKey(stdin, char); + } + + writeKey(stdin, '\r'); + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('Which styling approach?'); + }); + + writeKey(stdin, '\r'); + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('Review your answers:'); + await waitUntilReady(); + expect(lastFrame()).toContain('Name'); + await waitUntilReady(); + expect(lastFrame()).toContain('DataTable'); + await waitUntilReady(); + expect(lastFrame()).toContain('Style'); + await waitUntilReady(); + expect(lastFrame()).toContain('CSS Modules'); + }); + + writeKey(stdin, '\r'); + + await waitFor(async () => { + expect(onSubmit).toHaveBeenCalledWith({ + '0': 'DataTable', + '1': 'CSS Modules', + }); + }); + }); + + it('submits empty text as unanswered', async () => { + const textQuestion: Question[] = [ + { + question: 'Enter the class name:', + header: 'Class', + type: QuestionType.TEXT, + }, + ]; + + const onSubmit = vi.fn(); + const { stdin } = await renderWithProviders( + , + { width: 120 }, + ); + + writeKey(stdin, '\r'); + + await waitFor(async () => { + expect(onSubmit).toHaveBeenCalledWith({}); + }); + }); + + it('clears text on Ctrl+C', async () => { + const textQuestion: Question[] = [ + { + question: 'Enter the class name:', + header: 'Class', + type: QuestionType.TEXT, + }, + ]; + + const onCancel = vi.fn(); + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 120 }, + ); + + for (const char of 'SomeText') { + writeKey(stdin, char); + } + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('SomeText'); + }); + + // Send Ctrl+C + writeKey(stdin, '\x03'); // Ctrl+C + + await waitFor(async () => { + // Text should be cleared + await waitUntilReady(); + expect(lastFrame()).not.toContain('SomeText'); + await waitUntilReady(); + expect(lastFrame()).toContain('>'); + }); + + // Should NOT call onCancel (dialog should stay open) + expect(onCancel).not.toHaveBeenCalled(); + }); + + it('allows immediate arrow navigation after switching away from text input', async () => { + const multiQuestions: Question[] = [ + { + question: 'Choice Q?', + header: 'Choice', + type: QuestionType.CHOICE, + options: [{ label: 'Option 1', description: '' }], + multiSelect: false, + }, + { + question: 'Text Q?', + header: 'Text', + type: QuestionType.TEXT, + }, + ]; + + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 120 }, + ); + + // 1. Move to Text Q (Right arrow works for Choice Q) + writeKey(stdin, '\x1b[C'); + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('Text Q?'); + }); + + // 2. Type something in Text Q to make isEditingCustomOption true + writeKey(stdin, 'a'); + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('a'); + }); + + // 3. Move back to Choice Q (Left arrow works because cursor is at left edge) + // When typing 'a', cursor is at index 1. + // We need to move cursor to index 0 first for Left arrow to work for navigation. + writeKey(stdin, '\x1b[D'); // Left arrow moves cursor to index 0 + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('Text Q?'); + }); + + writeKey(stdin, '\x1b[D'); // Second Left arrow should now trigger navigation + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('Choice Q?'); + }); + + // 4. Immediately try Right arrow to go back to Text Q + writeKey(stdin, '\x1b[C'); + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('Text Q?'); + }); + }); + + it('handles rapid sequential answers correctly (stale closure protection)', async () => { + const multiQuestions: Question[] = [ + { + question: 'Question 1?', + header: 'Q1', + type: QuestionType.CHOICE, + options: [{ label: 'A1', description: '' }], + multiSelect: false, + }, + { + question: 'Question 2?', + header: 'Q2', + type: QuestionType.CHOICE, + options: [{ label: 'A2', description: '' }], + multiSelect: false, + }, + ]; + + const onSubmit = vi.fn(); + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 120 }, + ); + + // Answer Q1 and Q2 sequentialy + act(() => { + stdin.write('\r'); // Select A1 for Q1 -> triggers autoAdvance + }); + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('Question 2?'); + }); + + act(() => { + stdin.write('\r'); // Select A2 for Q2 -> triggers autoAdvance to Review + }); + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('Review your answers:'); + }); + + act(() => { + stdin.write('\r'); // Submit from Review + }); + + await waitFor(async () => { + expect(onSubmit).toHaveBeenCalledWith({ + '0': 'A1', + '1': 'A2', + }); + }); + }); + }); + + describe('Markdown rendering', () => { + it('auto-bolds plain single-line questions', async () => { + const questions: Question[] = [ + { + question: 'Which option do you prefer?', + header: 'Test', + type: QuestionType.CHOICE, + options: [{ label: 'Yes', description: '' }], + multiSelect: false, + }, + ]; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 120 }, + ); + + await waitFor(async () => { + await waitUntilReady(); + const frame = lastFrame(); + // Plain text should be rendered as bold + expect(frame).toContain('Which option do you prefer?'); + }); + }); + + it('does not auto-bold questions that already have markdown', async () => { + const questions: Question[] = [ + { + question: 'Is **this** working?', + header: 'Test', + type: QuestionType.CHOICE, + options: [{ label: 'Yes', description: '' }], + multiSelect: false, + }, + ]; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 120 }, + ); + + await waitFor(async () => { + await waitUntilReady(); + const frame = lastFrame(); + // Should NOT have double-bold (the whole question bolded AND "this" bolded) + // "Is " should not be bold, only "this" should be bold + expect(frame).toContain('Is '); + expect(frame).toContain('this'); + expect(frame).not.toContain('**this**'); + }); + }); + + it('renders bold markdown in question', async () => { + const questions: Question[] = [ + { + question: 'Is **this** working?', + header: 'Test', + type: QuestionType.CHOICE, + options: [{ label: 'Yes', description: '' }], + multiSelect: false, + }, + ]; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 120 }, + ); + + await waitFor(async () => { + await waitUntilReady(); + const frame = lastFrame(); + // Check for 'this' - asterisks should be gone + expect(frame).toContain('this'); + expect(frame).not.toContain('**this**'); + }); + }); + + it('renders inline code markdown in question', async () => { + const questions: Question[] = [ + { + question: 'Run `npm start`?', + header: 'Test', + type: QuestionType.CHOICE, + options: [{ label: 'Yes', description: '' }], + multiSelect: false, + }, + ]; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 120 }, + ); + + await waitFor(async () => { + await waitUntilReady(); + const frame = lastFrame(); + // Backticks should be removed + expect(frame).toContain('Run npm start?'); + expect(frame).not.toContain('`'); + }); + }); + }); + + it('uses availableTerminalHeight from UIStateContext if availableHeight prop is missing', async () => { + const questions: Question[] = [ + { + question: 'Choose an option', + header: 'Context Test', + type: QuestionType.CHOICE, + options: Array.from({ length: 10 }, (_, i) => ({ + label: `Option ${i + 1}`, + description: `Description ${i + 1}`, + })), + multiSelect: false, + }, + ]; + + const mockUIState = { + availableTerminalHeight: 5, // Small height to force scroll arrows + } as UIState; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + + + , + { + config: makeFakeConfig({ useAlternateBuffer: false }), + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), + }, + ); + + // With height 5 and alternate buffer disabled, it should show scroll arrows (▲) + await waitUntilReady(); + expect(lastFrame()).toContain('▲'); + await waitUntilReady(); + expect(lastFrame()).toContain('▼'); + }); + + it('does NOT truncate the question when in alternate buffer mode even with small height', async () => { + const longQuestion = + 'This is a very long question ' + 'with many words '.repeat(10); + const questions: Question[] = [ + { + question: longQuestion, + header: 'Alternate Buffer Test', + type: QuestionType.CHOICE, + options: [{ label: 'Option 1', description: 'Desc 1' }], + multiSelect: false, + }, + ]; + + const mockUIState = { + availableTerminalHeight: 5, + } as UIState; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + + + , + { + config: makeFakeConfig({ useAlternateBuffer: true }), + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }, + ); + + // Should NOT contain the truncation message + await waitUntilReady(); + expect(lastFrame()).not.toContain('hidden ...'); + // Should contain the full long question (or at least its parts) + await waitUntilReady(); + expect(lastFrame()).toContain('This is a very long question'); + }); + + describe('Choice question placeholder', () => { + it('uses placeholder for "Other" option when provided', async () => { + const questions: Question[] = [ + { + question: 'Select your preferred language:', + header: 'Language', + type: QuestionType.CHOICE, + options: [ + { label: 'TypeScript', description: '' }, + { label: 'JavaScript', description: '' }, + ], + placeholder: 'Type another language...', + multiSelect: false, + }, + ]; + + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 80 }, + ); + + // Navigate to the "Other" option + writeKey(stdin, '\x1b[B'); // Down + writeKey(stdin, '\x1b[B'); // Down to Other + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + }); + + it('uses default placeholder when not provided', async () => { + const questions: Question[] = [ + { + question: 'Select your preferred language:', + header: 'Language', + type: QuestionType.CHOICE, + options: [ + { label: 'TypeScript', description: '' }, + { label: 'JavaScript', description: '' }, + ], + multiSelect: false, + }, + ]; + + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 80 }, + ); + + // Navigate to the "Other" option + writeKey(stdin, '\x1b[B'); // Down + writeKey(stdin, '\x1b[B'); // Down to Other + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + }); + + it('supports "Other" option for yesno questions', async () => { + const questions: Question[] = [ + { + question: 'Is this correct?', + header: 'Confirm', + type: QuestionType.YESNO, + }, + ]; + + const onSubmit = vi.fn(); + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 80 }, + ); + + // Navigate to "Other" (3rd option: 1. Yes, 2. No, 3. Other) + writeKey(stdin, '\x1b[B'); // Down to No + writeKey(stdin, '\x1b[B'); // Down to Other + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('Enter a custom value'); + }); + + // Type feedback + for (const char of 'Yes, but with caveats') { + writeKey(stdin, char); + } + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toContain('Yes, but with caveats'); + }); + + // Submit + writeKey(stdin, '\r'); + + await waitFor(async () => { + expect(onSubmit).toHaveBeenCalledWith({ '0': 'Yes, but with caveats' }); + }); + }); + }); + + it('expands paste placeholders in multi-select custom option via Done', async () => { + const questions: Question[] = [ + { + question: 'Which features?', + header: 'Features', + type: QuestionType.CHOICE, + options: [{ label: 'TypeScript', description: '' }], + multiSelect: true, + }, + ]; + + const onSubmit = vi.fn(); + const { stdin } = await renderWithProviders( + , + { width: 120 }, + ); + + // Select TypeScript + writeKey(stdin, '\r'); + // Down to Other + writeKey(stdin, '\x1b[B'); + + // Simulate bracketed paste of multi-line text into the custom option + const pastedText = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6'; + const ESC = '\x1b'; + writeKey(stdin, `${ESC}[200~${pastedText}${ESC}[201~`); + + // Down to Done and submit + writeKey(stdin, '\x1b[B'); + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ + '0': `TypeScript, ${pastedText}`, + }); + }); + }); + + it('shows at least 3 selection options even in small terminal heights', async () => { + const questions: Question[] = [ + { + question: + 'A very long question that would normally take up most of the space and squeeze the list if we did not have a heuristic to prevent it. This line is just to make it longer. And another one. Imagine this is a plan.', + header: 'Test', + type: QuestionType.CHOICE, + options: [ + { label: 'Option 1', description: 'Description 1' }, + { label: 'Option 2', description: 'Description 2' }, + { label: 'Option 3', description: 'Description 3' }, + { label: 'Option 4', description: 'Description 4' }, + ], + multiSelect: false, + }, + ]; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 80 }, + ); + + await waitFor(async () => { + await waitUntilReady(); + const frame = lastFrame(); + // Should show at least 3 options + expect(frame).toContain('1. Option 1'); + expect(frame).toContain('2. Option 2'); + expect(frame).toContain('3. Option 3'); + }); + }); + + it('allows the question to exceed 15 lines in a tall terminal', async () => { + const longQuestion = Array.from( + { length: 25 }, + (_, i) => `Line ${i + 1}`, + ).join('\n'); + const questions: Question[] = [ + { + question: longQuestion, + header: 'Tall Test', + type: QuestionType.CHOICE, + options: [ + { label: 'Option 1', description: 'D1' }, + { label: 'Option 2', description: 'D2' }, + { label: 'Option 3', description: 'D3' }, + ], + multiSelect: false, + unconstrainedHeight: false, + }, + ]; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 80 }, + ); + + await waitFor(async () => { + await waitUntilReady(); + const frame = lastFrame(); + // Should show more than 15 lines of the question + // (The limit was previously 15, so showing Line 20 proves it's working) + expect(frame).toContain('Line 20'); + expect(frame).toContain('Line 25'); + // Should still show the options + expect(frame).toContain('1. Option 1'); + }); + }); + + it('indents multi-line descriptions correctly', async () => { + const questions: Question[] = [ + { + question: 'Single choice?', + header: 'Indent Test', + type: QuestionType.CHOICE, + options: [ + { + label: 'Option 1', + description: + 'This is a very long description that is expected to wrap onto multiple lines in a narrow terminal. We want to ensure that all lines are correctly indented.', + }, + ], + multiSelect: false, + }, + ]; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 40 }, + ); + + await waitFor(async () => { + await waitUntilReady(); + // Snapshot will capture the visual alignment + expect(lastFrame()).toMatchSnapshot(); + }); + }); + + it('indents multi-line descriptions correctly in multi-select mode', async () => { + const questions: Question[] = [ + { + question: 'Multi-select?', + header: 'Indent Test', + type: QuestionType.CHOICE, + options: [ + { + label: 'Option 1', + description: + 'This is a very long description that is expected to wrap onto multiple lines in a narrow terminal. We want to ensure that all lines are correctly indented even with checkboxes.', + }, + ], + multiSelect: true, + }, + ]; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 40 }, + ); + + await waitFor(async () => { + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..61caf558a5bb78ba0c361c07ff9564b634643816 --- /dev/null +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -0,0 +1,1296 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { + useCallback, + useMemo, + useRef, + useEffect, + useReducer, + useContext, +} from 'react'; +import { Box, Text, type DOMElement } from 'ink'; +import { useMouseClick } from '../hooks/useMouseClick.js'; +import { theme } from '../semantic-colors.js'; +import { checkExhaustive, type Question } from '@google/gemini-cli-core'; +import { BaseSelectionList } from './shared/BaseSelectionList.js'; +import type { SelectionListItem } from '../hooks/useSelectionList.js'; +import { TabHeader, type Tab } from './shared/TabHeader.js'; +import { useKeypress, type Key } from '../hooks/useKeypress.js'; +import { Command } from '../key/keyMatchers.js'; +import { TextInput } from './shared/TextInput.js'; +import { formatCommand } from '../key/keybindingUtils.js'; +import { + useTextBuffer, + expandPastePlaceholders, +} from './shared/text-buffer.js'; +import { getCachedStringWidth } from '../utils/textUtils.js'; +import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js'; +import { DialogFooter } from './shared/DialogFooter.js'; +import { MarkdownDisplay } from '../utils/MarkdownDisplay.js'; +import { RenderInline } from '../utils/InlineMarkdownRenderer.js'; +import { MaxSizedBox } from './shared/MaxSizedBox.js'; +import { UIStateContext } from '../contexts/UIStateContext.js'; +import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; + +/** Padding for dialog content to prevent text from touching edges. */ +const DIALOG_PADDING = 4; + +/** + * Checks if text is a single line without markdown identifiers. + */ +function isPlainSingleLine(text: string): boolean { + // Must be a single line (no newlines) + if (text.includes('\n') || text.includes('\r')) { + return false; + } + + // Check for common markdown identifiers + const markdownPatterns = [ + /^#{1,6}\s/, // Headers + /^[`~]{3,}/, // Code fences + /^[-*+]\s/, // Unordered lists + /^\d+\.\s/, // Ordered lists + /^[-*_]{3,}$/, // Horizontal rules + /\|/, // Tables + /\*\*|__/, // Bold + /(? void; +}> = ({ isChecked, onClick }) => { + const ref = useRef(null); + useMouseClick(ref, () => { + onClick(); + }); + + return ( + + + [{isChecked ? 'x' : ' '}] + + + ); +}; + +interface AskUserDialogState { + answers: { [key: string]: string }; + isEditingCustomOption: boolean; + submitted: boolean; +} + +type AskUserDialogAction = + | { + type: 'SET_ANSWER'; + payload: { + index: number; + answer: string; + submit?: boolean; + }; + } + | { type: 'SET_EDITING_CUSTOM'; payload: { isEditing: boolean } } + | { type: 'SUBMIT' }; + +const initialState: AskUserDialogState = { + answers: {}, + isEditingCustomOption: false, + submitted: false, +}; + +function askUserDialogReducerLogic( + state: AskUserDialogState, + action: AskUserDialogAction, +): AskUserDialogState { + if (state.submitted) { + return state; + } + + switch (action.type) { + case 'SET_ANSWER': { + const { index, answer, submit } = action.payload; + const hasAnswer = + answer !== undefined && answer !== null && answer.trim() !== ''; + const newAnswers = { ...state.answers }; + + if (hasAnswer) { + newAnswers[index] = answer; + } else { + delete newAnswers[index]; + } + + return { + ...state, + answers: newAnswers, + submitted: submit ? true : state.submitted, + }; + } + case 'SET_EDITING_CUSTOM': { + if (state.isEditingCustomOption === action.payload.isEditing) { + return state; + } + return { + ...state, + isEditingCustomOption: action.payload.isEditing, + }; + } + case 'SUBMIT': { + return { + ...state, + submitted: true, + }; + } + default: + checkExhaustive(action); + return state; + } +} + +/** + * Props for the AskUserDialog component. + */ +interface AskUserDialogProps { + /** + * The list of questions to ask the user. + */ + questions: Question[]; + /** + * Callback fired when the user submits their answers. + * Returns a map of question index to answer string. + */ + onSubmit: (answers: { [questionIndex: string]: string }) => void; + /** + * Callback fired when the user cancels the dialog (e.g. via Escape). + */ + onCancel: () => void; + /** + * Optional callback to notify parent when text input is active. + * Useful for managing global keypress handlers. + */ + onActiveTextInputChange?: (active: boolean) => void; + /** + * Width of the dialog. + */ + width: number; + /** + * Height constraint for scrollable content. + */ + availableHeight?: number; + /** + * Custom keyboard shortcut hints (e.g., ["Ctrl+P to edit"]) + */ + extraParts?: string[]; +} + +interface ReviewViewProps { + questions: Question[]; + answers: { [key: string]: string }; + onSubmit: () => void; + progressHeader?: React.ReactNode; + extraParts?: string[]; +} + +const ReviewView: React.FC = ({ + questions, + answers, + onSubmit, + progressHeader, + extraParts, +}) => { + const keyMatchers = useKeyMatchers(); + const unansweredCount = questions.length - Object.keys(answers).length; + const hasUnanswered = unansweredCount > 0; + + // Handle Enter to submit + useKeypress( + (key: Key) => { + if (keyMatchers[Command.RETURN](key)) { + onSubmit(); + return true; + } + return false; + }, + { isActive: true }, + ); + + return ( + + {progressHeader} + + + Review your answers: + + + + {hasUnanswered && ( + + + ⚠ You have {unansweredCount} unanswered question + {unansweredCount > 1 ? 's' : ''} + + + )} + + + {questions.map((q, i) => ( + + {q.header} + → + + {answers[i] || '(not answered)'} + + + ))} + + + + ); +}; + +// ============== Text Question View ============== + +interface TextQuestionViewProps { + question: Question; + onAnswer: (answer: string) => void; + onSelectionChange?: (answer: string) => void; + onEditingCustomOption?: (editing: boolean) => void; + availableWidth: number; + availableHeight?: number; + initialAnswer?: string; + progressHeader?: React.ReactNode; + keyboardHints?: React.ReactNode; +} + +const TextQuestionView: React.FC = ({ + question, + onAnswer, + onSelectionChange, + onEditingCustomOption, + availableWidth, + availableHeight, + initialAnswer, + progressHeader, + keyboardHints, +}) => { + const keyMatchers = useKeyMatchers(); + const isAlternateBuffer = useAlternateBuffer(); + const prefix = '> '; + const horizontalPadding = 1; // 1 for cursor + const bufferWidth = + availableWidth - getCachedStringWidth(prefix) - horizontalPadding; + + const buffer = useTextBuffer({ + initialText: initialAnswer, + viewport: { width: Math.max(1, bufferWidth), height: 3 }, + singleLine: false, + }); + + const { text: textValue } = buffer; + + // Sync state change with parent - only when it actually changes + const lastTextValueRef = useRef(textValue); + useEffect(() => { + if (textValue !== lastTextValueRef.current) { + onSelectionChange?.( + expandPastePlaceholders(textValue, buffer.pastedContent), + ); + lastTextValueRef.current = textValue; + } + }, [textValue, onSelectionChange, buffer.pastedContent]); + + // Handle Ctrl+C to clear all text + const handleExtraKeys = useCallback( + (key: Key) => { + if (keyMatchers[Command.QUIT](key)) { + if (textValue === '') { + return false; + } + buffer.setText(''); + return true; + } + return false; + }, + [buffer, textValue, keyMatchers], + ); + + useKeypress(handleExtraKeys, { isActive: true, priority: true }); + + const handleSubmit = useCallback( + (val: string) => { + onAnswer(val.trim()); + }, + [onAnswer], + ); + + // Notify parent that we're in text input mode (for Ctrl+C handling) + useEffect(() => { + onEditingCustomOption?.(true); + return () => { + onEditingCustomOption?.(false); + }; + }, [onEditingCustomOption]); + + const placeholder = question.placeholder || 'Enter your response'; + + const HEADER_HEIGHT = progressHeader ? 2 : 0; + const INPUT_HEIGHT = 2; // TextInput + margin + const FOOTER_HEIGHT = 2; // DialogFooter + margin + const overhead = HEADER_HEIGHT + INPUT_HEIGHT + FOOTER_HEIGHT; + const questionHeight = + availableHeight && !isAlternateBuffer + ? Math.max(1, availableHeight - overhead) + : undefined; + + return ( + + {progressHeader} + + + + + + + + {'> '} + + + + {keyboardHints} + + ); +}; + +// ============== Choice Question View ============== + +interface OptionItem { + key: string; + label: string; + description: string; + type: 'option' | 'other' | 'done' | 'all'; + index: number; +} + +interface ChoiceQuestionState { + selectedIndices: Set; + isCustomOptionSelected: boolean; + isCustomOptionFocused: boolean; +} + +type ChoiceQuestionAction = + | { type: 'TOGGLE_INDEX'; payload: { index: number; multiSelect: boolean } } + | { type: 'TOGGLE_ALL'; payload: { totalOptions: number } } + | { + type: 'SET_CUSTOM_SELECTED'; + payload: { selected: boolean; multiSelect: boolean }; + } + | { type: 'TOGGLE_CUSTOM_SELECTED'; payload: { multiSelect: boolean } } + | { type: 'SET_CUSTOM_FOCUSED'; payload: { focused: boolean } }; + +function choiceQuestionReducer( + state: ChoiceQuestionState, + action: ChoiceQuestionAction, +): ChoiceQuestionState { + switch (action.type) { + case 'TOGGLE_ALL': { + const { totalOptions } = action.payload; + const allSelected = state.selectedIndices.size === totalOptions; + if (allSelected) { + return { + ...state, + selectedIndices: new Set(), + }; + } else { + const newIndices = new Set(); + for (let i = 0; i < totalOptions; i++) { + newIndices.add(i); + } + return { + ...state, + selectedIndices: newIndices, + }; + } + } + case 'TOGGLE_INDEX': { + const { index, multiSelect } = action.payload; + const newIndices = new Set(multiSelect ? state.selectedIndices : []); + if (newIndices.has(index)) { + newIndices.delete(index); + } else { + newIndices.add(index); + } + return { + ...state, + selectedIndices: newIndices, + // In single select, selecting an option deselects custom + isCustomOptionSelected: multiSelect + ? state.isCustomOptionSelected + : false, + }; + } + case 'SET_CUSTOM_SELECTED': { + const { selected, multiSelect } = action.payload; + return { + ...state, + isCustomOptionSelected: selected, + // In single-select, selecting custom deselects others + selectedIndices: multiSelect ? state.selectedIndices : new Set(), + }; + } + case 'TOGGLE_CUSTOM_SELECTED': { + const { multiSelect } = action.payload; + if (!multiSelect) return state; + + return { + ...state, + isCustomOptionSelected: !state.isCustomOptionSelected, + }; + } + case 'SET_CUSTOM_FOCUSED': { + return { + ...state, + isCustomOptionFocused: action.payload.focused, + }; + } + default: + checkExhaustive(action); + return state; + } +} + +interface ChoiceQuestionViewProps { + question: Question; + onAnswer: (answer: string) => void; + onSelectionChange?: (answer: string) => void; + onEditingCustomOption?: (editing: boolean) => void; + availableWidth: number; + availableHeight?: number; + initialAnswer?: string; + progressHeader?: React.ReactNode; + keyboardHints?: React.ReactNode; +} + +const ChoiceQuestionView: React.FC = ({ + question, + onAnswer, + onSelectionChange, + onEditingCustomOption, + availableWidth, + availableHeight, + initialAnswer, + progressHeader, + keyboardHints, +}) => { + const keyMatchers = useKeyMatchers(); + const isAlternateBuffer = useAlternateBuffer(); + const hasAll = question.multiSelect && (question.options?.length ?? 0) > 1; + // Calculate total options including 'All' and 'Other' to ensure consistent numbering column width + const numOptions = (question.options?.length ?? 0) + (hasAll ? 1 : 0) + 1; + const numLen = String(numOptions).length; + const radioWidth = 2; // "● " + const numberWidth = numLen + 2; // e.g., "1. " + const checkboxWidth = question.multiSelect ? 4 : 1; // "[x] " or " " + const checkmarkWidth = question.multiSelect ? 0 : 2; // "" or " ✓" + const cursorPadding = 1; // Extra character for cursor at end of line + + const horizontalPadding = + radioWidth + numberWidth + checkboxWidth + checkmarkWidth + cursorPadding; + + const bufferWidth = availableWidth - horizontalPadding; + + const questionOptions = useMemo( + () => question.options ?? [], + [question.options], + ); + + // Initialize state from initialAnswer if returning to a previously answered question + const initialReducerState = useMemo((): ChoiceQuestionState => { + if (!initialAnswer) { + return { + selectedIndices: new Set(), + isCustomOptionSelected: false, + isCustomOptionFocused: false, + }; + } + + // Check if initialAnswer matches any option labels + const selectedIndices = new Set(); + let isCustomOptionSelected = false; + + if (question.multiSelect) { + const answers = initialAnswer.split(', '); + answers.forEach((answer) => { + const index = questionOptions.findIndex((opt) => opt.label === answer); + if (index !== -1) { + selectedIndices.add(index); + } else { + isCustomOptionSelected = true; + } + }); + } else { + const index = questionOptions.findIndex( + (opt) => opt.label === initialAnswer, + ); + if (index !== -1) { + selectedIndices.add(index); + } else { + isCustomOptionSelected = true; + } + } + + return { + selectedIndices, + isCustomOptionSelected, + isCustomOptionFocused: false, + }; + }, [initialAnswer, questionOptions, question.multiSelect]); + + const [state, dispatch] = useReducer( + choiceQuestionReducer, + initialReducerState, + ); + const { selectedIndices, isCustomOptionSelected, isCustomOptionFocused } = + state; + + const initialCustomText = useMemo(() => { + if (!initialAnswer) return ''; + if (question.multiSelect) { + const answers = initialAnswer.split(', '); + const custom = answers.find( + (a) => !questionOptions.some((opt) => opt.label === a), + ); + return custom || ''; + } else { + const isPredefined = questionOptions.some( + (opt) => opt.label === initialAnswer, + ); + return isPredefined ? '' : initialAnswer; + } + }, [initialAnswer, questionOptions, question.multiSelect]); + + const customBuffer = useTextBuffer({ + initialText: initialCustomText, + viewport: { width: Math.max(1, bufferWidth), height: 3 }, + singleLine: false, + }); + + const customOptionText = customBuffer.text; + + // Helper to build answer string from selections + const buildAnswerString = useCallback( + ( + indices: Set, + includeCustomOption: boolean, + customOption: string, + ) => { + const answers: string[] = []; + questionOptions.forEach((opt, i) => { + if (indices.has(i)) { + answers.push(opt.label); + } + }); + if (includeCustomOption && customOption.trim()) { + const expanded = expandPastePlaceholders( + customOption, + customBuffer.pastedContent, + ); + answers.push(expanded.trim()); + } + return answers.join(', '); + }, + [questionOptions, customBuffer.pastedContent], + ); + + // Synchronize selection changes with parent - only when it actually changes + const lastBuiltAnswerRef = useRef(''); + useEffect(() => { + const newAnswer = buildAnswerString( + selectedIndices, + isCustomOptionSelected, + customOptionText, + ); + if (newAnswer !== lastBuiltAnswerRef.current) { + onSelectionChange?.(newAnswer); + lastBuiltAnswerRef.current = newAnswer; + } + }, [ + selectedIndices, + isCustomOptionSelected, + customOptionText, + buildAnswerString, + onSelectionChange, + ]); + + // Handle "Type-to-Jump" and Ctrl+C for custom buffer + const handleExtraKeys = useCallback( + (key: Key) => { + // If focusing custom option, handle Ctrl+C + if (isCustomOptionFocused && keyMatchers[Command.QUIT](key)) { + if (customOptionText === '') { + return false; + } + customBuffer.setText(''); + return true; + } + + // Don't jump if a navigation or selection key is pressed + if ( + keyMatchers[Command.DIALOG_NAVIGATION_UP](key) || + keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key) || + keyMatchers[Command.DIALOG_NEXT](key) || + keyMatchers[Command.DIALOG_PREV](key) || + keyMatchers[Command.MOVE_LEFT](key) || + keyMatchers[Command.MOVE_RIGHT](key) || + keyMatchers[Command.RETURN](key) || + keyMatchers[Command.ESCAPE](key) || + keyMatchers[Command.QUIT](key) + ) { + return false; + } + + // Check if it's a numeric quick selection key (if numbers are shown) + const isNumeric = /^[0-9]$/.test(key.sequence); + if (isNumeric) { + return false; + } + + // Type-to-jump: if printable characters are typed and not focused, jump to custom + const isPrintable = + key.sequence && + !key.ctrl && + !key.alt && + (key.sequence.length > 1 || key.sequence.charCodeAt(0) >= 32); + + if (isPrintable && !isCustomOptionFocused) { + dispatch({ type: 'SET_CUSTOM_FOCUSED', payload: { focused: true } }); + onEditingCustomOption?.(true); + // For IME or multi-char sequences, we want to capture the whole thing. + // If it's a single char, we start the buffer with it. + customBuffer.setText(key.sequence); + return true; + } + return false; + }, + [ + isCustomOptionFocused, + customBuffer, + onEditingCustomOption, + customOptionText, + keyMatchers, + ], + ); + + useKeypress(handleExtraKeys, { isActive: true, priority: true }); + + const selectionItems = useMemo((): Array> => { + const list: Array> = questionOptions.map( + (opt, i) => { + const item: OptionItem = { + key: `opt-${i}`, + label: opt.label, + description: opt.description, + type: 'option', + index: i, + }; + return { key: item.key, value: item }; + }, + ); + + // Add 'All of the above' for multi-select + if (question.multiSelect && questionOptions.length > 1) { + const allItem: OptionItem = { + key: 'all', + label: 'All of the above', + description: 'Select all options', + type: 'all', + index: list.length, + }; + list.push({ key: 'all', value: allItem }); + } + + // Add custom option for choice and yesno types + const otherItem: OptionItem = { + key: 'other', + label: customOptionText || '', + description: '', + type: 'other', + index: list.length, + }; + list.push({ key: 'other', value: otherItem }); + + if (question.multiSelect) { + const doneItem: OptionItem = { + key: 'done', + label: 'Done', + description: 'Finish selection', + type: 'done', + index: list.length, + }; + list.push({ key: doneItem.key, value: doneItem, hideNumber: true }); + } + + return list; + }, [questionOptions, question.multiSelect, customOptionText]); + + const handleHighlight = useCallback( + (itemValue: OptionItem) => { + const nowFocusingCustomOption = itemValue.type === 'other'; + dispatch({ + type: 'SET_CUSTOM_FOCUSED', + payload: { focused: nowFocusingCustomOption }, + }); + // Notify parent when we start/stop focusing custom option (so navigation can resume) + onEditingCustomOption?.(nowFocusingCustomOption); + }, + [onEditingCustomOption], + ); + + const handleSelect = useCallback( + (itemValue: OptionItem) => { + if (question.multiSelect) { + if (itemValue.type === 'option') { + dispatch({ + type: 'TOGGLE_INDEX', + payload: { index: itemValue.index, multiSelect: true }, + }); + } else if (itemValue.type === 'other') { + dispatch({ + type: 'TOGGLE_CUSTOM_SELECTED', + payload: { multiSelect: true }, + }); + } else if (itemValue.type === 'all') { + dispatch({ + type: 'TOGGLE_ALL', + payload: { totalOptions: questionOptions.length }, + }); + } else if (itemValue.type === 'done') { + // Done just triggers navigation, selections already saved via useEffect + onAnswer( + buildAnswerString( + selectedIndices, + isCustomOptionSelected, + customOptionText, + ), + ); + } + } else { + if (itemValue.type === 'option') { + onAnswer(itemValue.label); + } else if (itemValue.type === 'other') { + // In single select, selecting other submits it if it has text + if (customOptionText.trim()) { + onAnswer( + expandPastePlaceholders( + customOptionText, + customBuffer.pastedContent, + ).trim(), + ); + } + } + } + }, + [ + question.multiSelect, + questionOptions.length, + selectedIndices, + isCustomOptionSelected, + customOptionText, + customBuffer.pastedContent, + onAnswer, + buildAnswerString, + ], + ); + + // Auto-select custom option when typing in it + useEffect(() => { + if (customOptionText.trim() && !isCustomOptionSelected) { + dispatch({ + type: 'SET_CUSTOM_SELECTED', + payload: { selected: true, multiSelect: !!question.multiSelect }, + }); + } + }, [customOptionText, isCustomOptionSelected, question.multiSelect]); + + const HEADER_HEIGHT = progressHeader ? 2 : 0; + const TITLE_MARGIN = 1; + const FOOTER_HEIGHT = 2; // DialogFooter + margin + const overhead = HEADER_HEIGHT + TITLE_MARGIN + FOOTER_HEIGHT; + + const listHeight = availableHeight + ? Math.max(1, availableHeight - overhead) + : undefined; + + // Reserve space for at least 3 items if more selectionItems available. + const reservedListHeight = Math.min(selectionItems.length * 2, 6); + const questionHeightLimit = + listHeight && !isAlternateBuffer + ? question.unconstrainedHeight + ? Math.max(1, listHeight - selectionItems.length * 2) + : Math.max(1, listHeight - Math.max(DIALOG_PADDING, reservedListHeight)) + : undefined; + + const maxItemsToShow = + listHeight && (!isAlternateBuffer || availableHeight !== undefined) + ? Math.min( + selectionItems.length, + Math.max( + 1, + Math.floor((listHeight - (questionHeightLimit ?? 0)) / 2), + ), + ) + : selectionItems.length; + + return ( + + {progressHeader} + + + + + {question.multiSelect && ( + + (Select all that apply) + + )} + + + + + + items={selectionItems} + onSelect={handleSelect} + onHighlight={handleHighlight} + focusKey={isCustomOptionFocused ? 'other' : undefined} + maxItemsToShow={maxItemsToShow} + showScrollArrows={true} + renderItem={(item, context) => { + const optionItem = item.value; + const isChecked = + (optionItem.type === 'option' && + selectedIndices.has(optionItem.index)) || + (optionItem.type === 'other' && isCustomOptionSelected) || + (optionItem.type === 'all' && + selectedIndices.size === questionOptions.length); + const showCheck = + question.multiSelect && + (optionItem.type === 'option' || + optionItem.type === 'other' || + optionItem.type === 'all'); + + // Render inline text input for custom option + if (optionItem.type === 'other') { + const placeholder = question.placeholder || 'Enter a custom value'; + return ( + + {showCheck && ( + { + if (!context.isSelected) { + handleSelect(optionItem); + } + }} + /> + )} + + { + if (question.multiSelect) { + const fullAnswer = buildAnswerString( + selectedIndices, + true, + val, + ); + if (fullAnswer) { + onAnswer(fullAnswer); + } + } else if (val.trim()) { + onAnswer(val.trim()); + } + }} + /> + {isChecked && !question.multiSelect && !context.isSelected && ( + ✓ + )} + + ); + } + + // Determine label color: checked (previously answered) uses success, selected uses accent, else primary + const labelColor = + isChecked && !question.multiSelect + ? theme.status.success + : context.isSelected + ? context.titleColor + : theme.text.primary; + + return ( + + + {showCheck && ( + { + if (!context.isSelected) { + handleSelect(optionItem); + } + }} + /> + )} + + {' '} + {optionItem.label} + + {isChecked && !question.multiSelect && ( + ✓ + )} + + {optionItem.description && ( + // Padding aligns with option label: 4 for multi-select (checkbox + space), 1 for single-select + + + + + + )} + + ); + }} + /> + {keyboardHints} + + ); +}; + +export const AskUserDialog: React.FC = ({ + questions, + onSubmit, + onCancel, + onActiveTextInputChange, + width, + availableHeight: availableHeightProp, + extraParts, +}) => { + const keyMatchers = useKeyMatchers(); + const uiState = useContext(UIStateContext); + const availableHeight = + availableHeightProp ?? + (uiState?.constrainHeight !== false + ? uiState?.availableTerminalHeight + : undefined); + + const [state, dispatch] = useReducer(askUserDialogReducerLogic, initialState); + const { answers, isEditingCustomOption, submitted } = state; + + const reviewTabIndex = questions.length; + const tabCount = + questions.length > 1 ? questions.length + 1 : questions.length; + + const { currentIndex, goToNextTab, goToPrevTab } = useTabbedNavigation({ + tabCount, + isActive: !submitted && questions.length > 1, + enableArrowNavigation: false, // We'll handle arrows via textBuffer callbacks or manually + enableTabKey: false, // We'll handle tab manually to match existing behavior + }); + + const currentQuestionIndex = currentIndex; + + const handleEditingCustomOption = useCallback((isEditing: boolean) => { + dispatch({ type: 'SET_EDITING_CUSTOM', payload: { isEditing } }); + }, []); + + useEffect(() => { + onActiveTextInputChange?.(isEditingCustomOption); + return () => { + onActiveTextInputChange?.(false); + }; + }, [isEditingCustomOption, onActiveTextInputChange]); + + const handleCancel = useCallback( + (key: Key) => { + if (submitted) return false; + if (keyMatchers[Command.ESCAPE](key)) { + onCancel(); + return true; + } else if (keyMatchers[Command.QUIT](key)) { + if (!isEditingCustomOption) { + onCancel(); + } + // Return false to let ctrl-C bubble up to AppContainer for exit flow + return false; + } + return false; + }, + [onCancel, submitted, isEditingCustomOption, keyMatchers], + ); + + useKeypress(handleCancel, { + isActive: !submitted, + }); + + const isOnReviewTab = currentQuestionIndex === reviewTabIndex; + + const handleNavigation = useCallback( + (key: Key) => { + if (submitted || questions.length <= 1) return false; + + const isNextKey = keyMatchers[Command.DIALOG_NEXT](key); + const isPrevKey = keyMatchers[Command.DIALOG_PREV](key); + + const isRight = keyMatchers[Command.MOVE_RIGHT](key); + const isLeft = keyMatchers[Command.MOVE_LEFT](key); + + // Tab keys always trigger navigation. + // Arrows trigger navigation if NOT in a text input OR if the input bubbles the event (already at edge). + const shouldGoNext = isNextKey || isRight; + const shouldGoPrev = isPrevKey || isLeft; + + if (shouldGoNext) { + goToNextTab(); + return true; + } else if (shouldGoPrev) { + goToPrevTab(); + return true; + } + return false; + }, + [questions.length, submitted, goToNextTab, goToPrevTab, keyMatchers], + ); + + useKeypress(handleNavigation, { + isActive: questions.length > 1 && !submitted, + }); + + useEffect(() => { + if (submitted) { + onSubmit(answers); + } + }, [submitted, answers, onSubmit]); + + const handleAnswer = useCallback( + (answer: string) => { + if (submitted) return; + + if (questions.length > 1) { + dispatch({ + type: 'SET_ANSWER', + payload: { + index: currentQuestionIndex, + answer, + }, + }); + goToNextTab(); + } else { + dispatch({ + type: 'SET_ANSWER', + payload: { + index: currentQuestionIndex, + answer, + submit: true, + }, + }); + } + }, + [currentQuestionIndex, questions, submitted, goToNextTab], + ); + + const handleReviewSubmit = useCallback(() => { + if (submitted) return; + dispatch({ type: 'SUBMIT' }); + }, [submitted]); + + const handleSelectionChange = useCallback( + (answer: string) => { + if (submitted) return; + dispatch({ + type: 'SET_ANSWER', + payload: { + index: currentQuestionIndex, + answer, + }, + }); + }, + [submitted, currentQuestionIndex], + ); + + const answeredIndices = useMemo( + () => new Set(Object.keys(answers).map(Number)), + [answers], + ); + + const currentQuestion = questions[currentQuestionIndex]; + + const effectiveQuestion = useMemo(() => { + if (currentQuestion?.type === 'yesno') { + return { + ...currentQuestion, + options: [ + { label: 'Yes', description: '' }, + { label: 'No', description: '' }, + ], + multiSelect: false, + }; + } + return currentQuestion; + }, [currentQuestion]); + + const tabs = useMemo((): Tab[] => { + const questionTabs: Tab[] = questions.map((q, i) => ({ + key: String(i), + header: q.header, + })); + if (questions.length > 1) { + questionTabs.push({ + key: 'review', + header: 'Review', + isSpecial: true, + }); + } + return questionTabs; + }, [questions]); + + const progressHeader = + questions.length > 1 ? ( + + ) : null; + + if (isOnReviewTab) { + return ( + + + + ); + } + + if (!currentQuestion) return null; + + const keyboardHints = ( + 1 + ? currentQuestion.type === 'text' || isEditingCustomOption + ? `${formatCommand(Command.DIALOG_NEXT)}/${formatCommand(Command.DIALOG_PREV)} to switch questions` + : '←/→ to switch questions' + : currentQuestion.type === 'text' || isEditingCustomOption + ? undefined + : '↑/↓ to navigate' + } + extraParts={extraParts} + /> + ); + + const questionView = + currentQuestion.type === 'text' ? ( + + ) : ( + + ); + + return ( + + {questionView} + + ); +}; diff --git a/packages/cli/src/ui/components/BackgroundTaskDisplay.test.tsx b/packages/cli/src/ui/components/BackgroundTaskDisplay.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6083a0e569ab9cbbe4ec83782467eef60daf3dd8 --- /dev/null +++ b/packages/cli/src/ui/components/BackgroundTaskDisplay.test.tsx @@ -0,0 +1,407 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { BackgroundTaskDisplay } from './BackgroundTaskDisplay.js'; +import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js'; +import { ShellExecutionService } from '@google/gemini-cli-core'; +import { act } from 'react'; +import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js'; +import { ScrollProvider } from '../contexts/ScrollProvider.js'; +import { Box } from 'ink'; + +// Mock dependencies +const mockDismissBackgroundTask = vi.fn(); +const mockSetActiveBackgroundTaskPid = vi.fn(); +const mockSetIsBackgroundTaskListOpen = vi.fn(); + +vi.mock('../contexts/UIActionsContext.js', () => ({ + useUIActions: () => ({ + dismissBackgroundTask: mockDismissBackgroundTask, + setActiveBackgroundTaskPid: mockSetActiveBackgroundTaskPid, + setIsBackgroundTaskListOpen: mockSetIsBackgroundTaskListOpen, + }), +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + ShellExecutionService: { + resizePty: vi.fn(), + subscribe: vi.fn(() => vi.fn()), + getLogFilePath: vi.fn( + (pid) => `~/.gemini/tmp/background-processes/background-${pid}.log`, + ), + getLogDir: vi.fn(() => '~/.gemini/tmp/background-processes'), + }, + }; +}); + +// Mock AnsiOutputText since it's a complex component +vi.mock('./AnsiOutput.js', () => ({ + AnsiOutputText: ({ data }: { data: string | unknown }) => { + if (typeof data === 'string') return <>{data}; + // Simple serialization for object data + return <>{JSON.stringify(data)}; + }, +})); + +// Mock useKeypress +let keypressHandlers: Array<{ handler: KeypressHandler; isActive: boolean }> = + []; +vi.mock('../hooks/useKeypress.js', () => ({ + useKeypress: vi.fn((handler, { isActive }) => { + keypressHandlers.push({ handler, isActive }); + }), +})); + +const simulateKey = (key: Partial) => { + const fullKey: Key = createMockKey(key); + keypressHandlers.forEach(({ handler, isActive }) => { + if (isActive) { + handler(fullKey); + } + }); +}; + +vi.mock('../contexts/MouseContext.js', () => ({ + useMouseContext: vi.fn(() => ({ + subscribe: vi.fn(), + unsubscribe: vi.fn(), + })), + useMouse: vi.fn(), +})); + +// Mock ScrollableList +vi.mock('./shared/ScrollableList.js', () => ({ + SCROLL_TO_ITEM_END: 999999, + ScrollableList: vi.fn( + ({ + data, + renderItem, + }: { + data: BackgroundTask[]; + renderItem: (props: { + item: BackgroundTask; + index: number; + }) => React.ReactNode; + }) => ( + + {data.map((item: BackgroundTask, index: number) => ( + {renderItem({ item, index })} + ))} + + ), + ), +})); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const createMockKey = (overrides: Partial): Key => ({ + name: '', + ctrl: false, + alt: false, + cmd: false, + shift: false, + insertable: false, + sequence: '', + ...overrides, +}); + +describe('', () => { + const mockShells = new Map(); + const shell1: BackgroundTask = { + pid: 1001, + command: 'npm start', + output: 'Starting server...', + isBinary: false, + binaryBytesReceived: 0, + status: 'running', + }; + const shell2: BackgroundTask = { + pid: 1002, + command: 'tail -f log.txt', + output: 'Log entry 1', + isBinary: false, + binaryBytesReceived: 0, + status: 'running', + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockShells.clear(); + mockShells.set(shell1.pid, shell1); + mockShells.set(shell2.pid, shell2); + keypressHandlers = []; + }); + + it('renders the output of the active shell', async () => { + const width = 80; + const { lastFrame, unmount } = await render( + + + , + width, + ); + + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('renders tabs for multiple shells', async () => { + const width = 100; + const { lastFrame, unmount } = await render( + + + , + width, + ); + + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('highlights the focused state', async () => { + const width = 80; + const { lastFrame, unmount } = await render( + + + , + width, + ); + + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('resizes the PTY on mount and when dimensions change', async () => { + const width = 80; + const { rerender, unmount } = await render( + + + , + width, + ); + + expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( + shell1.pid, + 76, + 20, + ); + + rerender( + + + , + ); + + expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( + shell1.pid, + 96, + 26, + ); + unmount(); + }); + + it('renders the process list when isListOpenProp is true', async () => { + const width = 80; + const { lastFrame, unmount } = await render( + + + , + width, + ); + + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('selects the current process and closes the list when Ctrl+L is pressed in list view', async () => { + const width = 80; + const { unmount } = await render( + + + , + width, + ); + + // Simulate down arrow to select the second process (handled by RadioButtonSelect) + await act(async () => { + simulateKey({ name: 'down' }); + }); + + // Simulate Ctrl+L (handled by BackgroundTaskDisplay) + await act(async () => { + simulateKey({ name: 'l', ctrl: true }); + }); + + expect(mockSetActiveBackgroundTaskPid).toHaveBeenCalledWith(shell2.pid); + expect(mockSetIsBackgroundTaskListOpen).toHaveBeenCalledWith(false); + unmount(); + }); + + it('kills the highlighted process when Ctrl+K is pressed in list view', async () => { + const width = 80; + const { unmount } = await render( + + + , + width, + ); + + // Initial state: shell1 (active) is highlighted + + // Move to shell2 + await act(async () => { + simulateKey({ name: 'down' }); + }); + + // Press Ctrl+K + await act(async () => { + simulateKey({ name: 'k', ctrl: true }); + }); + + expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell2.pid); + unmount(); + }); + + it('kills the active process when Ctrl+K is pressed in output view', async () => { + const width = 80; + const { unmount } = await render( + + + , + width, + ); + + await act(async () => { + simulateKey({ name: 'k', ctrl: true }); + }); + + expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell1.pid); + unmount(); + }); + + it('scrolls to active shell when list opens', async () => { + // shell2 is active + const width = 80; + const { lastFrame, unmount } = await render( + + + , + width, + ); + + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('keeps exit code status color even when selected', async () => { + const exitedShell: BackgroundTask = { + pid: 1003, + command: 'exit 0', + output: '', + isBinary: false, + binaryBytesReceived: 0, + status: 'exited', + exitCode: 0, + }; + mockShells.set(exitedShell.pid, exitedShell); + + const width = 80; + const { lastFrame, unmount } = await render( + + + , + width, + ); + + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/BackgroundTaskDisplay.tsx b/packages/cli/src/ui/components/BackgroundTaskDisplay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aad76bc2456e1e37c8043533e87f146072dfd27d --- /dev/null +++ b/packages/cli/src/ui/components/BackgroundTaskDisplay.tsx @@ -0,0 +1,488 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { useEffect, useState, useRef } from 'react'; +import { useUIActions } from '../contexts/UIActionsContext.js'; +import { theme } from '../semantic-colors.js'; +import { + ShellExecutionService, + shortenPath, + tildeifyPath, + type AnsiOutput, + type AnsiLine, + type AnsiToken, +} from '@google/gemini-cli-core'; +import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js'; +import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js'; +import { Command } from '../key/keyMatchers.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { formatCommand } from '../key/keybindingUtils.js'; +import { + ScrollableList, + type ScrollableListRef, +} from './shared/ScrollableList.js'; + +import { SCROLL_TO_ITEM_END } from './shared/VirtualizedList.js'; + +import { + RadioButtonSelect, + type RadioSelectItem, +} from './shared/RadioButtonSelect.js'; +import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; + +interface BackgroundTaskDisplayProps { + shells: Map; + activePid: number; + width: number; + height: number; + isFocused: boolean; + isListOpenProp: boolean; +} + +const CONTENT_PADDING_X = 1; +const BORDER_WIDTH = 2; // Left and Right border +const MAIN_BORDER_HEIGHT = 2; // Top and Bottom border +const HEADER_HEIGHT = 1; +const FOOTER_HEIGHT = 1; +const TOTAL_OVERHEAD_HEIGHT = + MAIN_BORDER_HEIGHT + HEADER_HEIGHT + FOOTER_HEIGHT; +const PROCESS_LIST_HEADER_HEIGHT = 3; // 1 padding top, 1 text, 1 margin bottom +const TAB_DISPLAY_HORIZONTAL_PADDING = 4; +const LOG_PATH_OVERHEAD = 7; // "Log: " (5) + paddingX (2) + +const formatShellCommandForDisplay = (command: string, maxWidth: number) => { + const commandFirstLine = command.split('\n')[0]; + return cpLen(commandFirstLine) > maxWidth + ? `${cpSlice(commandFirstLine, 0, maxWidth - 3)}...` + : commandFirstLine; +}; + +export const BackgroundTaskDisplay = ({ + shells, + activePid, + width, + height, + isFocused, + isListOpenProp, +}: BackgroundTaskDisplayProps) => { + const keyMatchers = useKeyMatchers(); + const { + dismissBackgroundTask, + setActiveBackgroundTaskPid, + setIsBackgroundTaskListOpen, + } = useUIActions(); + const activeShell = shells.get(activePid); + const [output, setOutput] = useState( + activeShell?.output || '', + ); + const [highlightedPid, setHighlightedPid] = useState( + activePid, + ); + const outputRef = useRef>(null); + const subscribedRef = useRef(false); + + useEffect(() => { + if (!activePid) return; + + const ptyWidth = Math.max(1, width - BORDER_WIDTH - CONTENT_PADDING_X * 2); + const ptyHeight = Math.max(1, height - TOTAL_OVERHEAD_HEIGHT); + ShellExecutionService.resizePty(activePid, ptyWidth, ptyHeight); + }, [activePid, width, height]); + + useEffect(() => { + if (!activePid) { + setOutput(''); + return; + } + + // Set initial output from the shell object + const shell = shells.get(activePid); + if (shell) { + setOutput(shell.output); + } + + subscribedRef.current = false; + + // Subscribe to live updates for the active shell + const unsubscribe = ShellExecutionService.subscribe(activePid, (event) => { + if (event.type === 'data') { + if (typeof event.chunk === 'string') { + if (!subscribedRef.current) { + // Initial synchronous update contains full history + setOutput(event.chunk); + } else { + // Subsequent updates are deltas for child_process + setOutput((prev) => + typeof prev === 'string' ? prev + event.chunk : event.chunk, + ); + } + } else { + // PTY always sends full AnsiOutput + setOutput(event.chunk); + } + } + }); + + subscribedRef.current = true; + + return () => { + unsubscribe(); + subscribedRef.current = false; + }; + }, [activePid, shells]); + + // Sync highlightedPid with activePid when list opens + useEffect(() => { + if (isListOpenProp) { + setHighlightedPid(activePid); + } + }, [isListOpenProp, activePid]); + + useKeypress( + (key) => { + if (!activeShell) return; + + if (isListOpenProp) { + // Navigation (Up/Down/Enter) is handled by RadioButtonSelect + // We only handle special keys not consumed by RadioButtonSelect or overriding them if needed + // RadioButtonSelect handles Enter -> onSelect + + if (keyMatchers[Command.BACKGROUND_SHELL_ESCAPE](key)) { + setIsBackgroundTaskListOpen(false); + return true; + } + + if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { + if (highlightedPid) { + void dismissBackgroundTask(highlightedPid); + // If we killed the active one, the list might update via props + } + return true; + } + + if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) { + if (highlightedPid) { + setActiveBackgroundTaskPid(highlightedPid); + } + setIsBackgroundTaskListOpen(false); + return true; + } + return false; + } + + if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) { + return false; + } + + if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { + void dismissBackgroundTask(activeShell.pid); + return true; + } + + if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) { + setIsBackgroundTaskListOpen(true); + return true; + } + + if (keyMatchers[Command.BACKGROUND_SHELL_SELECT](key)) { + ShellExecutionService.writeToPty(activeShell.pid, '\r'); + return true; + } else if (keyMatchers[Command.DELETE_CHAR_LEFT](key)) { + ShellExecutionService.writeToPty(activeShell.pid, '\b'); + return true; + } else if (key.sequence) { + ShellExecutionService.writeToPty(activeShell.pid, key.sequence); + return true; + } + return false; + }, + { isActive: isFocused && !!activeShell }, + ); + + const helpTextParts = [ + { label: 'Close', command: Command.TOGGLE_BACKGROUND_SHELL }, + { label: 'Kill', command: Command.KILL_BACKGROUND_SHELL }, + { label: 'List', command: Command.TOGGLE_BACKGROUND_SHELL_LIST }, + ]; + + const helpTextStr = helpTextParts + .map((p) => `${p.label} (${formatCommand(p.command)})`) + .join(' | '); + + const renderHelpText = () => ( + + {helpTextParts.map((p, i) => ( + + {i > 0 ? ' | ' : ''} + {p.label} ( + {formatCommand(p.command)}) + + ))} + + ); + + const renderTabs = () => { + const shellList = Array.from(shells.values()).filter( + (s) => s.status === 'running', + ); + + const pidInfoWidth = getCachedStringWidth( + ` (PID: ${activePid}) ${isFocused ? '(Focused)' : ''}`, + ); + + const availableWidth = + width - + TAB_DISPLAY_HORIZONTAL_PADDING - + getCachedStringWidth(helpTextStr) - + pidInfoWidth; + + let currentWidth = 0; + const tabs = []; + + for (let i = 0; i < shellList.length; i++) { + const shell = shellList[i]; + // Account for " i: " (length 4 if i < 9) and spaces (length 2) + const labelOverhead = 4 + (i + 1).toString().length; + const maxTabLabelLength = Math.max( + 1, + Math.floor(availableWidth / shellList.length) - labelOverhead, + ); + const truncatedCommand = formatShellCommandForDisplay( + shell.command, + maxTabLabelLength, + ); + const label = ` ${i + 1}: ${truncatedCommand} `; + const labelWidth = getCachedStringWidth(label); + + // If this is the only shell, we MUST show it (truncated if necessary) + // even if it exceeds availableWidth, as there are no alternatives. + if (i > 0 && currentWidth + labelWidth > availableWidth) { + break; + } + + const isActive = shell.pid === activePid; + + tabs.push( + + {label} + , + ); + currentWidth += labelWidth; + } + + if (shellList.length > tabs.length && !isListOpenProp) { + const overflowLabel = ` ... (${formatCommand(Command.TOGGLE_BACKGROUND_SHELL_LIST)}) `; + const overflowWidth = getCachedStringWidth(overflowLabel); + + // If we only have one tab, ensure we don't show the overflow if it's too cramped + // We want at least 10 chars for the overflow or we favor the first tab. + const shouldShowOverflow = + tabs.length > 1 || availableWidth - currentWidth >= overflowWidth; + + if (shouldShowOverflow) { + tabs.push( + + {overflowLabel} + , + ); + } + } + + return tabs; + }; + + const renderProcessList = () => { + const maxCommandLength = Math.max( + 0, + width - BORDER_WIDTH - CONTENT_PADDING_X * 2 - 10, + ); + + const items: Array> = Array.from( + shells.values(), + ).map((shell, index) => { + const truncatedCommand = formatShellCommandForDisplay( + shell.command, + maxCommandLength, + ); + + let label = `${index + 1}: ${truncatedCommand} (PID: ${shell.pid})`; + if (shell.status === 'exited') { + label += ` (Exit Code: ${shell.exitCode})`; + } + + return { + key: shell.pid.toString(), + value: shell.pid, + label, + }; + }); + + const initialIndex = items.findIndex((item) => item.value === activePid); + + return ( + + + + {`Select Process (${formatCommand(Command.BACKGROUND_SHELL_SELECT)} to select, ${formatCommand(Command.KILL_BACKGROUND_SHELL)} to kill, ${formatCommand(Command.BACKGROUND_SHELL_ESCAPE)} to cancel):`} + + + + = 0 ? initialIndex : 0} + onSelect={(pid) => { + setActiveBackgroundTaskPid(pid); + setIsBackgroundTaskListOpen(false); + }} + onHighlight={(pid) => setHighlightedPid(pid)} + isFocused={isFocused} + maxItemsToShow={Math.max( + 1, + height - TOTAL_OVERHEAD_HEIGHT - PROCESS_LIST_HEADER_HEIGHT, + )} + renderItem={( + item, + { isSelected: _isSelected, titleColor: _titleColor }, + ) => { + // Custom render to handle exit code coloring if needed, + // or just use default. The default RadioButtonSelect renderer + // handles standard label. + // But we want to color exit code differently? + // The previous implementation colored exit code green/red. + // Let's reimplement that. + + // We need access to shell details here. + // We can put shell details in the item or lookup. + // Lookup from shells map. + const shell = shells.get(item.value); + if (!shell) return {item.label}; + + const truncatedCommand = formatShellCommandForDisplay( + shell.command, + maxCommandLength, + ); + + return ( + + {truncatedCommand} (PID: {shell.pid}) + {shell.status === 'exited' ? ( + + {' '} + (Exit Code: {shell.exitCode}) + + ) : null} + + ); + }} + /> + + + ); + }; + + const renderFooter = () => { + const pidToDisplay = isListOpenProp + ? (highlightedPid ?? activePid) + : activePid; + if (!pidToDisplay) return null; + const logPath = ShellExecutionService.getLogFilePath(pidToDisplay); + const displayPath = shortenPath( + tildeifyPath(logPath), + width - LOG_PATH_OVERHEAD, + ); + return ( + + Log: {displayPath} + + ); + }; + + const renderOutput = () => { + const lines = typeof output === 'string' ? output.split('\n') : output; + + return ( + { + if (typeof line === 'string') { + return {line}; + } + return ( + + {line.length > 0 + ? line.map((token: AnsiToken, tokenIndex: number) => ( + + {token.text} + + )) + : null} + + ); + }} + estimatedItemHeight={() => 1} + keyExtractor={(_, index) => index.toString()} + hasFocus={isFocused} + initialScrollIndex={SCROLL_TO_ITEM_END} + /> + ); + }; + + return ( + + + + {renderTabs()} + + {' '} + (PID: {activeShell?.pid}) {isFocused ? '(Focused)' : ''} + + + {renderHelpText()} + + + {isListOpenProp ? renderProcessList() : renderOutput()} + + {renderFooter()} + + ); +}; diff --git a/packages/cli/src/ui/components/Banner.test.tsx b/packages/cli/src/ui/components/Banner.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7219cf486141c33adf2c0d89888290ef969bbb20 --- /dev/null +++ b/packages/cli/src/ui/components/Banner.test.tsx @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../test-utils/render.js'; +import { Banner } from './Banner.js'; +import { describe, it, expect } from 'vitest'; + +describe('Banner', () => { + it.each([ + ['warning mode', true, 'Warning Message'], + ['info mode', false, 'Info Message'], + ['multi-line warning', true, 'Title Line\\nBody Line 1\\nBody Line 2'], + ])('renders in %s', async (_, isWarning, text) => { + const renderResult = await renderWithProviders( + , + ); + await renderResult.waitUntilReady(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); + }); + + it('handles newlines in text', async () => { + const text = 'Line 1\\nLine 2'; + const renderResult = await renderWithProviders( + , + ); + await renderResult.waitUntilReady(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/Banner.tsx b/packages/cli/src/ui/components/Banner.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3f9777aa45aeec0fbf0292c8d5664d10a69ae775 --- /dev/null +++ b/packages/cli/src/ui/components/Banner.tsx @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { ThemedGradient } from './ThemedGradient.js'; +import { theme } from '../semantic-colors.js'; +import type { ReactNode } from 'react'; + +export function getFormattedBannerContent( + rawText: string, + isWarning: boolean, + subsequentLineColor: string, +): ReactNode { + const text = rawText.replace(/\\n/g, '\n'); + const lines = text.split('\n'); + + return lines.map((line, index) => { + if (index === 0) { + if (isWarning) { + return ( + + {line} + + ); + } + return ( + + {line} + + ); + } + + return ( + + {line} + + ); + }); +} + +interface BannerProps { + bannerText: string; + isWarning: boolean; + width: number; +} + +export const Banner = ({ bannerText, isWarning, width }: BannerProps) => { + const subsequentLineColor = theme.text.primary; + + const formattedBannerContent = getFormattedBannerContent( + bannerText, + isWarning, + subsequentLineColor, + ); + + return ( + + {formattedBannerContent} + + ); +}; diff --git a/packages/cli/src/ui/components/BubblingRegression.test.tsx b/packages/cli/src/ui/components/BubblingRegression.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5e83a6b9ebbf9091a880f9f35ed3f72494a161a7 --- /dev/null +++ b/packages/cli/src/ui/components/BubblingRegression.test.tsx @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { act } from 'react'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import { AskUserDialog } from './AskUserDialog.js'; +import { QuestionType, type Question } from '@google/gemini-cli-core'; + +describe('Key Bubbling Regression', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const choiceQuestion: Question[] = [ + { + question: 'Choice Q?', + header: 'Choice', + type: QuestionType.CHOICE, + options: [ + { label: 'Option 1', description: '' }, + { label: 'Option 2', description: '' }, + ], + multiSelect: false, + }, + ]; + + it('does not navigate when pressing "j" or "k" in a focused text input', async () => { + const { stdin, lastFrame } = await renderWithProviders( + , + { width: 120 }, + ); + + // 1. Move down to "Enter a custom value" (3rd item) + act(() => { + stdin.write('\x1b[B'); // Down arrow to Option 2 + }); + act(() => { + stdin.write('\x1b[B'); // Down arrow to Custom + }); + + await waitFor(() => { + expect(lastFrame()).toContain('Enter a custom value'); + }); + + // 2. Type "j" + act(() => { + stdin.write('j'); + }); + + await waitFor(() => { + expect(lastFrame()).toContain('j'); + // Verify we are still focusing the custom option (3rd item in list) + expect(lastFrame()).toMatch(/● 3\.\s+j/); + }); + + // 3. Type "k" + act(() => { + stdin.write('k'); + }); + + await waitFor(() => { + expect(lastFrame()).toContain('jk'); + expect(lastFrame()).toMatch(/● 3\.\s+jk/); + }); + }); +}); diff --git a/packages/cli/src/ui/components/Checklist.test.tsx b/packages/cli/src/ui/components/Checklist.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..329a560aecd566369f635eab1cfb88915ac0cb93 --- /dev/null +++ b/packages/cli/src/ui/components/Checklist.test.tsx @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { describe, it, expect } from 'vitest'; +import { Checklist } from './Checklist.js'; +import type { ChecklistItemData } from './ChecklistItem.js'; + +describe('', () => { + const items: ChecklistItemData[] = [ + { status: 'completed', label: 'Task 1' }, + { status: 'in_progress', label: 'Task 2' }, + { status: 'pending', label: 'Task 3' }, + { status: 'cancelled', label: 'Task 4' }, + ]; + + it('renders nothing when list is empty', async () => { + const { lastFrame } = await render( + , + ); + expect(lastFrame({ allowEmpty: true })).toBe(''); + }); + + it('renders nothing when collapsed and no active items', async () => { + const inactiveItems: ChecklistItemData[] = [ + { status: 'completed', label: 'Task 1' }, + { status: 'cancelled', label: 'Task 2' }, + ]; + const { lastFrame } = await render( + , + ); + expect(lastFrame({ allowEmpty: true })).toBe(''); + }); + + it('renders summary view correctly (collapsed)', async () => { + const { lastFrame } = await render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders expanded view correctly', async () => { + const { lastFrame } = await render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders summary view without in-progress item if none exists', async () => { + const pendingItems: ChecklistItemData[] = [ + { status: 'completed', label: 'Task 1' }, + { status: 'pending', label: 'Task 2' }, + ]; + const { lastFrame } = await render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/Checklist.tsx b/packages/cli/src/ui/components/Checklist.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d9fb51278c3d3c7a2a0b1a5761ad8c6405b6968c --- /dev/null +++ b/packages/cli/src/ui/components/Checklist.tsx @@ -0,0 +1,126 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useMemo } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { ChecklistItem, type ChecklistItemData } from './ChecklistItem.js'; + +export interface ChecklistProps { + title: string; + items: ChecklistItemData[]; + isExpanded: boolean; + toggleHint?: string; +} + +const ChecklistTitleDisplay: React.FC<{ + title: string; + items: ChecklistItemData[]; + toggleHint?: string; +}> = ({ title, items, toggleHint }) => { + const score = useMemo(() => { + let total = 0; + let completed = 0; + for (const item of items) { + if (item.status !== 'cancelled') { + total += 1; + if (item.status === 'completed') { + completed += 1; + } + } + } + return `${completed}/${total} completed`; + }, [items]); + + return ( + + + {title} + + + {score} + {toggleHint ? ` (${toggleHint})` : ''} + + + ); +}; + +const ChecklistListDisplay: React.FC<{ items: ChecklistItemData[] }> = ({ + items, +}) => ( + + {items.map((item, index) => ( + + ))} + +); + +export const Checklist: React.FC = ({ + title, + items, + isExpanded, + toggleHint, +}) => { + const inProgress: ChecklistItemData | null = useMemo( + () => items.find((item) => item.status === 'in_progress') || null, + [items], + ); + + const hasActiveItems = useMemo( + () => + items.some( + (item) => item.status === 'pending' || item.status === 'in_progress', + ), + [items], + ); + + if (items.length === 0 || (!isExpanded && !hasActiveItems)) { + return null; + } + + return ( + + {isExpanded ? ( + + + + + ) : ( + + + + + {inProgress && ( + + + + )} + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/ChecklistItem.test.tsx b/packages/cli/src/ui/components/ChecklistItem.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c71af523e19cf2f740690b56de2bfa0fcb50eec4 --- /dev/null +++ b/packages/cli/src/ui/components/ChecklistItem.test.tsx @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { describe, it, expect } from 'vitest'; +import { ChecklistItem, type ChecklistItemData } from './ChecklistItem.js'; +import { Box } from 'ink'; + +describe('', () => { + it.each([ + { status: 'pending', label: 'Do this' }, + { status: 'in_progress', label: 'Doing this' }, + { status: 'completed', label: 'Done this' }, + { status: 'cancelled', label: 'Skipped this' }, + { status: 'blocked', label: 'Blocked this' }, + ] as ChecklistItemData[])('renders %s item correctly', async (item) => { + const { lastFrame } = await render(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('truncates long text when wrap="truncate"', async () => { + const item: ChecklistItemData = { + status: 'in_progress', + label: + 'This is a very long text that should be truncated because the wrap prop is set to truncate', + }; + const { lastFrame } = await render( + + + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('wraps long text by default', async () => { + const item: ChecklistItemData = { + status: 'in_progress', + label: + 'This is a very long text that should wrap because the default behavior is wrapping', + }; + const { lastFrame } = await render( + + + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/ChecklistItem.tsx b/packages/cli/src/ui/components/ChecklistItem.tsx new file mode 100644 index 0000000000000000000000000000000000000000..065c79d516b5247d53dda7aabc1b464e4cf9eeb9 --- /dev/null +++ b/packages/cli/src/ui/components/ChecklistItem.tsx @@ -0,0 +1,100 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { checkExhaustive } from '@google/gemini-cli-core'; + +export type ChecklistStatus = + | 'pending' + | 'in_progress' + | 'completed' + | 'cancelled' + | 'blocked'; + +export interface ChecklistItemData { + status: ChecklistStatus; + label: string; +} + +const ChecklistStatusDisplay: React.FC<{ status: ChecklistStatus }> = ({ + status, +}) => { + switch (status) { + case 'completed': + return ( + + ✓ + + ); + case 'in_progress': + return ( + + » + + ); + case 'pending': + return ( + + ☐ + + ); + case 'cancelled': + return ( + + ✗ + + ); + case 'blocked': + return ( + + ⛔ + + ); + default: + checkExhaustive(status); + } +}; + +export interface ChecklistItemProps { + item: ChecklistItemData; + wrap?: 'truncate'; + role?: 'listitem'; +} + +export const ChecklistItem: React.FC = ({ + item, + wrap, + role: ariaRole, +}) => { + const textColor = (() => { + switch (item.status) { + case 'in_progress': + return theme.text.accent; + case 'completed': + case 'cancelled': + case 'blocked': + return theme.text.secondary; + case 'pending': + return theme.text.primary; + default: + checkExhaustive(item.status); + } + })(); + const strikethrough = item.status === 'cancelled'; + + return ( + + + + + {item.label} + + + + ); +}; diff --git a/packages/cli/src/ui/components/CliSpinner.test.tsx b/packages/cli/src/ui/components/CliSpinner.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4da6abb199dd718a090113bef7daa0f0fbc5c9d3 --- /dev/null +++ b/packages/cli/src/ui/components/CliSpinner.test.tsx @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../test-utils/render.js'; +import { createMockSettings } from '../../test-utils/settings.js'; +import { CliSpinner } from './CliSpinner.js'; +import { debugState } from '../debug.js'; +import { describe, it, expect, beforeEach } from 'vitest'; + +describe('', () => { + beforeEach(() => { + debugState.debugNumAnimatedComponents = 0; + }); + + it('should increment debugNumAnimatedComponents on mount and decrement on unmount', async () => { + expect(debugState.debugNumAnimatedComponents).toBe(0); + const { unmount } = await renderWithProviders(); + expect(debugState.debugNumAnimatedComponents).toBe(1); + unmount(); + expect(debugState.debugNumAnimatedComponents).toBe(0); + }); + + it('should not render when showSpinner is false', async () => { + const settings = createMockSettings({ ui: { showSpinner: false } }); + const { lastFrame, unmount } = await renderWithProviders(, { + settings, + }); + expect(lastFrame({ allowEmpty: true })).toBe(''); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/CliSpinner.tsx b/packages/cli/src/ui/components/CliSpinner.tsx new file mode 100644 index 0000000000000000000000000000000000000000..66cb7a0281ebfbac2e0e3ac7572b2a6c6196b4f3 --- /dev/null +++ b/packages/cli/src/ui/components/CliSpinner.tsx @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import Spinner from 'ink-spinner'; +import { type ComponentProps, useEffect } from 'react'; +import { debugState } from '../debug.js'; +import { useSettings } from '../contexts/SettingsContext.js'; + +export type SpinnerProps = ComponentProps; + +export const CliSpinner = (props: SpinnerProps) => { + const settings = useSettings(); + const shouldShow = settings.merged.ui?.showSpinner !== false; + + useEffect(() => { + if (shouldShow) { + debugState.debugNumAnimatedComponents++; + return () => { + debugState.debugNumAnimatedComponents--; + }; + } + return undefined; + }, [shouldShow]); + + if (!shouldShow) { + return null; + } + + return ; +}; diff --git a/packages/cli/src/ui/components/ColorsDisplay.test.tsx b/packages/cli/src/ui/components/ColorsDisplay.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d934831c0e4da29903f4efef8a451a6feddbbf28 --- /dev/null +++ b/packages/cli/src/ui/components/ColorsDisplay.test.tsx @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../test-utils/render.js'; +import { ColorsDisplay } from './ColorsDisplay.js'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { themeManager } from '../themes/theme-manager.js'; +import type { Theme, ColorsTheme } from '../themes/theme.js'; +import type { SemanticColors } from '../themes/semantic-tokens.js'; + +describe('ColorsDisplay', () => { + beforeEach(() => { + vi.spyOn(themeManager, 'getSemanticColors').mockReturnValue({ + text: { + primary: '#ffffff', + secondary: '#cccccc', + link: '#0000ff', + accent: '#ff00ff', + response: '#ffffff', + }, + background: { + primary: '#000000', + message: '#111111', + input: '#222222', + focus: '#333333', + diff: { + added: '#003300', + removed: '#330000', + }, + }, + border: { + default: '#555555', + }, + ui: { + comment: '#666666', + symbol: '#cccccc', + active: '#0000ff', + dark: '#333333', + focus: '#0000ff', + gradient: undefined, + }, + status: { + error: '#ff0000', + success: '#00ff00', + warning: '#ffff00', + }, + }); + + vi.spyOn(themeManager, 'getActiveTheme').mockReturnValue({ + name: 'Test Theme', + type: 'dark', + colors: {} as unknown as ColorsTheme, + semanticColors: { + text: { + primary: '#ffffff', + secondary: '#cccccc', + link: '#0000ff', + accent: '#ff00ff', + response: '#ffffff', + }, + background: { + primary: '#000000', + message: '#111111', + input: '#222222', + diff: { + added: '#003300', + removed: '#330000', + }, + }, + border: { + default: '#555555', + }, + ui: { + comment: '#666666', + symbol: '#cccccc', + active: '#0000ff', + dark: '#333333', + focus: '#0000ff', + gradient: undefined, + }, + status: { + error: '#ff0000', + success: '#00ff00', + warning: '#ffff00', + }, + } as unknown as SemanticColors, + } as unknown as Theme); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders correctly', async () => { + const mockTheme = themeManager.getActiveTheme(); + const { lastFrame, unmount } = await renderWithProviders( + , + ); + const output = lastFrame(); + + // Check for title and description + expect(output).toContain('How do colors get applied?'); + expect(output).toContain('Hex:'); + + // Check for some color names and values expect(output).toContain('text.primary'); + expect(output).toContain('#ffffff'); + expect(output).toContain('background.diff.added'); + expect(output).toContain('#003300'); + expect(output).toContain('border.default'); + expect(output).toContain('#555555'); + + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/ColorsDisplay.tsx b/packages/cli/src/ui/components/ColorsDisplay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..96b98bf5407cf8a6315855dfd3cd5a5eafc959df --- /dev/null +++ b/packages/cli/src/ui/components/ColorsDisplay.tsx @@ -0,0 +1,277 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import Gradient from 'ink-gradient'; +import { theme } from '../semantic-colors.js'; +import type { Theme } from '../themes/theme.js'; + +interface StandardColorRow { + type: 'standard'; + name: string; + value: string; +} + +interface GradientColorRow { + type: 'gradient'; + name: string; + value: string[]; +} + +interface BackgroundColorRow { + type: 'background'; + name: string; + value: string; +} + +type ColorRow = StandardColorRow | GradientColorRow | BackgroundColorRow; + +const VALUE_COLUMN_WIDTH = 10; + +const COLOR_DESCRIPTIONS: Record = { + 'text.primary': 'Primary text color (uses terminal default if blank)', + 'text.secondary': 'Secondary/dimmed text color', + 'text.link': 'Hyperlink and highlighting color', + 'text.accent': 'Accent color for emphasis', + 'text.response': + 'Color for model response text (uses terminal default if blank)', + 'background.primary': 'Main terminal background color', + 'background.message': 'Subtle background for message blocks', + 'background.input': 'Background for the input prompt', + 'background.focus': 'Background highlight for selected/focused items', + 'background.diff.added': 'Background for added lines in diffs', + 'background.diff.removed': 'Background for removed lines in diffs', + 'border.default': 'Standard border color', + 'ui.comment': 'Color for code comments and metadata', + 'ui.symbol': 'Color for technical symbols and UI icons', + 'ui.active': 'Border color for active or running elements', + 'ui.dark': 'Deeply dimmed color for subtle UI elements', + 'ui.focus': + 'Color for focused elements (e.g. selected menu items, focused borders)', + 'status.error': 'Color for error messages and critical status', + 'status.success': 'Color for success messages and positive status', + 'status.warning': 'Color for warnings and cautionary status', +}; + +interface ColorsDisplayProps { + activeTheme: Theme; +} + +/** + * Determines a contrasting text color (black or white) based on the background color's luminance. + */ +function getContrastingTextColor(hex: string): string { + if (!hex || !hex.startsWith('#') || hex.length < 7) { + // Fallback for invalid hex codes or named colors + return theme.text.primary; + } + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + // Using YIQ formula to determine luminance + const yiq = (r * 299 + g * 587 + b * 114) / 1000; + return yiq >= 128 ? '#000000' : '#FFFFFF'; +} + +export const ColorsDisplay: React.FC = ({ + activeTheme, +}) => { + const semanticColors = activeTheme.semanticColors; + + const backgroundRows: BackgroundColorRow[] = []; + const standardRows: StandardColorRow[] = []; + let gradientRow: GradientColorRow | null = null; + + if (semanticColors.ui.gradient && semanticColors.ui.gradient.length > 0) { + gradientRow = { + type: 'gradient', + name: 'ui.gradient', + value: semanticColors.ui.gradient, + }; + } + + /** + * Recursively flattens the semanticColors object. + */ + const flattenColors = (obj: object, path: string = '') => { + for (const [key, value] of Object.entries(obj)) { + if (value === undefined || value === null) continue; + const newPath = path ? `${path}.${key}` : key; + + if (key === 'gradient' && Array.isArray(value)) { + // Gradient handled separately + continue; + } + + if (typeof value === 'object' && !Array.isArray(value)) { + flattenColors(value, newPath); + } else if (typeof value === 'string') { + if (newPath.startsWith('background.')) { + backgroundRows.push({ + type: 'background', + name: newPath, + value, + }); + } else { + standardRows.push({ + type: 'standard', + name: newPath, + value, + }); + } + } + } + }; + + flattenColors(semanticColors); + + // Final order: Backgrounds first, then Standards, then Gradient + const allRows: ColorRow[] = [ + ...backgroundRows, + ...standardRows, + ...(gradientRow ? [gradientRow] : []), + ]; + + return ( + + + + DEVELOPER TOOLS (Not visible to users) + + + + How do colors get applied? + + + + • Hex: Rendered exactly by modern terminals. Not + overridden by app themes. + + + • Blank: Uses your terminal's default + foreground/background. + + + • Compatibility: On older terminals, hex is + approximated to the nearest ANSI color. + + + • ANSI Names: 'red', + 'green', etc. are mapped to your terminal app's + palette. + + + + + + {/* Header */} + + + + Value + + + + + Name + + + + + {/* All Rows */} + + {allRows.map((row) => { + if (row.type === 'standard') return renderStandardRow(row); + if (row.type === 'gradient') return renderGradientRow(row); + if (row.type === 'background') return renderBackgroundRow(row); + return null; + })} + + + ); +}; + +function renderStandardRow({ name, value }: StandardColorRow) { + const isHex = value.startsWith('#'); + const displayColor = isHex ? value : theme.text.primary; + const description = COLOR_DESCRIPTIONS[name] || ''; + + return ( + + + {value || '(blank)'} + + + + {name} + + + {description} + + + + ); +} + +function renderGradientRow({ name, value }: GradientColorRow) { + const description = COLOR_DESCRIPTIONS[name] || ''; + + return ( + + + {value.map((c, i) => ( + + {c} + + ))} + + + + + {name} + + + + {description} + + + + ); +} + +function renderBackgroundRow({ name, value }: BackgroundColorRow) { + const description = COLOR_DESCRIPTIONS[name] || ''; + + return ( + + + + {value || 'default'} + + + + + {name} + + + {description} + + + + ); +} diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8a7ca134a89df2b3bf416e788f94d9bfefc97fd1 --- /dev/null +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -0,0 +1,1140 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'; +import { render } from '../../test-utils/render.js'; +import { act, useEffect } from 'react'; +import { Box, Text } from 'ink'; +import { Composer } from './Composer.js'; +import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; +import { + UIActionsContext, + type UIActions, +} from '../contexts/UIActionsContext.js'; +import { ConfigContext } from '../contexts/ConfigContext.js'; +import { SettingsContext } from '../contexts/SettingsContext.js'; +import { createMockSettings } from '../../test-utils/settings.js'; +import { + ApprovalMode, + tokenLimit, + CoreToolCallStatus, +} from '@google/gemini-cli-core'; +import type { Config } from '@google/gemini-cli-core'; +import { StreamingState } from '../types.js'; +import { TransientMessageType } from '../../utils/events.js'; +import type { LoadedSettings } from '../../config/settings.js'; +import type { SessionMetrics } from '../contexts/SessionContext.js'; +import type { TextBuffer } from './shared/text-buffer.js'; + +// Mock VimModeContext hook +vi.mock('../contexts/VimModeContext.js', () => ({ + useVimMode: vi.fn(() => ({ + vimEnabled: false, + vimMode: 'INSERT', + })), +})); + +vi.mock('../hooks/useTerminalSize.js', () => ({ + useTerminalSize: vi.fn(() => ({ + columns: 100, + rows: 24, + })), +})); + +const composerTestControls = vi.hoisted(() => ({ + suggestionsVisible: false, + isAlternateBuffer: false, +})); + +// Mock child components +vi.mock('./LoadingIndicator.js', () => ({ + LoadingIndicator: ({ + thought, + thoughtLabel, + }: { + thought?: { subject?: string } | string; + thoughtLabel?: string; + }) => { + const fallbackText = + typeof thought === 'string' ? thought : thought?.subject; + const text = thoughtLabel ?? fallbackText; + return LoadingIndicator{text ? `: ${text}` : ''}; + }, +})); + +vi.mock('./StatusDisplay.js', () => ({ + StatusDisplay: ({ hideContextSummary }: { hideContextSummary: boolean }) => ( + StatusDisplay{hideContextSummary ? ' (hidden summary)' : ''} + ), +})); + +vi.mock('./ContextSummaryDisplay.js', () => ({ + ContextSummaryDisplay: () => ContextSummaryDisplay, +})); + +vi.mock('./HookStatusDisplay.js', () => ({ + HookStatusDisplay: () => HookStatusDisplay, +})); + +vi.mock('./ApprovalModeIndicator.js', () => ({ + ApprovalModeIndicator: ({ approvalMode }: { approvalMode: ApprovalMode }) => ( + ApprovalModeIndicator: {approvalMode} + ), +})); + +vi.mock('./ShellModeIndicator.js', () => ({ + ShellModeIndicator: () => ShellModeIndicator, +})); + +vi.mock('./ShortcutsHelp.js', () => ({ + ShortcutsHelp: () => ShortcutsHelp, +})); + +vi.mock('./DetailedMessagesDisplay.js', () => ({ + DetailedMessagesDisplay: () => DetailedMessagesDisplay, +})); + +vi.mock('./InputPrompt.js', () => ({ + InputPrompt: ({ + placeholder, + onSuggestionsVisibilityChange, + }: { + placeholder?: string; + onSuggestionsVisibilityChange?: (visible: boolean) => void; + }) => { + useEffect(() => { + onSuggestionsVisibilityChange?.(composerTestControls.suggestionsVisible); + }, [onSuggestionsVisibilityChange]); + + return InputPrompt: {placeholder}; + }, + calculatePromptWidths: vi.fn(() => ({ + inputWidth: 80, + suggestionsWidth: 40, + containerWidth: 84, + })), +})); + +vi.mock('../hooks/useAlternateBuffer.js', () => ({ + useAlternateBuffer: () => composerTestControls.isAlternateBuffer, +})); + +vi.mock('./Footer.js', () => ({ + Footer: () => Footer, +})); + +vi.mock('./ShowMoreLines.js', () => ({ + ShowMoreLines: () => ShowMoreLines, +})); + +vi.mock('./QueuedMessageDisplay.js', () => ({ + QueuedMessageDisplay: ({ messageQueue }: { messageQueue: string[] }) => { + if (messageQueue.length === 0) { + return null; + } + return ( + <> + {messageQueue.map((message, index) => ( + {message} + ))} + + ); + }, +})); + +// Mock contexts +vi.mock('../contexts/OverflowContext.js', () => ({ + OverflowProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +// Create mock context providers +const createMockUIState = (overrides: Partial = {}): UIState => + ({ + streamingState: StreamingState.Idle, + isConfigInitialized: true, + contextFileNames: [], + showApprovalModeIndicator: ApprovalMode.DEFAULT, + messageQueue: [], + showErrorDetails: false, + constrainHeight: false, + isInputActive: true, + buffer: { text: '' }, + inputWidth: 80, + suggestionsWidth: 40, + userMessages: [], + slashCommands: [], + commandContext: null, + shellModeActive: false, + isFocused: true, + thought: '', + currentLoadingPhrase: '', + currentTip: '', + currentWittyPhrase: '', + elapsedTime: 0, + ctrlCPressedOnce: false, + ctrlDPressedOnce: false, + showEscapePrompt: false, + shortcutsHelpVisible: false, + cleanUiDetailsVisible: true, + ideContextState: null, + geminiMdFileCount: 0, + renderMarkdown: true, + history: [], + sessionStats: { + sessionId: 'test-session', + sessionStartTime: new Date(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + metrics: {} as any, + lastPromptTokenCount: 0, + promptCount: 0, + }, + branchName: 'main', + debugMessage: '', + corgiMode: false, + errorCount: 0, + nightly: false, + isTrustedFolder: true, + activeHooks: [], + isBackgroundTaskVisible: false, + embeddedShellFocused: false, + showIsExpandableHint: false, + ...overrides, + }) as UIState; + +const createMockUIActions = (): UIActions => + ({ + handleFinalSubmit: vi.fn(), + handleClearScreen: vi.fn(), + setShellModeActive: vi.fn(), + setCleanUiDetailsVisible: vi.fn(), + toggleCleanUiDetailsVisible: vi.fn(), + revealCleanUiDetailsTemporarily: vi.fn(), + onEscapePromptChange: vi.fn(), + vimHandleInput: vi.fn(), + setShortcutsHelpVisible: vi.fn(), + }) as Partial as UIActions; + +const createMockConfig = (overrides = {}): Config => + ({ + getModel: vi.fn(() => 'gemini-1.5-pro'), + getTargetDir: vi.fn(() => '/test/dir'), + getDebugMode: vi.fn(() => false), + getAccessibility: vi.fn(() => ({})), + getMcpServers: vi.fn(() => ({})), + isPlanEnabled: vi.fn(() => true), + getToolRegistry: () => ({ + getTool: vi.fn(), + }), + getSkillManager: () => ({ + getSkills: () => [], + getDisplayableSkills: () => [], + }), + getMcpClientManager: () => ({ + getMcpServers: () => ({}), + getBlockedMcpServers: () => [], + }), + ...overrides, + }) as unknown as Config; + +import { QuotaContext, type QuotaState } from '../contexts/QuotaContext.js'; +import { InputContext, type InputState } from '../contexts/InputContext.js'; + +const renderComposer = async ( + uiState: UIState, + settings = createMockSettings({ ui: {} }), + config = createMockConfig(), + uiActions = createMockUIActions(), + inputStateOverrides: Partial = {}, + quotaStateOverrides: Partial = {}, +) => { + const inputState = { + buffer: { text: '' } as unknown as TextBuffer, + userMessages: [], + shellModeActive: false, + showEscapePrompt: false, + copyModeEnabled: false, + inputWidth: 80, + suggestionsWidth: 40, + ...(uiState as unknown as Partial), + ...inputStateOverrides, + }; + + const quotaState: QuotaState = { + userTier: undefined, + stats: undefined, + proQuotaRequest: null, + validationRequest: null, + overageMenuRequest: null, + emptyWalletRequest: null, + ...quotaStateOverrides, + }; + + const result = await render( + + + + + + + + + + + + + , + ); + + // Wait for shortcuts hint debounce if using fake timers + if (vi.isFakeTimers()) { + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + } + + return result; +}; + +describe('Composer', () => { + beforeEach(() => { + vi.useFakeTimers(); + composerTestControls.suggestionsVisible = false; + composerTestControls.isAlternateBuffer = false; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('Footer Display Settings', () => { + it('renders Footer by default when hideFooter is false', async () => { + const uiState = createMockUIState(); + const settings = createMockSettings({ ui: { hideFooter: false } }); + + const { lastFrame } = await renderComposer(uiState, settings); + + expect(lastFrame()).toContain('Footer'); + }); + + it('does NOT render Footer when hideFooter is true', async () => { + const uiState = createMockUIState(); + const settings = createMockSettings({ ui: { hideFooter: true } }); + + const { lastFrame } = await renderComposer(uiState, settings); + + // Check for content that only appears IN the Footer component itself + expect(lastFrame()).not.toContain('[NORMAL]'); // Vim mode indicator + expect(lastFrame()).not.toContain('(main'); // Branch name with parentheses + }); + + it('passes correct props to Footer including vim mode when enabled', async () => { + const uiState = createMockUIState({ + branchName: 'feature-branch', + corgiMode: true, + errorCount: 2, + sessionStats: { + sessionId: 'test-session', + sessionStartTime: new Date(), + metrics: { + models: {}, + tools: {}, + files: {}, + } as SessionMetrics, + lastPromptTokenCount: 150, + promptCount: 5, + }, + }); + const config = createMockConfig({ + getModel: vi.fn(() => 'gemini-1.5-flash'), + getTargetDir: vi.fn(() => '/project/path'), + getDebugMode: vi.fn(() => true), + }); + const settings = createMockSettings({ + ui: { + hideFooter: false, + showMemoryUsage: true, + }, + }); + // Mock vim mode for this test + const { useVimMode } = await import('../contexts/VimModeContext.js'); + vi.mocked(useVimMode).mockReturnValueOnce({ + vimEnabled: true, + vimMode: 'INSERT', + toggleVimEnabled: vi.fn(), + setVimMode: vi.fn(), + } as unknown as ReturnType); + + const { lastFrame } = await renderComposer(uiState, settings, config); + + expect(lastFrame()).toContain('Footer'); + // Footer should be rendered with all the state passed through + }); + }); + + describe('Loading Indicator', () => { + it('renders LoadingIndicator with thought when streaming', async () => { + const uiState = createMockUIState({ + streamingState: StreamingState.Responding, + thought: { + subject: 'Processing', + description: 'Processing your request...', + }, + currentLoadingPhrase: 'Analyzing', + elapsedTime: 1500, + }); + + const { lastFrame } = await renderComposer(uiState); + + const output = lastFrame(); + expect(output).toContain('LoadingIndicator: Processing'); + }); + + it('renders generic thinking text in loading indicator when full inline thinking is enabled', async () => { + const uiState = createMockUIState({ + streamingState: StreamingState.Responding, + thought: { + subject: 'Thinking about code', + description: 'Full text is already in history', + }, + }); + const settings = createMockSettings({ + ui: { inlineThinkingMode: 'full' }, + }); + + const { lastFrame } = await renderComposer(uiState, settings); + + const output = lastFrame(); + // In Refreshed UX, we don't force 'Thinking...' label in renderStatusNode + // It uses the subject directly + expect(output).toContain('LoadingIndicator: Thinking about code'); + }); + + it('shows shortcuts hint while loading', async () => { + const uiState = createMockUIState({ + streamingState: StreamingState.Responding, + elapsedTime: 1, + cleanUiDetailsVisible: false, + }); + + const { lastFrame } = await renderComposer(uiState); + + const output = lastFrame(); + expect(output).toContain('LoadingIndicator'); + expect(output).toContain('press tab twice for more'); + expect(output).not.toContain('? for shortcuts'); + }); + + it('renders LoadingIndicator with thought when loadingPhrases is off', async () => { + const uiState = createMockUIState({ + streamingState: StreamingState.Responding, + thought: { subject: 'Hidden', description: 'Should not show' }, + }); + const settings = createMockSettings({ + ui: { loadingPhrases: 'off' }, + }); + + const { lastFrame } = await renderComposer(uiState, settings); + + const output = lastFrame(); + expect(output).toContain('LoadingIndicator'); + expect(output).toContain('LoadingIndicator: Hidden'); + }); + + it('does not render LoadingIndicator when waiting for confirmation', async () => { + const uiState = createMockUIState({ + streamingState: StreamingState.WaitingForConfirmation, + thought: { + subject: 'Confirmation', + description: 'Should not show during confirmation', + }, + }); + + const { lastFrame } = await renderComposer(uiState); + + const output = lastFrame(); + expect(output).not.toContain('LoadingIndicator'); + }); + + it('does not render LoadingIndicator when a tool confirmation is pending', async () => { + const uiState = createMockUIState({ + streamingState: StreamingState.Responding, + pendingHistoryItems: [ + { + type: 'tool_group', + tools: [ + { + callId: 'call-1', + name: 'edit', + description: 'edit file', + status: CoreToolCallStatus.AwaitingApproval, + resultDisplay: undefined, + confirmationDetails: undefined, + }, + ], + }, + ], + }); + + const { lastFrame } = await renderComposer(uiState); + + const output = lastFrame({ allowEmpty: true }); + expect(output).toBe(''); + }); + + it('renders LoadingIndicator when embedded shell is focused but background shell is visible', async () => { + const uiState = createMockUIState({ + streamingState: StreamingState.Responding, + embeddedShellFocused: true, + isBackgroundTaskVisible: true, + }); + + const { lastFrame } = await renderComposer(uiState); + + const output = lastFrame(); + expect(output).toContain('LoadingIndicator'); + }); + + it('renders both LoadingIndicator and ApprovalModeIndicator when streaming in full UI mode', async () => { + const uiState = createMockUIState({ + streamingState: StreamingState.Responding, + thought: { + subject: 'Thinking', + description: '', + }, + showApprovalModeIndicator: ApprovalMode.PLAN, + }); + + const { lastFrame } = await renderComposer(uiState); + + const output = lastFrame(); + expect(output).toContain('LoadingIndicator: Thinking'); + expect(output).toContain('ApprovalModeIndicator'); + }); + + it('does NOT render LoadingIndicator when embedded shell is focused and background shell is NOT visible', async () => { + const uiState = createMockUIState({ + streamingState: StreamingState.Responding, + embeddedShellFocused: true, + isBackgroundTaskVisible: false, + }); + + const { lastFrame } = await renderComposer(uiState); + + const output = lastFrame(); + expect(output).not.toContain('LoadingIndicator'); + }); + }); + + describe('Message Queue Display', () => { + it('displays queued messages when present', async () => { + const uiState = createMockUIState({ + messageQueue: [ + 'First queued message', + 'Second queued message', + 'Third queued message', + ], + }); + + const { lastFrame } = await renderComposer(uiState); + + const output = lastFrame(); + expect(output).toContain('First queued message'); + expect(output).toContain('Second queued message'); + expect(output).toContain('Third queued message'); + }); + + it('renders QueuedMessageDisplay with empty message queue', async () => { + const uiState = createMockUIState({ + messageQueue: [], + }); + + const { lastFrame } = await renderComposer(uiState); + + // The component should render but return null for empty queue + // This test verifies that the component receives the correct prop + const output = lastFrame(); + expect(output).toContain('InputPrompt'); // Verify basic Composer rendering + }); + }); + + describe('Context and Status Display', () => { + it('shows StatusDisplay and ApprovalModeIndicator in normal state', async () => { + const uiState = createMockUIState({ + ctrlCPressedOnce: false, + ctrlDPressedOnce: false, + }); + + const { lastFrame } = await renderComposer(uiState); + + const output = lastFrame(); + expect(output).toContain('StatusDisplay'); + expect(output).toContain('ApprovalModeIndicator'); + expect(output).not.toContain('ToastDisplay'); + }); + + it('shows ToastDisplay and hides ApprovalModeIndicator when a toast is present', async () => { + const uiState = createMockUIState({ + ctrlCPressedOnce: true, + }); + + const { lastFrame } = await renderComposer(uiState); + + const output = lastFrame(); + expect(output).toContain('Press Ctrl+C again to exit.'); + // In Refreshed UX, Row 1 shows toast, and Row 2 shows ApprovalModeIndicator/StatusDisplay + // They are no longer mutually exclusive. + expect(output).toContain('ApprovalModeIndicator'); + expect(output).toContain('StatusDisplay'); + }); + + it('shows ToastDisplay for other toast types', async () => { + const uiState = createMockUIState({ + transientMessage: { + text: 'Warning', + type: TransientMessageType.Warning, + }, + }); + + const { lastFrame } = await renderComposer(uiState); + + const output = lastFrame(); + expect(output).toContain('Warning'); + expect(output).toContain('ApprovalModeIndicator'); + }); + }); + + describe('Input and Indicators', () => { + it('hides non-essential UI details in clean mode', async () => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + }); + const settings = createMockSettings({ + ui: { showShortcutsHint: false }, + }); + + const { lastFrame } = await renderComposer(uiState, settings); + + const output = lastFrame(); + expect(output).not.toContain('press tab twice for more'); + expect(output).not.toContain('? for shortcuts'); + expect(output).toContain('InputPrompt'); + expect(output).not.toContain('Footer'); + }); + + it('renders InputPrompt when input is active', async () => { + const uiState = createMockUIState({ + isInputActive: true, + }); + + const { lastFrame } = await renderComposer(uiState); + + expect(lastFrame()).toContain('InputPrompt'); + }); + + it('does not render InputPrompt when input is inactive', async () => { + const uiState = createMockUIState({ + isInputActive: false, + }); + + const { lastFrame } = await renderComposer(uiState); + + expect(lastFrame()).not.toContain('InputPrompt'); + }); + + it.each([ + [ApprovalMode.DEFAULT], + [ApprovalMode.AUTO_EDIT], + [ApprovalMode.PLAN], + [ApprovalMode.YOLO], + ])( + 'shows ApprovalModeIndicator when approval mode is %s and shell mode is inactive', + async (mode) => { + const uiState = createMockUIState({ + showApprovalModeIndicator: mode, + }); + + const { lastFrame } = await renderComposer(uiState); + + expect(lastFrame()).toMatch(/ApprovalModeIndic[\s\S]*ator/); + }, + ); + + it('shows ShellModeIndicator when shell mode is active', async () => { + const uiState = createMockUIState(); + + const { lastFrame } = await renderComposer( + uiState, + undefined, + undefined, + undefined, + { shellModeActive: true }, + ); + + expect(lastFrame()).toMatch(/ShellModeIndic[\s\S]*tor/); + }); + + it('shows RawMarkdownIndicator when renderMarkdown is false', async () => { + const uiState = createMockUIState({ + renderMarkdown: false, + }); + + const { lastFrame } = await renderComposer(uiState); + + expect(lastFrame()).toContain('raw markdown mode'); + }); + + it('does not show RawMarkdownIndicator when renderMarkdown is true', async () => { + const uiState = createMockUIState({ + renderMarkdown: true, + }); + + const { lastFrame } = await renderComposer(uiState); + + expect(lastFrame()).not.toContain('raw markdown mode'); + }); + + it.each([ + { mode: ApprovalMode.YOLO, label: '● YOLO' }, + { mode: ApprovalMode.PLAN, label: '● plan' }, + { + mode: ApprovalMode.AUTO_EDIT, + label: '● auto edit', + }, + ])( + 'shows minimal mode badge "$mode" when clean UI details are hidden', + async ({ mode, label }) => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + showApprovalModeIndicator: mode, + }); + + const { lastFrame } = await renderComposer(uiState); + expect(lastFrame()).toContain(label); + }, + ); + + it('hides minimal mode badge while loading in clean mode', async () => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + streamingState: StreamingState.Responding, + elapsedTime: 1, + showApprovalModeIndicator: ApprovalMode.PLAN, + }); + + const { lastFrame } = await renderComposer(uiState); + const output = lastFrame(); + expect(output).toContain('LoadingIndicator'); + expect(output).not.toContain('plan'); + expect(output).toContain('press tab twice for more'); + expect(output).not.toContain('? for shortcuts'); + }); + + it('hides minimal mode badge while action-required state is active', async () => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + showApprovalModeIndicator: ApprovalMode.PLAN, + customDialog: ( + + Prompt + + ), + }); + + const { lastFrame } = await renderComposer(uiState); + expect(lastFrame({ allowEmpty: true })).toBe(''); + }); + + it('shows Esc rewind prompt in minimal mode without showing full UI', async () => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + history: [{ id: 1, type: 'user', text: 'msg' }], + }); + + const { lastFrame } = await renderComposer( + uiState, + undefined, + undefined, + undefined, + { showEscapePrompt: true }, + ); + const output = lastFrame(); + expect(output).toContain('Press Esc again to rewind.'); + expect(output).not.toContain('ContextSummaryDisplay'); + }); + + it('shows context usage bleed-through when over 60%', async () => { + const model = 'gemini-2.5-pro'; + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + currentModel: model, + sessionStats: { + sessionId: 'test-session', + sessionStartTime: new Date(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + metrics: {} as any, + lastPromptTokenCount: Math.floor(tokenLimit(model) * 0.7), + promptCount: 0, + }, + }); + const settings = createMockSettings({ + ui: { + footer: { hideContextPercentage: false }, + }, + }); + + const { lastFrame } = await renderComposer(uiState, settings); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + + // StatusDisplay (which contains ContextUsageDisplay) should bleed through in minimal mode + expect(lastFrame()).toContain('StatusDisplay'); + expect(lastFrame()).toContain('70% used'); + }); + }); + + describe('Error Details Display', () => { + it('shows DetailedMessagesDisplay when showErrorDetails is true', async () => { + const uiState = createMockUIState({ + showErrorDetails: true, + }); + + const { lastFrame } = await renderComposer(uiState); + + expect(lastFrame()).toContain('DetailedMessagesDisplay'); + expect(lastFrame()).toContain('ShowMoreLines'); + }); + + it('does not show error details when showErrorDetails is false', async () => { + const uiState = createMockUIState({ + showErrorDetails: false, + }); + + const { lastFrame } = await renderComposer(uiState); + + expect(lastFrame()).not.toContain('DetailedMessagesDisplay'); + }); + }); + + describe('Vim Mode Placeholders', () => { + it('shows correct placeholder in INSERT mode', async () => { + const uiState = createMockUIState({ isInputActive: true }); + const { useVimMode } = await import('../contexts/VimModeContext.js'); + vi.mocked(useVimMode).mockReturnValue({ + vimEnabled: true, + vimMode: 'INSERT', + toggleVimEnabled: vi.fn(), + setVimMode: vi.fn(), + }); + + const { lastFrame } = await renderComposer(uiState); + + expect(lastFrame()).toContain( + "InputPrompt: Press 'Esc' for NORMAL mode.", + ); + }); + + it('shows correct placeholder in NORMAL mode', async () => { + const uiState = createMockUIState({ isInputActive: true }); + const { useVimMode } = await import('../contexts/VimModeContext.js'); + vi.mocked(useVimMode).mockReturnValue({ + vimEnabled: true, + vimMode: 'NORMAL', + toggleVimEnabled: vi.fn(), + setVimMode: vi.fn(), + }); + + const { lastFrame } = await renderComposer(uiState); + + expect(lastFrame()).toContain( + "InputPrompt: Press 'i' for INSERT mode.", + ); + }); + }); + + describe('Shortcuts Hint', () => { + it('restores shortcuts hint after 200ms debounce when buffer is empty', async () => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + }); + + const { lastFrame } = await renderComposer( + uiState, + undefined, + undefined, + undefined, + { buffer: { text: '' } as unknown as TextBuffer }, + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + + expect(lastFrame({ allowEmpty: true })).toContain( + 'press tab twice for more', + ); + }); + + it('hides shortcuts hint when text is typed in buffer', async () => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + }); + + const { lastFrame } = await renderComposer( + uiState, + undefined, + undefined, + undefined, + { buffer: { text: 'hello' } as unknown as TextBuffer }, + ); + + expect(lastFrame()).not.toContain('press tab twice for more'); + expect(lastFrame()).not.toContain('? for shortcuts'); + }); + + it('hides shortcuts hint when showShortcutsHint setting is false', async () => { + const uiState = createMockUIState(); + const settings = createMockSettings({ + ui: { + showShortcutsHint: false, + }, + }); + + const { lastFrame } = await renderComposer(uiState, settings); + + expect(lastFrame()).not.toContain('? for shortcuts'); + }); + + it('hides shortcuts hint when a action is required (e.g. dialog is open)', async () => { + const uiState = createMockUIState({ + customDialog: ( + + Test Dialog + Test Content + + ), + }); + + const { lastFrame, unmount } = await renderComposer(uiState); + + expect(lastFrame({ allowEmpty: true })).toBe(''); + unmount(); + }); + + it('keeps shortcuts hint visible when no action is required', async () => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + }); + + const { lastFrame } = await renderComposer(uiState); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + + expect(lastFrame()).toContain('press tab twice for more'); + }); + + it('shows shortcuts hint when full UI details are visible', async () => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: true, + }); + + const { lastFrame } = await renderComposer(uiState); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + + // In Refreshed UX, shortcuts hint is in the top multipurpose status row + expect(lastFrame()).toContain('? for shortcuts'); + }); + + it('shows shortcuts hint while loading when full UI details are visible', async () => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: true, + streamingState: StreamingState.Responding, + }); + + const { lastFrame } = await renderComposer(uiState); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + + // In experimental layout, status row is visible during loading + expect(lastFrame()).toContain('LoadingIndicator'); + expect(lastFrame()).toContain('? for shortcuts'); + expect(lastFrame()).not.toContain('press tab twice for more'); + }); + + it('shows shortcuts hint while loading in minimal mode', async () => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + streamingState: StreamingState.Responding, + elapsedTime: 1, + }); + + const { lastFrame } = await renderComposer(uiState); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + + // In experimental layout, status row is visible in clean mode while busy + expect(lastFrame()).toContain('LoadingIndicator'); + expect(lastFrame()).toContain('press tab twice for more'); + expect(lastFrame()).not.toContain('? for shortcuts'); + }); + + it('shows shortcuts help in minimal mode when toggled on', async () => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + shortcutsHelpVisible: true, + }); + + const { lastFrame } = await renderComposer(uiState); + + expect(lastFrame()).toContain('ShortcutsHelp'); + }); + + it('hides shortcuts hint when suggestions are visible above input in alternate buffer', async () => { + composerTestControls.isAlternateBuffer = true; + composerTestControls.suggestionsVisible = true; + + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + showApprovalModeIndicator: ApprovalMode.PLAN, + }); + + const { lastFrame } = await renderComposer(uiState); + + expect(lastFrame()).not.toContain('press tab twice for more'); + expect(lastFrame()).not.toContain('? for shortcuts'); + expect(lastFrame()).not.toContain('plan'); + }); + + it('hides approval mode indicator when suggestions are visible above input in alternate buffer', async () => { + composerTestControls.isAlternateBuffer = true; + composerTestControls.suggestionsVisible = true; + + const uiState = createMockUIState({ + cleanUiDetailsVisible: true, + showApprovalModeIndicator: ApprovalMode.YOLO, + }); + + const { lastFrame } = await renderComposer(uiState); + + expect(lastFrame()).not.toContain('ApprovalModeIndicator'); + }); + + it('keeps shortcuts hint when suggestions are visible below input in regular buffer', async () => { + composerTestControls.isAlternateBuffer = false; + composerTestControls.suggestionsVisible = true; + + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + }); + + const { lastFrame } = await renderComposer(uiState); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + + // In Refreshed UX, shortcuts hint is in the top status row and doesn't collide with suggestions below + expect(lastFrame()).toContain('press tab twice for more'); + }); + }); + + describe('Shortcuts Help', () => { + it('shows shortcuts help in passive state', async () => { + const uiState = createMockUIState({ + shortcutsHelpVisible: true, + streamingState: StreamingState.Idle, + }); + + const { lastFrame, unmount } = await renderComposer(uiState); + + expect(lastFrame()).toContain('ShortcutsHelp'); + unmount(); + }); + + it('hides shortcuts help while streaming', async () => { + const uiState = createMockUIState({ + shortcutsHelpVisible: true, + streamingState: StreamingState.Responding, + }); + + const { lastFrame, unmount } = await renderComposer(uiState); + + expect(lastFrame()).not.toContain('ShortcutsHelp'); + unmount(); + }); + it('hides shortcuts help when action is required', async () => { + const uiState = createMockUIState({ + shortcutsHelpVisible: true, + customDialog: ( + + Test Dialog + + ), + }); + + const { lastFrame, unmount } = await renderComposer(uiState); + + expect(lastFrame({ allowEmpty: true })).toBe(''); + unmount(); + }); + }); + describe('Snapshots', () => { + it('matches snapshot in idle state', async () => { + const uiState = createMockUIState(); + const { lastFrame } = await renderComposer(uiState); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('matches snapshot while streaming', async () => { + const uiState = createMockUIState({ + streamingState: StreamingState.Responding, + thought: { + subject: 'Thinking', + description: 'Thinking about the meaning of life...', + }, + }); + const { lastFrame } = await renderComposer(uiState); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('matches snapshot in narrow view', async () => { + const uiState = createMockUIState({ + terminalWidth: 40, + }); + const { lastFrame } = await renderComposer(uiState); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('matches snapshot in minimal UI mode', async () => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + }); + const { lastFrame } = await renderComposer(uiState); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('matches snapshot in minimal UI mode while loading', async () => { + const uiState = createMockUIState({ + cleanUiDetailsVisible: false, + streamingState: StreamingState.Responding, + elapsedTime: 1000, + }); + const { lastFrame } = await renderComposer(uiState); + expect(lastFrame()).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..253380a449e27c1f8eed15ec82896b376c24b48d --- /dev/null +++ b/packages/cli/src/ui/components/Composer.tsx @@ -0,0 +1,183 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, useIsScreenReaderEnabled } from 'ink'; +import { useState, useEffect } from 'react'; +import { useConfig } from '../contexts/ConfigContext.js'; +import { useSettings } from '../contexts/SettingsContext.js'; +import { useUIState } from '../contexts/UIStateContext.js'; +import { useInputState } from '../contexts/InputContext.js'; +import { useUIActions } from '../contexts/UIActionsContext.js'; +import { useVimMode } from '../contexts/VimModeContext.js'; +import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { isNarrowWidth } from '../utils/isNarrowWidth.js'; +import { ToastDisplay, shouldShowToast } from './ToastDisplay.js'; +import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js'; +import { ShortcutsHelp } from './ShortcutsHelp.js'; +import { InputPrompt } from './InputPrompt.js'; +import { Footer } from './Footer.js'; +import { StatusRow } from './StatusRow.js'; +import { ShowMoreLines } from './ShowMoreLines.js'; +import { QueuedMessageDisplay } from './QueuedMessageDisplay.js'; +import { OverflowProvider } from '../contexts/OverflowContext.js'; +import { ConfigInitDisplay } from './ConfigInitDisplay.js'; +import { TodoTray } from './messages/Todo.js'; +import { useComposerStatus } from '../hooks/useComposerStatus.js'; +import { appEvents, AppEvent } from '../../utils/events.js'; + +export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { + const uiState = useUIState(); + const inputState = useInputState(); + const uiActions = useUIActions(); + const settings = useSettings(); + const config = useConfig(); + const { vimEnabled, vimMode } = useVimMode(); + const isScreenReaderEnabled = useIsScreenReaderEnabled(); + const { columns: terminalWidth } = useTerminalSize(); + const isNarrow = isNarrowWidth(terminalWidth); + const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5)); + const [suggestionsVisible, setSuggestionsVisible] = useState(false); + + const isAlternateBuffer = useAlternateBuffer(); + const showUiDetails = uiState.cleanUiDetailsVisible; + const suggestionsPosition = isAlternateBuffer ? 'above' : 'below'; + const hideContextSummary = + suggestionsVisible && suggestionsPosition === 'above'; + + const { hasPendingActionRequired, shouldCollapseDuringApproval } = + useComposerStatus(); + + const isPassiveShortcutsHelpState = + uiState.isInputActive && + uiState.streamingState === 'idle' && + !hasPendingActionRequired; + + const { setShortcutsHelpVisible } = uiActions; + + useEffect(() => { + if (hasPendingActionRequired) { + appEvents.emit(AppEvent.ScrollToBottom); + } + }, [hasPendingActionRequired]); + + useEffect(() => { + if (uiState.shortcutsHelpVisible && !isPassiveShortcutsHelpState) { + setShortcutsHelpVisible(false); + } + }, [ + uiState.shortcutsHelpVisible, + isPassiveShortcutsHelpState, + setShortcutsHelpVisible, + ]); + + const showShortcutsHelp = + uiState.shortcutsHelpVisible && + uiState.streamingState === 'idle' && + !hasPendingActionRequired; + + if (hasPendingActionRequired && shouldCollapseDuringApproval) { + return null; + } + + const showToast = shouldShowToast(uiState, inputState); + const hideUiDetailsForSuggestions = + suggestionsVisible && suggestionsPosition === 'above'; + + // Mini Mode VIP Flags (Pure Content Triggers) + const showMinimalToast = showToast; + + return ( + + {uiState.isResuming && ( + + )} + + {showUiDetails && ( + + )} + + {showUiDetails && } + + {showShortcutsHelp && } + + {(showUiDetails || showMinimalToast) && ( + + + + )} + + + + + + {showUiDetails && uiState.showErrorDetails && ( + + + + + + + )} + + {uiState.isInputActive && ( + + )} + + {showUiDetails && + !settings.merged.ui.hideFooter && + !isScreenReaderEnabled &&