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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 && }
+
+ );
+};
diff --git a/packages/cli/src/ui/components/ConfigExtensionDialog.tsx b/packages/cli/src/ui/components/ConfigExtensionDialog.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7f09d46491169449c89a967c3c5c8de4a2ffdc67
--- /dev/null
+++ b/packages/cli/src/ui/components/ConfigExtensionDialog.tsx
@@ -0,0 +1,343 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { useEffect, useState, useRef, useCallback } from 'react';
+import { Box, Text } from 'ink';
+import { theme } from '../semantic-colors.js';
+import type { ExtensionManager } from '../../config/extension-manager.js';
+import {
+ configureExtension,
+ configureSpecificSetting,
+ configureAllExtensions,
+ type ConfigLogger,
+ type RequestSettingCallback,
+ type RequestConfirmationCallback,
+} from '../../commands/extensions/utils.js';
+import {
+ ExtensionSettingScope,
+ type ExtensionSetting,
+} from '../../config/extensions/extensionSettings.js';
+import { TextInput } from './shared/TextInput.js';
+import { useTextBuffer } from './shared/text-buffer.js';
+import { DialogFooter } from './shared/DialogFooter.js';
+import { type Key, useKeypress } from '../hooks/useKeypress.js';
+
+export interface ConfigExtensionDialogProps {
+ extensionManager: ExtensionManager;
+ onClose: () => void;
+ extensionName?: string;
+ settingKey?: string;
+ scope?: ExtensionSettingScope;
+ configureAll?: boolean;
+ loggerAdapter: ConfigLogger;
+}
+
+type DialogState =
+ | { type: 'IDLE' }
+ | { type: 'BUSY'; message?: string }
+ | {
+ type: 'ASK_SETTING';
+ setting: ExtensionSetting;
+ resolve: (val: string) => void;
+ initialValue?: string;
+ }
+ | {
+ type: 'ASK_CONFIRMATION';
+ message: string;
+ resolve: (val: boolean) => void;
+ }
+ | { type: 'DONE' }
+ | { type: 'ERROR'; error: Error };
+
+export const ConfigExtensionDialog: React.FC = ({
+ extensionManager,
+ onClose,
+ extensionName,
+ settingKey,
+ scope = ExtensionSettingScope.USER,
+ configureAll,
+ loggerAdapter,
+}) => {
+ const [state, setState] = useState({ type: 'IDLE' });
+ const [logMessages, setLogMessages] = useState([]);
+
+ // Buffers for input
+ const settingBuffer = useTextBuffer({
+ initialText: '',
+ viewport: { width: 80, height: 1 },
+ singleLine: true,
+ escapePastedPaths: true,
+ });
+
+ const mounted = useRef(true);
+
+ useEffect(() => {
+ mounted.current = true;
+ return () => {
+ mounted.current = false;
+ };
+ }, []);
+
+ const addLog = useCallback(
+ (msg: string) => {
+ setLogMessages((prev) => [...prev, msg].slice(-5)); // Keep last 5
+ loggerAdapter.log(msg);
+ },
+ [loggerAdapter],
+ );
+
+ const requestSetting: RequestSettingCallback = useCallback(
+ async (setting) =>
+ new Promise((resolve) => {
+ if (!mounted.current) return;
+ settingBuffer.setText(''); // Clear buffer
+ setState({
+ type: 'ASK_SETTING',
+ setting,
+ resolve: (val) => {
+ resolve(val);
+ setState({ type: 'BUSY', message: 'Updating...' });
+ },
+ });
+ }),
+ [settingBuffer],
+ );
+
+ const requestConfirmation: RequestConfirmationCallback = useCallback(
+ async (message) =>
+ new Promise((resolve) => {
+ if (!mounted.current) return;
+ setState({
+ type: 'ASK_CONFIRMATION',
+ message,
+ resolve: (val) => {
+ resolve(val);
+ setState({ type: 'BUSY', message: 'Processing...' });
+ },
+ });
+ }),
+ [],
+ );
+
+ useEffect(() => {
+ async function run() {
+ try {
+ setState({ type: 'BUSY', message: 'Initializing...' });
+
+ // Wrap logger to capture logs locally too
+ const localLogger: ConfigLogger = {
+ log: (msg) => {
+ addLog(msg);
+ },
+ error: (msg) => {
+ addLog('Error: ' + msg);
+ loggerAdapter.error(msg);
+ },
+ };
+
+ if (configureAll) {
+ await configureAllExtensions(
+ extensionManager,
+ scope,
+ localLogger,
+ requestSetting,
+ requestConfirmation,
+ );
+ } else if (extensionName && settingKey) {
+ await configureSpecificSetting(
+ extensionManager,
+ extensionName,
+ settingKey,
+ scope,
+ localLogger,
+ requestSetting,
+ );
+ } else if (extensionName) {
+ await configureExtension(
+ extensionManager,
+ extensionName,
+ scope,
+ localLogger,
+ requestSetting,
+ requestConfirmation,
+ );
+ }
+
+ if (mounted.current) {
+ setState({ type: 'DONE' });
+ // Delay close slightly to show done
+ setTimeout(onClose, 1000);
+ }
+ } catch (err: unknown) {
+ if (mounted.current) {
+ const error = err instanceof Error ? err : new Error(String(err));
+ setState({ type: 'ERROR', error });
+ loggerAdapter.error(error.message);
+ }
+ }
+ }
+
+ // Only run once
+ if (state.type === 'IDLE') {
+ void run();
+ }
+ }, [
+ extensionManager,
+ extensionName,
+ settingKey,
+ scope,
+ configureAll,
+ loggerAdapter,
+ requestSetting,
+ requestConfirmation,
+ addLog,
+ onClose,
+ state.type,
+ ]);
+
+ // Handle Input Submission
+ const handleSettingSubmit = (val: string) => {
+ if (state.type === 'ASK_SETTING') {
+ state.resolve(val);
+ }
+ };
+
+ // Handle Keys for Confirmation
+ useKeypress(
+ (key: Key) => {
+ if (state.type === 'ASK_CONFIRMATION') {
+ if (key.name === 'y' || key.name === 'enter') {
+ state.resolve(true);
+ return true;
+ }
+ if (key.name === 'n' || key.name === 'escape') {
+ state.resolve(false);
+ return true;
+ }
+ }
+ if (state.type === 'DONE' || state.type === 'ERROR') {
+ if (key.name === 'enter' || key.name === 'escape') {
+ onClose();
+ return true;
+ }
+ }
+ return false;
+ },
+ {
+ isActive:
+ state.type === 'ASK_CONFIRMATION' ||
+ state.type === 'DONE' ||
+ state.type === 'ERROR',
+ },
+ );
+
+ if (state.type === 'BUSY' || state.type === 'IDLE') {
+ return (
+
+
+ {state.type === 'BUSY' ? state.message : 'Starting...'}
+
+ {logMessages.map((msg, i) => (
+ {msg}
+ ))}
+
+ );
+ }
+
+ if (state.type === 'ASK_SETTING') {
+ return (
+
+
+ Configure {state.setting.name}
+
+
+ {state.setting.description || state.setting.envVar}
+
+
+ {'> '}
+
+
+
+
+ );
+ }
+
+ if (state.type === 'ASK_CONFIRMATION') {
+ return (
+
+
+ Confirmation Required
+
+ {state.message}
+
+
+ Press{' '}
+
+ Y
+ {' '}
+ to confirm or{' '}
+
+ N
+ {' '}
+ to cancel
+
+
+
+ );
+ }
+
+ if (state.type === 'ERROR') {
+ return (
+
+
+ Error
+
+ {state.error.message}
+
+
+ );
+ }
+
+ return (
+
+
+ Configuration Complete
+
+
+
+ );
+};
diff --git a/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx b/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b4ae8b93b1ad56e8109a252bbe3f05f47c94020d
--- /dev/null
+++ b/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx
@@ -0,0 +1,150 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { act } from 'react';
+import type { EventEmitter } from 'node:events';
+import { renderWithProviders } from '../../test-utils/render.js';
+import { waitFor } from '../../test-utils/async.js';
+import { ConfigInitDisplay } from './ConfigInitDisplay.js';
+import {
+ describe,
+ it,
+ expect,
+ vi,
+ beforeEach,
+ afterEach,
+ type MockInstance,
+} from 'vitest';
+import {
+ CoreEvent,
+ MCPServerStatus,
+ type McpClient,
+ coreEvents,
+} from '@google/gemini-cli-core';
+import { Text } from 'ink';
+
+// Mock GeminiSpinner
+vi.mock('./GeminiSpinner.js', () => ({
+ GeminiSpinner: () => Spinner,
+}));
+
+describe('ConfigInitDisplay', () => {
+ let onSpy: MockInstance;
+
+ beforeEach(() => {
+ onSpy = vi.spyOn(coreEvents as EventEmitter, 'on');
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renders initial state', async () => {
+ const { lastFrame } = await renderWithProviders();
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('updates message on McpClientUpdate event', async () => {
+ let listener: ((clients?: Map) => void) | undefined;
+ onSpy.mockImplementation((event: unknown, fn: unknown) => {
+ if (event === CoreEvent.McpClientUpdate) {
+ listener = fn as (clients?: Map) => void;
+ }
+ return coreEvents;
+ });
+
+ const { lastFrame } = await renderWithProviders();
+
+ // Wait for listener to be registered
+ await waitFor(() => {
+ if (!listener) throw new Error('Listener not registered yet');
+ });
+
+ const mockClient1 = {
+ getStatus: () => MCPServerStatus.CONNECTED,
+ } as McpClient;
+ const mockClient2 = {
+ getStatus: () => MCPServerStatus.CONNECTING,
+ } as McpClient;
+ const clients = new Map([
+ ['server1', mockClient1],
+ ['server2', mockClient2],
+ ]);
+
+ // Trigger the listener manually since we mocked the event emitter
+ act(() => {
+ listener!(clients);
+ });
+
+ // Wait for the UI to update
+ await waitFor(() => {
+ expect(lastFrame()).toMatchSnapshot();
+ });
+ });
+
+ it('truncates list of waiting servers if too many', async () => {
+ let listener: ((clients?: Map) => void) | undefined;
+ onSpy.mockImplementation((event: unknown, fn: unknown) => {
+ if (event === CoreEvent.McpClientUpdate) {
+ listener = fn as (clients?: Map) => void;
+ }
+ return coreEvents;
+ });
+
+ const { lastFrame } = await renderWithProviders();
+
+ await waitFor(() => {
+ if (!listener) throw new Error('Listener not registered yet');
+ });
+
+ const mockClientConnecting = {
+ getStatus: () => MCPServerStatus.CONNECTING,
+ } as McpClient;
+
+ const clients = new Map([
+ ['s1', mockClientConnecting],
+ ['s2', mockClientConnecting],
+ ['s3', mockClientConnecting],
+ ['s4', mockClientConnecting],
+ ['s5', mockClientConnecting],
+ ]);
+
+ act(() => {
+ listener!(clients);
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toMatchSnapshot();
+ });
+ });
+
+ it('handles empty clients map', async () => {
+ let listener: ((clients?: Map) => void) | undefined;
+ onSpy.mockImplementation((event: unknown, fn: unknown) => {
+ if (event === CoreEvent.McpClientUpdate) {
+ listener = fn as (clients?: Map) => void;
+ }
+ return coreEvents;
+ });
+
+ const { lastFrame } = await renderWithProviders();
+
+ await waitFor(() => {
+ if (!listener) throw new Error('Listener not registered yet');
+ });
+
+ if (listener) {
+ const safeListener = listener;
+ act(() => {
+ safeListener(new Map());
+ });
+ }
+
+ await waitFor(() => {
+ expect(lastFrame()).toMatchSnapshot();
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/ConfigInitDisplay.tsx b/packages/cli/src/ui/components/ConfigInitDisplay.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..49972606210ca59427bb1a4617eca49c18a1b885
--- /dev/null
+++ b/packages/cli/src/ui/components/ConfigInitDisplay.tsx
@@ -0,0 +1,75 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useEffect, useState } from 'react';
+import { Box, Text } from 'ink';
+import {
+ CoreEvent,
+ coreEvents,
+ type McpClient,
+ MCPServerStatus,
+} from '@google/gemini-cli-core';
+import { GeminiSpinner } from './GeminiSpinner.js';
+import { theme } from '../semantic-colors.js';
+
+export const ConfigInitDisplay = ({
+ message: initialMessage = 'Working...',
+}: {
+ message?: string;
+}) => {
+ const [message, setMessage] = useState(initialMessage);
+
+ useEffect(() => {
+ const onChange = (clients?: Map) => {
+ if (!clients || clients.size === 0) {
+ setMessage(initialMessage);
+ return;
+ }
+ let connected = 0;
+ const connecting: string[] = [];
+ for (const [name, client] of clients.entries()) {
+ if (client.getStatus() === MCPServerStatus.CONNECTED) {
+ connected++;
+ } else {
+ connecting.push(name);
+ }
+ }
+
+ if (connecting.length > 0) {
+ const maxDisplay = 3;
+ const displayedServers = connecting.slice(0, maxDisplay).join(', ');
+ const remaining = connecting.length - maxDisplay;
+ const suffix = remaining > 0 ? `, +${remaining} more` : '';
+ const mcpMessage = `Connecting to MCP servers... (${connected}/${clients.size}) - Waiting for: ${displayedServers}${suffix}`;
+ setMessage(
+ initialMessage && initialMessage !== 'Working...'
+ ? `${initialMessage} (${mcpMessage})`
+ : mcpMessage,
+ );
+ } else {
+ const mcpMessage = `Connecting to MCP servers... (${connected}/${clients.size})`;
+ setMessage(
+ initialMessage && initialMessage !== 'Working...'
+ ? `${initialMessage} (${mcpMessage})`
+ : mcpMessage,
+ );
+ }
+ };
+
+ coreEvents.on(CoreEvent.McpClientUpdate, onChange);
+ return () => {
+ coreEvents.off(CoreEvent.McpClientUpdate, onChange);
+ };
+ }, [initialMessage]);
+
+ return (
+
+
+ {message}
+
+
+ );
+};
diff --git a/packages/cli/src/ui/components/ConsentPrompt.test.tsx b/packages/cli/src/ui/components/ConsentPrompt.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..09a2dde16e43d4c0f5f458fae542c680152feb1e
--- /dev/null
+++ b/packages/cli/src/ui/components/ConsentPrompt.test.tsx
@@ -0,0 +1,131 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Text } from 'ink';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render } from '../../test-utils/render.js';
+import { act } from 'react';
+import { ConsentPrompt } from './ConsentPrompt.js';
+import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
+import { MarkdownDisplay } from '../utils/MarkdownDisplay.js';
+
+vi.mock('./shared/RadioButtonSelect.js', () => ({
+ RadioButtonSelect: vi.fn(() => null),
+}));
+
+vi.mock('../utils/MarkdownDisplay.js', () => ({
+ MarkdownDisplay: vi.fn(() => null),
+}));
+
+const MockedRadioButtonSelect = vi.mocked(RadioButtonSelect);
+const MockedMarkdownDisplay = vi.mocked(MarkdownDisplay);
+
+describe('ConsentPrompt', () => {
+ const onConfirm = vi.fn();
+ const terminalWidth = 80;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders a string prompt with MarkdownDisplay', async () => {
+ const prompt = 'Are you sure?';
+ const { unmount } = await render(
+ ,
+ );
+
+ expect(MockedMarkdownDisplay).toHaveBeenCalledWith(
+ {
+ isPending: true,
+ text: prompt,
+ terminalWidth,
+ },
+ undefined,
+ );
+ unmount();
+ });
+
+ it('renders a ReactNode prompt directly', async () => {
+ const prompt = Are you sure?;
+ const { lastFrame, unmount } = await render(
+ ,
+ );
+
+ expect(MockedMarkdownDisplay).not.toHaveBeenCalled();
+ expect(lastFrame()).toContain('Are you sure?');
+ unmount();
+ });
+
+ it('calls onConfirm with true when "Yes" is selected', async () => {
+ const prompt = 'Are you sure?';
+ const { waitUntilReady, unmount } = await render(
+ ,
+ );
+
+ const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
+ await act(async () => {
+ onSelect(true);
+ });
+ await waitUntilReady();
+
+ expect(onConfirm).toHaveBeenCalledWith(true);
+ unmount();
+ });
+
+ it('calls onConfirm with false when "No" is selected', async () => {
+ const prompt = 'Are you sure?';
+ const { waitUntilReady, unmount } = await render(
+ ,
+ );
+
+ const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
+ await act(async () => {
+ onSelect(false);
+ });
+ await waitUntilReady();
+
+ expect(onConfirm).toHaveBeenCalledWith(false);
+ unmount();
+ });
+
+ it('passes correct items to RadioButtonSelect', async () => {
+ const prompt = 'Are you sure?';
+ const { unmount } = await render(
+ ,
+ );
+
+ expect(MockedRadioButtonSelect).toHaveBeenCalledWith(
+ expect.objectContaining({
+ items: [
+ { label: 'Yes', value: true, key: 'Yes' },
+ { label: 'No', value: false, key: 'No' },
+ ],
+ }),
+ undefined,
+ );
+ unmount();
+ });
+});
diff --git a/packages/cli/src/ui/components/ConsentPrompt.tsx b/packages/cli/src/ui/components/ConsentPrompt.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..859d29281d466e3bfe9ddbc71ebd7253ec3f4282
--- /dev/null
+++ b/packages/cli/src/ui/components/ConsentPrompt.tsx
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Box } from 'ink';
+import { type ReactNode } from 'react';
+import { theme } from '../semantic-colors.js';
+import { MarkdownDisplay } from '../utils/MarkdownDisplay.js';
+import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
+import { DialogFooter } from './shared/DialogFooter.js';
+
+type ConsentPromptProps = {
+ // If a simple string is given, it will render using markdown by default.
+ prompt: ReactNode;
+ onConfirm: (value: boolean) => void;
+ terminalWidth: number;
+};
+
+export const ConsentPrompt = (props: ConsentPromptProps) => {
+ const { prompt, onConfirm, terminalWidth } = props;
+
+ return (
+
+ {typeof prompt === 'string' ? (
+
+ ) : (
+ prompt
+ )}
+
+
+
+
+
+ );
+};
diff --git a/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx b/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b7662c3a2692c0d87fbeec0de71b2e53d34d7f92
--- /dev/null
+++ b/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx
@@ -0,0 +1,33 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
+import { describe, it, expect } from 'vitest';
+
+describe('ConsoleSummaryDisplay', () => {
+ it('renders nothing when errorCount is 0', async () => {
+ const { lastFrame, unmount } = await render(
+ ,
+ );
+ expect(lastFrame({ allowEmpty: true })).toBe('');
+ unmount();
+ });
+
+ it.each([
+ [1, '1 error'],
+ [5, '5 errors'],
+ ])('renders correct message for %i errors', async (count, expectedText) => {
+ const { lastFrame, unmount } = await render(
+ ,
+ );
+ const output = lastFrame();
+ expect(output).toContain(expectedText);
+ expect(output).toContain('✖');
+ expect(output).toContain('(F12 for details)');
+ unmount();
+ });
+});
diff --git a/packages/cli/src/ui/components/ConsoleSummaryDisplay.tsx b/packages/cli/src/ui/components/ConsoleSummaryDisplay.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9d19683e228a9977d7930e82c194a7a3d2321793
--- /dev/null
+++ b/packages/cli/src/ui/components/ConsoleSummaryDisplay.tsx
@@ -0,0 +1,35 @@
+/**
+ * @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';
+
+interface ConsoleSummaryDisplayProps {
+ errorCount: number;
+ // logCount is not currently in the plan to be displayed in summary
+}
+
+export const ConsoleSummaryDisplay: React.FC = ({
+ errorCount,
+}) => {
+ if (errorCount === 0) {
+ return null;
+ }
+
+ const errorIcon = '\u2716'; // Heavy multiplication x (✖)
+
+ return (
+
+ {errorCount > 0 && (
+
+ {errorIcon} {errorCount} error{errorCount > 1 ? 's' : ''}{' '}
+ (F12 for details)
+
+ )}
+
+ );
+};
diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8c013cafa975f6eb47c4b2524fbdf200085e46e0
--- /dev/null
+++ b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx
@@ -0,0 +1,97 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { render } from '../../test-utils/render.js';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
+import * as useTerminalSize from '../hooks/useTerminalSize.js';
+
+vi.mock('../hooks/useTerminalSize.js', () => ({
+ useTerminalSize: vi.fn(),
+}));
+
+const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+});
+
+const renderWithWidth = async (
+ width: number,
+ props: React.ComponentProps,
+) => {
+ useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
+ const result = await render();
+ return result;
+};
+
+describe('', () => {
+ const baseProps = {
+ geminiMdFileCount: 0,
+ contextFileNames: [],
+ mcpServers: {},
+ ideContext: {
+ workspaceState: {
+ openFiles: [],
+ },
+ },
+ skillCount: 1,
+ };
+
+ it('should render on a single line on a wide screen', async () => {
+ const props = {
+ ...baseProps,
+ geminiMdFileCount: 1,
+ contextFileNames: ['GEMINI.md'],
+ mcpServers: { 'test-server': { command: 'test' } },
+ ideContext: {
+ workspaceState: {
+ openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
+ },
+ },
+ };
+ const { lastFrame, unmount } = await renderWithWidth(120, props);
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
+ it('should render on multiple lines on a narrow screen', async () => {
+ const props = {
+ ...baseProps,
+ geminiMdFileCount: 1,
+ contextFileNames: ['GEMINI.md'],
+ mcpServers: { 'test-server': { command: 'test' } },
+ ideContext: {
+ workspaceState: {
+ openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
+ },
+ },
+ };
+ const { lastFrame, unmount } = await renderWithWidth(60, props);
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
+ it('should not render empty parts', async () => {
+ const props = {
+ ...baseProps,
+ geminiMdFileCount: 0,
+ contextFileNames: [],
+ mcpServers: {},
+ skillCount: 0,
+ ideContext: {
+ workspaceState: {
+ openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
+ },
+ },
+ };
+ const { lastFrame, unmount } = await renderWithWidth(60, props);
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+});
diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..171e29e905a8e82eb59e0b05ed49d8fccc0dc044
--- /dev/null
+++ b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx
@@ -0,0 +1,124 @@
+/**
+ * @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 { type IdeContext, type MCPServerConfig } from '@google/gemini-cli-core';
+import { Command } from '../key/keyMatchers.js';
+import { formatCommand } from '../key/keybindingUtils.js';
+
+interface ContextSummaryDisplayProps {
+ geminiMdFileCount: number;
+ contextFileNames: string[];
+ mcpServers?: Record;
+ blockedMcpServers?: Array<{ name: string; extensionName: string }>;
+ ideContext?: IdeContext;
+ skillCount: number;
+ backgroundProcessCount?: number;
+}
+
+export const ContextSummaryDisplay: React.FC = ({
+ geminiMdFileCount,
+ contextFileNames,
+ mcpServers,
+ blockedMcpServers,
+ ideContext,
+ skillCount,
+ backgroundProcessCount = 0,
+}) => {
+ const mcpServerCount = Object.keys(mcpServers || {}).length;
+ const blockedMcpServerCount = blockedMcpServers?.length || 0;
+ const openFileCount = ideContext?.workspaceState?.openFiles?.length ?? 0;
+
+ if (
+ geminiMdFileCount === 0 &&
+ mcpServerCount === 0 &&
+ blockedMcpServerCount === 0 &&
+ openFileCount === 0 &&
+ skillCount === 0 &&
+ backgroundProcessCount === 0
+ ) {
+ return null;
+ }
+
+ const openFilesText = (() => {
+ if (openFileCount === 0) {
+ return '';
+ }
+ return `${openFileCount} open file${
+ openFileCount > 1 ? 's' : ''
+ } (${formatCommand(Command.SHOW_IDE_CONTEXT_DETAIL)} to view)`;
+ })();
+
+ const geminiMdText = (() => {
+ if (geminiMdFileCount === 0) {
+ return '';
+ }
+ const allNamesTheSame = new Set(contextFileNames).size < 2;
+ const name = allNamesTheSame ? contextFileNames[0] : 'context';
+ return `${geminiMdFileCount} ${name} file${
+ geminiMdFileCount > 1 ? 's' : ''
+ }`;
+ })();
+
+ const mcpText = (() => {
+ if (mcpServerCount === 0 && blockedMcpServerCount === 0) {
+ return '';
+ }
+
+ const parts = [];
+ if (mcpServerCount > 0) {
+ parts.push(
+ `${mcpServerCount} MCP server${mcpServerCount > 1 ? 's' : ''}`,
+ );
+ }
+
+ if (blockedMcpServerCount > 0) {
+ let blockedText = `${blockedMcpServerCount} Blocked`;
+ if (mcpServerCount === 0) {
+ blockedText += ` MCP server${blockedMcpServerCount > 1 ? 's' : ''}`;
+ }
+ parts.push(blockedText);
+ }
+ return parts.join(', ');
+ })();
+
+ const skillText = (() => {
+ if (skillCount === 0) {
+ return '';
+ }
+ return `${skillCount} skill${skillCount > 1 ? 's' : ''}`;
+ })();
+
+ const backgroundText = (() => {
+ if (backgroundProcessCount === 0) {
+ return '';
+ }
+ return `${backgroundProcessCount} Background process${
+ backgroundProcessCount > 1 ? 'es' : ''
+ }`;
+ })();
+
+ const summaryParts = [
+ openFilesText,
+ geminiMdText,
+ mcpText,
+ skillText,
+ backgroundText,
+ ].filter(Boolean);
+
+ return (
+
+ {summaryParts.map((part, index) => (
+
+ {index > 0 && {' · '}}
+ {part}
+
+ ))}
+
+ );
+};
diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d8ec1650ee93d3cf51df81b2e2ada16fb18be5ff
--- /dev/null
+++ b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx
@@ -0,0 +1,87 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { renderWithProviders } from '../../test-utils/render.js';
+import { ContextUsageDisplay } from './ContextUsageDisplay.js';
+import { describe, it, expect, vi } from 'vitest';
+
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ tokenLimit: () => 10000,
+ };
+});
+
+describe('ContextUsageDisplay', () => {
+ it('renders correct percentage used', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ const output = lastFrame();
+ expect(output).toContain('50% used');
+ unmount();
+ });
+
+ it('renders correctly when usage is 0%', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ const output = lastFrame();
+ expect(output).toContain('0% used');
+ unmount();
+ });
+
+ it('renders abbreviated label when terminal width is small', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ { width: 80 },
+ );
+ const output = lastFrame();
+ expect(output).toContain('20%');
+ expect(output).not.toContain('context used');
+ unmount();
+ });
+
+ it('renders 80% correctly', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ const output = lastFrame();
+ expect(output).toContain('80% used');
+ unmount();
+ });
+
+ it('renders 100% when full', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ const output = lastFrame();
+ expect(output).toContain('100% used');
+ unmount();
+ });
+});
diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3e82145dcaeb76d2ba22077792ad54c20e024382
--- /dev/null
+++ b/packages/cli/src/ui/components/ContextUsageDisplay.tsx
@@ -0,0 +1,49 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Text } from 'ink';
+import { theme } from '../semantic-colors.js';
+import { getContextUsagePercentage } from '../utils/contextUsage.js';
+import { useSettings } from '../contexts/SettingsContext.js';
+import {
+ MIN_TERMINAL_WIDTH_FOR_FULL_LABEL,
+ DEFAULT_COMPRESSION_THRESHOLD,
+} from '../constants.js';
+
+export const ContextUsageDisplay = ({
+ promptTokenCount,
+ model,
+ terminalWidth,
+}: {
+ promptTokenCount: number;
+ model: string | undefined;
+ terminalWidth: number;
+}) => {
+ const settings = useSettings();
+ const percentage = getContextUsagePercentage(promptTokenCount, model);
+ const percentageUsed = (percentage * 100).toFixed(0);
+
+ const threshold =
+ settings.merged.model?.compressionThreshold ??
+ DEFAULT_COMPRESSION_THRESHOLD;
+
+ let textColor = theme.text.secondary;
+ if (percentage >= 1.0) {
+ textColor = theme.status.error;
+ } else if (percentage >= threshold) {
+ textColor = theme.status.warning;
+ }
+
+ const label =
+ terminalWidth < MIN_TERMINAL_WIDTH_FOR_FULL_LABEL ? '%' : '% used';
+
+ return (
+
+ {percentageUsed}
+ {label}
+
+ );
+};
diff --git a/packages/cli/src/ui/components/CopyModeWarning.test.tsx b/packages/cli/src/ui/components/CopyModeWarning.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c1b797ffd586cc6c9846161d6f107fdacfa2d496
--- /dev/null
+++ b/packages/cli/src/ui/components/CopyModeWarning.test.tsx
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { CopyModeWarning } from './CopyModeWarning.js';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderWithProviders } from '../../test-utils/render.js';
+import { useInputState } from '../contexts/InputContext.js';
+
+vi.mock('../contexts/InputContext.js');
+
+describe('CopyModeWarning', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders nothing when copy mode is disabled', async () => {
+ vi.mocked(useInputState).mockReturnValue({
+ copyModeEnabled: false,
+ } as unknown as ReturnType);
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ expect(lastFrame({ allowEmpty: true })).toBe('');
+ unmount();
+ });
+
+ it('renders warning when copy mode is enabled', async () => {
+ vi.mocked(useInputState).mockReturnValue({
+ copyModeEnabled: true,
+ } as unknown as ReturnType);
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ expect(lastFrame()).toContain('In Copy Mode');
+ expect(lastFrame()).toContain('Use Page Up/Down to scroll');
+ expect(lastFrame()).toContain('Press Ctrl+S or any other key to exit');
+ unmount();
+ });
+});
diff --git a/packages/cli/src/ui/components/CopyModeWarning.tsx b/packages/cli/src/ui/components/CopyModeWarning.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2eec1b62ae8d4c7fb1c30ebcaba66f413bd6b39b
--- /dev/null
+++ b/packages/cli/src/ui/components/CopyModeWarning.tsx
@@ -0,0 +1,25 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Box, Text } from 'ink';
+import { useInputState } from '../contexts/InputContext.js';
+import { theme } from '../semantic-colors.js';
+
+export const CopyModeWarning: React.FC = () => {
+ const { copyModeEnabled } = useInputState();
+
+ return (
+
+ {copyModeEnabled && (
+
+ In Copy Mode. Use Page Up/Down to scroll. Press Ctrl+S or any other
+ key to exit.
+
+ )}
+
+ );
+};
diff --git a/packages/cli/src/ui/components/DebugProfiler.test.tsx b/packages/cli/src/ui/components/DebugProfiler.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a014c740f08812ad376e2e516071b67140b99f1c
--- /dev/null
+++ b/packages/cli/src/ui/components/DebugProfiler.test.tsx
@@ -0,0 +1,305 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { appEvents, AppEvent } from '../../utils/events.js';
+import { coreEvents } from '@google/gemini-cli-core';
+import {
+ profiler,
+ DebugProfiler,
+ ACTION_TIMESTAMP_CAPACITY,
+ FRAME_TIMESTAMP_CAPACITY,
+} from './DebugProfiler.js';
+import { render } from '../../test-utils/render.js';
+import { useUIState, type UIState } from '../contexts/UIStateContext.js';
+import { FixedDeque } from 'mnemonist';
+import { debugState } from '../debug.js';
+import { act } from 'react';
+
+vi.mock('../contexts/UIStateContext.js', () => ({
+ useUIState: vi.fn(),
+}));
+
+describe('DebugProfiler', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ profiler.profilersActive = 1;
+ profiler.numFrames = 0;
+ profiler.totalIdleFrames = 0;
+ profiler.lastFrameStartTime = 0;
+ profiler.openedDebugConsole = false;
+ profiler.lastActionTimestamp = 0;
+ profiler.possiblyIdleFrameTimestamps = new FixedDeque(
+ Array,
+ FRAME_TIMESTAMP_CAPACITY,
+ );
+ profiler.actionTimestamps = new FixedDeque(
+ Array,
+ ACTION_TIMESTAMP_CAPACITY,
+ );
+ debugState.debugNumAnimatedComponents = 0;
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ profiler.actionTimestamps.clear();
+ profiler.possiblyIdleFrameTimestamps.clear();
+ debugState.debugNumAnimatedComponents = 0;
+ });
+
+ it('should not exceed action timestamp capacity', () => {
+ for (let i = 0; i < ACTION_TIMESTAMP_CAPACITY + 10; i++) {
+ profiler.reportAction();
+ // To ensure we don't trigger the debounce
+ profiler.lastActionTimestamp = 0;
+ }
+ expect(profiler.actionTimestamps.size).toBe(ACTION_TIMESTAMP_CAPACITY);
+ });
+
+ it('should not exceed frame timestamp capacity', () => {
+ for (let i = 0; i < FRAME_TIMESTAMP_CAPACITY + 10; i++) {
+ profiler.reportFrameRendered();
+ // To ensure we don't trigger the debounce
+ profiler.lastFrameStartTime = 0;
+ }
+ expect(profiler.possiblyIdleFrameTimestamps.size).toBe(
+ FRAME_TIMESTAMP_CAPACITY,
+ );
+ });
+
+ it('should drop oldest action timestamps when capacity is reached', () => {
+ for (let i = 0; i < ACTION_TIMESTAMP_CAPACITY; i++) {
+ profiler.actionTimestamps.push(i);
+ }
+ profiler.lastActionTimestamp = 0;
+ profiler.reportAction();
+
+ expect(profiler.actionTimestamps.size).toBe(ACTION_TIMESTAMP_CAPACITY);
+ expect(profiler.actionTimestamps.peekFirst()).toBe(1);
+ });
+
+ it('should drop oldest frame timestamps when capacity is reached', () => {
+ for (let i = 0; i < FRAME_TIMESTAMP_CAPACITY; i++) {
+ profiler.possiblyIdleFrameTimestamps.push(i);
+ }
+ profiler.lastFrameStartTime = 0;
+ profiler.reportFrameRendered();
+
+ expect(profiler.possiblyIdleFrameTimestamps.size).toBe(
+ FRAME_TIMESTAMP_CAPACITY,
+ );
+ expect(profiler.possiblyIdleFrameTimestamps.peekFirst()).toBe(1);
+ });
+
+ it('should not report frames as idle if an action happens shortly after', async () => {
+ const startTime = Date.now();
+ vi.setSystemTime(startTime);
+
+ for (let i = 0; i < 5; i++) {
+ profiler.reportFrameRendered();
+ vi.advanceTimersByTime(20);
+ }
+
+ vi.setSystemTime(startTime + 400);
+ profiler.reportAction();
+
+ vi.advanceTimersByTime(600);
+ profiler.checkForIdleFrames();
+
+ expect(profiler.totalIdleFrames).toBe(0);
+ });
+
+ it('should report frames as idle if no action happens nearby', async () => {
+ const startTime = Date.now();
+ vi.setSystemTime(startTime);
+
+ for (let i = 0; i < 5; i++) {
+ profiler.reportFrameRendered();
+ vi.advanceTimersByTime(20);
+ }
+
+ vi.advanceTimersByTime(1000);
+ profiler.checkForIdleFrames();
+
+ expect(profiler.totalIdleFrames).toBe(5);
+ });
+
+ it('should not report frames as idle if an action happens shortly before', async () => {
+ const startTime = Date.now();
+ vi.setSystemTime(startTime);
+
+ profiler.reportAction();
+
+ vi.advanceTimersByTime(400);
+
+ for (let i = 0; i < 5; i++) {
+ profiler.reportFrameRendered();
+ vi.advanceTimersByTime(20);
+ }
+
+ vi.advanceTimersByTime(600);
+ profiler.checkForIdleFrames();
+
+ expect(profiler.totalIdleFrames).toBe(0);
+ });
+
+ it('should correctly identify mixed idle and non-idle frames', async () => {
+ const startTime = Date.now();
+ vi.setSystemTime(startTime);
+
+ for (let i = 0; i < 3; i++) {
+ profiler.reportFrameRendered();
+ vi.advanceTimersByTime(20);
+ }
+
+ vi.advanceTimersByTime(1000);
+
+ profiler.reportAction();
+ vi.advanceTimersByTime(100);
+
+ for (let i = 0; i < 3; i++) {
+ profiler.reportFrameRendered();
+ vi.advanceTimersByTime(20);
+ }
+
+ vi.advanceTimersByTime(600);
+ profiler.checkForIdleFrames();
+
+ expect(profiler.totalIdleFrames).toBe(3);
+ });
+
+ it('should report flicker frames', () => {
+ const reportActionSpy = vi.spyOn(profiler, 'reportAction');
+ const cleanup = profiler.registerFlickerHandler(true);
+
+ appEvents.emit(AppEvent.Flicker);
+
+ expect(profiler.totalFlickerFrames).toBe(1);
+ expect(reportActionSpy).toHaveBeenCalled();
+
+ cleanup();
+ });
+
+ it('should not report idle frames when actions are interleaved', async () => {
+ const startTime = Date.now();
+ vi.setSystemTime(startTime);
+
+ profiler.reportFrameRendered();
+ vi.advanceTimersByTime(20);
+
+ profiler.reportFrameRendered();
+ vi.advanceTimersByTime(200);
+
+ profiler.reportAction();
+ vi.advanceTimersByTime(200);
+
+ profiler.reportFrameRendered();
+ vi.advanceTimersByTime(20);
+
+ profiler.reportFrameRendered();
+
+ vi.advanceTimersByTime(600);
+ profiler.checkForIdleFrames();
+
+ expect(profiler.totalIdleFrames).toBe(0);
+ });
+
+ it('should not report frames as idle if debugNumAnimatedComponents > 0', async () => {
+ const startTime = Date.now();
+ vi.setSystemTime(startTime);
+ debugState.debugNumAnimatedComponents = 1;
+
+ for (let i = 0; i < 5; i++) {
+ profiler.reportFrameRendered();
+ vi.advanceTimersByTime(20);
+ }
+
+ vi.advanceTimersByTime(1000);
+ profiler.checkForIdleFrames();
+
+ expect(profiler.totalIdleFrames).toBe(0);
+ });
+});
+
+describe('DebugProfiler Component', () => {
+ beforeEach(() => {
+ // Reset the mock implementation before each test
+ vi.mocked(useUIState).mockReturnValue({
+ showDebugProfiler: false,
+ constrainHeight: false,
+ } as unknown as UIState);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should return null when showDebugProfiler is false', async () => {
+ vi.mocked(useUIState).mockReturnValue({
+ showDebugProfiler: false,
+ constrainHeight: false,
+ } as unknown as UIState);
+ const { lastFrame, unmount } = await render();
+ expect(lastFrame({ allowEmpty: true })).toBe('');
+ unmount();
+ });
+
+ it('should render stats when showDebugProfiler is true', async () => {
+ vi.mocked(useUIState).mockReturnValue({
+ showDebugProfiler: true,
+ constrainHeight: false,
+ } as unknown as UIState);
+ profiler.numFrames = 10;
+ profiler.totalIdleFrames = 5;
+ profiler.totalFlickerFrames = 2;
+
+ const { lastFrame, unmount } = await render();
+ const output = lastFrame();
+
+ expect(output).toContain('Renders: 10 (total)');
+ expect(output).toContain('5 (idle)');
+ expect(output).toContain('2 (flicker)');
+ unmount();
+ });
+
+ it('should report an action when a CoreEvent is emitted', async () => {
+ vi.mocked(useUIState).mockReturnValue({
+ showDebugProfiler: true,
+ constrainHeight: false,
+ } as unknown as UIState);
+
+ const reportActionSpy = vi.spyOn(profiler, 'reportAction');
+
+ const { waitUntilReady, unmount } = await render();
+
+ await act(async () => {
+ coreEvents.emitModelChanged('new-model');
+ });
+ await waitUntilReady();
+
+ expect(reportActionSpy).toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should report an action when an AppEvent is emitted', async () => {
+ vi.mocked(useUIState).mockReturnValue({
+ showDebugProfiler: true,
+ constrainHeight: false,
+ } as unknown as UIState);
+
+ const reportActionSpy = vi.spyOn(profiler, 'reportAction');
+
+ const { waitUntilReady, unmount } = await render();
+
+ await act(async () => {
+ appEvents.emit(AppEvent.SelectionWarning);
+ });
+ await waitUntilReady();
+
+ expect(reportActionSpy).toHaveBeenCalled();
+ unmount();
+ });
+});
diff --git a/packages/cli/src/ui/components/DebugProfiler.tsx b/packages/cli/src/ui/components/DebugProfiler.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b162373473507407c1decaea1a7fbe1a766295c0
--- /dev/null
+++ b/packages/cli/src/ui/components/DebugProfiler.tsx
@@ -0,0 +1,243 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Text } from 'ink';
+import { useEffect, useState } from 'react';
+import { FixedDeque } from 'mnemonist';
+import { theme } from '../semantic-colors.js';
+import { useUIState } from '../contexts/UIStateContext.js';
+import { debugState } from '../debug.js';
+import { appEvents, AppEvent } from '../../utils/events.js';
+import { coreEvents, CoreEvent, debugLogger } from '@google/gemini-cli-core';
+
+// Frames that render at least this far before or after an action are considered
+// idle frames.
+const MIN_TIME_FROM_ACTION_TO_BE_IDLE = 500;
+
+export const ACTION_TIMESTAMP_CAPACITY = 2048;
+export const FRAME_TIMESTAMP_CAPACITY = 2048;
+
+// Exported for testing purposes.
+export const profiler = {
+ profilersActive: 0,
+ numFrames: 0,
+ totalIdleFrames: 0,
+ totalFlickerFrames: 0,
+ hasLoggedFirstFlicker: false,
+ lastFrameStartTime: 0,
+ openedDebugConsole: false,
+ lastActionTimestamp: 0,
+
+ possiblyIdleFrameTimestamps: new FixedDeque(
+ Array,
+ FRAME_TIMESTAMP_CAPACITY,
+ ),
+ actionTimestamps: new FixedDeque(Array, ACTION_TIMESTAMP_CAPACITY),
+
+ reportAction() {
+ const now = Date.now();
+ if (now - this.lastActionTimestamp > 16) {
+ if (this.actionTimestamps.size >= ACTION_TIMESTAMP_CAPACITY) {
+ this.actionTimestamps.shift();
+ }
+ this.actionTimestamps.push(now);
+ this.lastActionTimestamp = now;
+ }
+ },
+
+ reportFrameRendered() {
+ if (this.profilersActive === 0) {
+ return;
+ }
+ const now = Date.now();
+ this.lastFrameStartTime = now;
+ this.numFrames++;
+ if (debugState.debugNumAnimatedComponents === 0) {
+ if (this.possiblyIdleFrameTimestamps.size >= FRAME_TIMESTAMP_CAPACITY) {
+ this.possiblyIdleFrameTimestamps.shift();
+ }
+ this.possiblyIdleFrameTimestamps.push(now);
+ } else {
+ // If a spinner is present, consider this an action that both prevents
+ // this frame from being idle and also should prevent a follow on frame
+ // from being considered idle.
+ if (this.actionTimestamps.size >= ACTION_TIMESTAMP_CAPACITY) {
+ this.actionTimestamps.shift();
+ }
+ this.actionTimestamps.push(now);
+ }
+ },
+
+ checkForIdleFrames() {
+ const now = Date.now();
+ const judgementCutoff = now - MIN_TIME_FROM_ACTION_TO_BE_IDLE;
+ const oneSecondIntervalFromJudgementCutoff = judgementCutoff - 1000;
+
+ let idleInPastSecond = 0;
+
+ while (
+ this.possiblyIdleFrameTimestamps.size > 0 &&
+ this.possiblyIdleFrameTimestamps.peekFirst()! <= judgementCutoff
+ ) {
+ const frameTime = this.possiblyIdleFrameTimestamps.shift()!;
+ const start = frameTime - MIN_TIME_FROM_ACTION_TO_BE_IDLE;
+ const end = frameTime + MIN_TIME_FROM_ACTION_TO_BE_IDLE;
+
+ while (
+ this.actionTimestamps.size > 0 &&
+ this.actionTimestamps.peekFirst()! < start
+ ) {
+ this.actionTimestamps.shift();
+ }
+
+ const hasAction =
+ this.actionTimestamps.size > 0 &&
+ this.actionTimestamps.peekFirst()! <= end;
+
+ if (!hasAction) {
+ if (frameTime >= oneSecondIntervalFromJudgementCutoff) {
+ idleInPastSecond++;
+ }
+ this.totalIdleFrames++;
+ }
+ }
+
+ if (idleInPastSecond >= 5) {
+ if (this.openedDebugConsole === false) {
+ this.openedDebugConsole = true;
+ appEvents.emit(AppEvent.OpenDebugConsole);
+ }
+ debugLogger.error(
+ `${idleInPastSecond} frames rendered while the app was ` +
+ `idle in the past second. This likely indicates severe infinite loop ` +
+ `React state management bugs.`,
+ );
+ }
+ },
+
+ registerFlickerHandler(constrainHeight: boolean) {
+ const flickerHandler = () => {
+ // If we are not constraining the height, we are intentionally
+ // overflowing the screen.
+ if (!constrainHeight) {
+ return;
+ }
+
+ this.totalFlickerFrames++;
+ this.reportAction();
+
+ if (!this.hasLoggedFirstFlicker) {
+ this.hasLoggedFirstFlicker = true;
+ debugLogger.error(
+ 'A flicker frame was detected. This will cause UI instability. Type `/profile` for more info.',
+ );
+ }
+ };
+ appEvents.on(AppEvent.Flicker, flickerHandler);
+ return () => {
+ appEvents.off(AppEvent.Flicker, flickerHandler);
+ };
+ },
+};
+
+export const DebugProfiler = () => {
+ const { showDebugProfiler, constrainHeight } = useUIState();
+ const [forceRefresh, setForceRefresh] = useState(0);
+
+ // Effect for listening to stdin for keypresses and stdout for resize events.
+ useEffect(() => {
+ profiler.profilersActive++;
+ const stdin = process.stdin;
+ const stdout = process.stdout;
+
+ const handler = () => {
+ profiler.reportAction();
+ };
+
+ stdin.on('data', handler);
+ stdout.on('resize', handler);
+
+ // Register handlers for all core and app events to ensure they are
+ // considered "actions" and don't trigger spurious idle frame warnings.
+ // These events are expected to trigger UI renders.
+ for (const eventName of Object.values(CoreEvent)) {
+ coreEvents.on(eventName, handler);
+ }
+
+ for (const eventName of Object.values(AppEvent)) {
+ appEvents.on(eventName, handler);
+ }
+
+ // Register handlers for extension lifecycle events emitted on coreEvents
+ // but not part of the CoreEvent enum, to prevent false-positive idle warnings.
+ const extensionEvents = [
+ 'extensionsStarting',
+ 'extensionsStopping',
+ ] as const;
+ for (const eventName of extensionEvents) {
+ coreEvents.on(eventName, handler);
+ }
+
+ return () => {
+ stdin.off('data', handler);
+ stdout.off('resize', handler);
+
+ for (const eventName of Object.values(CoreEvent)) {
+ coreEvents.off(eventName, handler);
+ }
+
+ for (const eventName of Object.values(AppEvent)) {
+ appEvents.off(eventName, handler);
+ }
+
+ for (const eventName of extensionEvents) {
+ coreEvents.off(eventName, handler);
+ }
+
+ profiler.profilersActive--;
+ };
+ }, []);
+
+ useEffect(() => {
+ const updateInterval = setInterval(() => {
+ profiler.checkForIdleFrames();
+ }, 1000);
+ return () => clearInterval(updateInterval);
+ }, []);
+
+ useEffect(
+ () => profiler.registerFlickerHandler(constrainHeight),
+ [constrainHeight],
+ );
+
+ // Effect for updating stats
+ useEffect(() => {
+ if (!showDebugProfiler) {
+ return;
+ }
+ // Only update the UX infrequently as updating the UX itself will cause
+ // frames to run so can disturb what we are measuring.
+ const forceRefreshInterval = setInterval(() => {
+ setForceRefresh((f) => f + 1);
+ profiler.reportAction();
+ }, 4000);
+ return () => clearInterval(forceRefreshInterval);
+ }, [showDebugProfiler]);
+
+ if (!showDebugProfiler) {
+ return null;
+ }
+
+ return (
+
+ Renders: {profiler.numFrames} (total),{' '}
+ {profiler.totalIdleFrames} (idle),{' '}
+
+ {profiler.totalFlickerFrames} (flicker)
+
+
+ );
+};
diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6cb61ea95cf83c3ea20da4c03cb4b4392653ad7d
--- /dev/null
+++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx
@@ -0,0 +1,121 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { renderWithProviders } from '../../test-utils/render.js';
+import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import type { ConsoleMessageItem } from '../types.js';
+import { Box } from 'ink';
+import type React from 'react';
+import { createMockSettings } from '../../test-utils/settings.js';
+import { useConsoleMessages } from '../hooks/useConsoleMessages.js';
+
+vi.mock('../hooks/useConsoleMessages.js', () => ({
+ useConsoleMessages: vi.fn(),
+}));
+
+vi.mock('./shared/ScrollableList.js', () => ({
+ ScrollableList: ({
+ data,
+ renderItem,
+ }: {
+ data: unknown[];
+ renderItem: (props: { item: unknown }) => React.ReactNode;
+ }) => (
+
+ {data.map((item: unknown, index: number) => (
+ {renderItem({ item })}
+ ))}
+
+ ),
+}));
+
+describe('DetailedMessagesDisplay', () => {
+ beforeEach(() => {
+ vi.mocked(useConsoleMessages).mockReturnValue([]);
+ });
+ it('renders nothing when messages are empty', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
+ },
+ );
+ expect(lastFrame({ allowEmpty: true })).toBe('');
+ unmount();
+ });
+
+ it('renders messages correctly', async () => {
+ const messages: ConsoleMessageItem[] = [
+ { type: 'log', content: 'Log message', count: 1 },
+ { type: 'warn', content: 'Warning message', count: 1 },
+ { type: 'error', content: 'Error message', count: 1 },
+ { type: 'debug', content: 'Debug message', count: 1 },
+ ];
+ vi.mocked(useConsoleMessages).mockReturnValue(messages);
+
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
+ },
+ );
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ unmount();
+ });
+
+ it('shows the F12 hint even in low error verbosity mode', async () => {
+ const messages: ConsoleMessageItem[] = [
+ { type: 'error', content: 'Error message', count: 1 },
+ ];
+ vi.mocked(useConsoleMessages).mockReturnValue(messages);
+
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
+ },
+ );
+ expect(lastFrame()).toContain('(F12 to close)');
+ unmount();
+ });
+
+ it('shows the F12 hint in full error verbosity mode', async () => {
+ const messages: ConsoleMessageItem[] = [
+ { type: 'error', content: 'Error message', count: 1 },
+ ];
+ vi.mocked(useConsoleMessages).mockReturnValue(messages);
+
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
+ },
+ );
+ expect(lastFrame()).toContain('(F12 to close)');
+ unmount();
+ });
+
+ it('renders message counts', async () => {
+ const messages: ConsoleMessageItem[] = [
+ { type: 'log', content: 'Repeated message', count: 5 },
+ ];
+ vi.mocked(useConsoleMessages).mockReturnValue(messages);
+
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
+ },
+ );
+ const output = lastFrame();
+
+ expect(output).toMatchSnapshot();
+ unmount();
+ });
+});
diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..97e456eb991d36e6f513264f128e8f9cdc20f82f
--- /dev/null
+++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx
@@ -0,0 +1,131 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { useRef, useCallback, useMemo } from 'react';
+import { Box, Text } from 'ink';
+import { theme } from '../semantic-colors.js';
+import type { ConsoleMessageItem } from '../types.js';
+import {
+ ScrollableList,
+ type ScrollableListRef,
+} from './shared/ScrollableList.js';
+import { useConsoleMessages } from '../hooks/useConsoleMessages.js';
+import { useConfig } from '../contexts/ConfigContext.js';
+
+interface DetailedMessagesDisplayProps {
+ maxHeight: number | undefined;
+ width: number;
+ hasFocus: boolean;
+}
+
+const iconBoxWidth = 3;
+
+export const DetailedMessagesDisplay: React.FC<
+ DetailedMessagesDisplayProps
+> = ({ maxHeight, width, hasFocus }) => {
+ const scrollableListRef = useRef>(null);
+
+ const consoleMessages = useConsoleMessages();
+ const config = useConfig();
+
+ const messages = useMemo(() => {
+ if (config.getDebugMode()) {
+ return consoleMessages;
+ }
+ return consoleMessages.filter((msg) => msg.type !== 'debug');
+ }, [consoleMessages, config]);
+
+ const borderAndPadding = 3;
+
+ const estimatedItemHeight = useCallback(
+ (index: number) => {
+ const msg = messages[index];
+ if (!msg) {
+ return 1;
+ }
+ const textWidth = width - borderAndPadding - iconBoxWidth;
+ if (textWidth <= 0) {
+ return 1;
+ }
+ const lines = Math.ceil((msg.content?.length || 1) / textWidth);
+ return Math.max(1, lines);
+ },
+ [width, messages],
+ );
+
+ if (messages.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+ Debug Console (F12 to close)
+
+
+
+ {
+ let textColor = theme.text.primary;
+ let icon = 'ℹ'; // Information source (ℹ)
+
+ switch (msg.type) {
+ case 'warn':
+ textColor = theme.status.warning;
+ icon = '⚠'; // Warning sign (⚠)
+ break;
+ case 'error':
+ textColor = theme.status.error;
+ icon = '✖'; // Heavy multiplication x (✖)
+ break;
+ case 'debug':
+ textColor = theme.text.secondary; // Or theme.text.secondary
+ icon = '🔍'; // Left-pointing magnifying glass (🔍)
+ break;
+ case 'log':
+ default:
+ // Default textColor and icon are already set
+ break;
+ }
+
+ return (
+
+
+ {icon}
+
+
+ {msg.content}
+ {msg.count && msg.count > 1 && (
+ (x{msg.count})
+ )}
+
+
+ );
+ }}
+ keyExtractor={(item, index) => `${item.content}-${index}`}
+ estimatedItemHeight={estimatedItemHeight}
+ hasFocus={hasFocus}
+ initialScrollIndex={Number.MAX_SAFE_INTEGER}
+ />
+
+
+ );
+};
diff --git a/packages/cli/src/ui/components/DialogManager.test.tsx b/packages/cli/src/ui/components/DialogManager.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6acc76303c01fc3a334ebe0d84435e828353c147
--- /dev/null
+++ b/packages/cli/src/ui/components/DialogManager.test.tsx
@@ -0,0 +1,204 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { renderWithProviders } from '../../test-utils/render.js';
+import { DialogManager } from './DialogManager.js';
+import { describe, it, expect, vi } from 'vitest';
+import { Text } from 'ink';
+import { type UIState } from '../contexts/UIStateContext.js';
+import { type QuotaState } from '../contexts/QuotaContext.js';
+import { type RestartReason } from '../hooks/useIdeTrustListener.js';
+import { type IdeInfo } from '@google/gemini-cli-core';
+
+// Mock child components
+vi.mock('../IdeIntegrationNudge.js', () => ({
+ IdeIntegrationNudge: () => IdeIntegrationNudge,
+}));
+vi.mock('./LoopDetectionConfirmation.js', () => ({
+ LoopDetectionConfirmation: () => LoopDetectionConfirmation,
+}));
+vi.mock('./FolderTrustDialog.js', () => ({
+ FolderTrustDialog: () => FolderTrustDialog,
+}));
+vi.mock('./ConsentPrompt.js', () => ({
+ ConsentPrompt: () => ConsentPrompt,
+}));
+vi.mock('./ThemeDialog.js', () => ({
+ ThemeDialog: () => ThemeDialog,
+}));
+vi.mock('./SettingsDialog.js', () => ({
+ SettingsDialog: () => SettingsDialog,
+}));
+vi.mock('../auth/AuthInProgress.js', () => ({
+ AuthInProgress: () => AuthInProgress,
+}));
+vi.mock('../auth/AuthDialog.js', () => ({
+ AuthDialog: () => AuthDialog,
+}));
+vi.mock('../auth/ApiAuthDialog.js', () => ({
+ ApiAuthDialog: () => ApiAuthDialog,
+}));
+vi.mock('./EditorSettingsDialog.js', () => ({
+ EditorSettingsDialog: () => EditorSettingsDialog,
+}));
+vi.mock('../privacy/PrivacyNotice.js', () => ({
+ PrivacyNotice: () => PrivacyNotice,
+}));
+vi.mock('./ProQuotaDialog.js', () => ({
+ ProQuotaDialog: () => ProQuotaDialog,
+}));
+vi.mock('./PermissionsModifyTrustDialog.js', () => ({
+ PermissionsModifyTrustDialog: () => PermissionsModifyTrustDialog,
+}));
+vi.mock('./ModelDialog.js', () => ({
+ ModelDialog: () => ModelDialog,
+}));
+vi.mock('./IdeTrustChangeDialog.js', () => ({
+ IdeTrustChangeDialog: () => IdeTrustChangeDialog,
+}));
+vi.mock('./AgentConfigDialog.js', () => ({
+ AgentConfigDialog: () => AgentConfigDialog,
+}));
+
+describe('DialogManager', () => {
+ const defaultProps = {
+ addItem: vi.fn(),
+ terminalWidth: 100,
+ };
+
+ const baseUiState = {
+ constrainHeight: false,
+ terminalHeight: 24,
+ staticExtraHeight: 0,
+ terminalWidth: 80,
+ confirmUpdateExtensionRequests: [],
+ showIdeRestartPrompt: false,
+ shouldShowIdePrompt: false,
+ isFolderTrustDialogOpen: false,
+ loopDetectionConfirmationRequest: null,
+ confirmationRequest: null,
+ consentRequest: null,
+ isThemeDialogOpen: false,
+ isSettingsDialogOpen: false,
+ isModelDialogOpen: false,
+ isAuthenticating: false,
+ isAwaitingApiKeyInput: false,
+ isAuthDialogOpen: false,
+ isEditorDialogOpen: false,
+ showPrivacyNotice: false,
+ isPermissionsDialogOpen: false,
+ isAgentConfigDialogOpen: false,
+ selectedAgentName: undefined,
+ selectedAgentDisplayName: undefined,
+ selectedAgentDefinition: undefined,
+ };
+
+ it('renders nothing by default', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ { uiState: baseUiState as Partial as UIState },
+ );
+ expect(lastFrame({ allowEmpty: true })).toBe('');
+ unmount();
+ });
+
+ const testCases: Array<[Partial, string, Partial?]> = [
+ [
+ {
+ showIdeRestartPrompt: true,
+ ideTrustRestartReason: 'update' as RestartReason,
+ },
+ 'IdeTrustChangeDialog',
+ ],
+ [
+ {},
+ 'ProQuotaDialog',
+ {
+ proQuotaRequest: {
+ failedModel: 'a',
+ fallbackModel: 'b',
+ message: 'c',
+ isTerminalQuotaError: false,
+ resolve: vi.fn(),
+ },
+ },
+ ],
+ [
+ {
+ shouldShowIdePrompt: true,
+ currentIDE: { name: 'vscode', version: '1.0' } as unknown as IdeInfo,
+ },
+ 'IdeIntegrationNudge',
+ ],
+ [{ isFolderTrustDialogOpen: true }, 'FolderTrustDialog'],
+ [
+ { loopDetectionConfirmationRequest: { onComplete: vi.fn() } },
+ 'LoopDetectionConfirmation',
+ ],
+ [
+ { commandConfirmationRequest: { prompt: 'foo', onConfirm: vi.fn() } },
+ 'ConsentPrompt',
+ ],
+ [
+ { authConsentRequest: { prompt: 'bar', onConfirm: vi.fn() } },
+ 'ConsentPrompt',
+ ],
+ [
+ {
+ confirmUpdateExtensionRequests: [{ prompt: 'foo', onConfirm: vi.fn() }],
+ },
+ 'ConsentPrompt',
+ ],
+ [{ isThemeDialogOpen: true }, 'ThemeDialog'],
+ [{ isSettingsDialogOpen: true }, 'SettingsDialog'],
+ [{ isModelDialogOpen: true }, 'ModelDialog'],
+ [{ isAuthenticating: true }, 'AuthInProgress'],
+ [{ isAwaitingApiKeyInput: true }, 'ApiAuthDialog'],
+ [{ isAuthDialogOpen: true }, 'AuthDialog'],
+ [{ isEditorDialogOpen: true }, 'EditorSettingsDialog'],
+ [{ showPrivacyNotice: true }, 'PrivacyNotice'],
+ [{ isPermissionsDialogOpen: true }, 'PermissionsModifyTrustDialog'],
+ [
+ {
+ isAgentConfigDialogOpen: true,
+ selectedAgentName: 'test-agent',
+ selectedAgentDisplayName: 'Test Agent',
+ selectedAgentDefinition: {
+ name: 'test-agent',
+ kind: 'local',
+ description: 'Test agent',
+ inputConfig: { inputSchema: {} },
+ promptConfig: { systemPrompt: 'test' },
+ modelConfig: { model: 'inherit' },
+ runConfig: { maxTimeMinutes: 5 },
+ },
+ },
+ 'AgentConfigDialog',
+ ],
+ ];
+
+ it.each(testCases)(
+ 'renders %s when state is %o',
+ async (
+ uiStateOverride: Partial,
+ expectedComponent: string,
+ quotaStateOverride?: Partial,
+ ) => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ uiState: {
+ ...baseUiState,
+ ...uiStateOverride,
+ } as Partial as UIState,
+ quotaState: quotaStateOverride,
+ },
+ );
+ expect(lastFrame()).toContain(expectedComponent);
+ unmount();
+ },
+ );
+});
diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..1b5c00c3723cf381f8474446f3f30a561edf7908
--- /dev/null
+++ b/packages/cli/src/ui/components/DialogManager.tsx
@@ -0,0 +1,382 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Box, Text } from 'ink';
+import { IdeIntegrationNudge } from '../IdeIntegrationNudge.js';
+import { LoopDetectionConfirmation } from './LoopDetectionConfirmation.js';
+import { FolderTrustDialog } from './FolderTrustDialog.js';
+import { ConsentPrompt } from './ConsentPrompt.js';
+import { ThemeDialog } from './ThemeDialog.js';
+import { SettingsDialog } from './SettingsDialog.js';
+import { AuthInProgress } from '../auth/AuthInProgress.js';
+import { AuthDialog } from '../auth/AuthDialog.js';
+import { BannedAccountDialog } from '../auth/BannedAccountDialog.js';
+import { ApiAuthDialog } from '../auth/ApiAuthDialog.js';
+import { EditorSettingsDialog } from './EditorSettingsDialog.js';
+import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
+import { ProQuotaDialog } from './ProQuotaDialog.js';
+import { ValidationDialog } from './ValidationDialog.js';
+import { OverageMenuDialog } from './OverageMenuDialog.js';
+import { EmptyWalletDialog } from './EmptyWalletDialog.js';
+import { relaunchApp } from '../../utils/processUtils.js';
+import { SessionBrowser } from './SessionBrowser.js';
+import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
+import { ModelDialog } from './ModelDialog.js';
+import { VoiceModelDialog } from './VoiceModelDialog.js';
+import { theme } from '../semantic-colors.js';
+import { useUIState } from '../contexts/UIStateContext.js';
+import { useQuotaState } from '../contexts/QuotaContext.js';
+import { useUIActions } from '../contexts/UIActionsContext.js';
+import { useConfig } from '../contexts/ConfigContext.js';
+import { useSettings } from '../contexts/SettingsContext.js';
+import process from 'node:process';
+import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
+import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js';
+import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
+import { NewAgentsNotification } from './NewAgentsNotification.js';
+import { AgentConfigDialog } from './AgentConfigDialog.js';
+import { PolicyUpdateDialog } from './PolicyUpdateDialog.js';
+import { LoginRestartDialog } from '../auth/LoginRestartDialog.js';
+
+interface DialogManagerProps {
+ addItem: UseHistoryManagerReturn['addItem'];
+ terminalWidth: number;
+}
+
+// Props for DialogManager
+export const DialogManager = ({
+ addItem,
+ terminalWidth,
+}: DialogManagerProps) => {
+ const config = useConfig();
+ const settings = useSettings();
+
+ const uiState = useUIState();
+ const quotaState = useQuotaState();
+ const uiActions = useUIActions();
+ const {
+ constrainHeight,
+ terminalHeight,
+ staticExtraHeight,
+ terminalWidth: uiTerminalWidth,
+ } = uiState;
+
+ if (uiState.adminSettingsChanged) {
+ return ;
+ }
+ if (uiState.showIdeRestartPrompt) {
+ return ;
+ }
+ if (uiState.newAgents) {
+ return (
+
+ );
+ }
+ if (quotaState.proQuotaRequest) {
+ return (
+
+ );
+ }
+ if (quotaState.validationRequest) {
+ return (
+
+ );
+ }
+ if (quotaState.overageMenuRequest) {
+ return (
+
+ );
+ }
+ if (quotaState.emptyWalletRequest) {
+ return (
+
+ );
+ }
+ if (uiState.shouldShowIdePrompt) {
+ return (
+
+ );
+ }
+ if (uiState.isFolderTrustDialogOpen) {
+ return (
+
+ );
+ }
+ if (uiState.isPolicyUpdateDialogOpen) {
+ return (
+ uiActions.setIsPolicyUpdateDialogOpen(false)}
+ />
+ );
+ }
+ if (uiState.loopDetectionConfirmationRequest) {
+ return (
+
+ );
+ }
+
+ if (uiState.permissionConfirmationRequest) {
+ const files = uiState.permissionConfirmationRequest.files;
+ const filesList = files.map((f) => `- ${f}`).join('\n');
+ return (
+ {
+ uiState.permissionConfirmationRequest?.onComplete({ allowed });
+ }}
+ terminalWidth={terminalWidth}
+ />
+ );
+ }
+
+ // commandConfirmationRequest and authConsentRequest are kept separate
+ // to avoid focus deadlocks and state race conditions between the
+ // synchronous command loop and the asynchronous auth flow.
+ if (uiState.commandConfirmationRequest) {
+ return (
+
+ );
+ }
+ if (uiState.authConsentRequest) {
+ return (
+
+ );
+ }
+ if (uiState.confirmUpdateExtensionRequests.length > 0) {
+ const request = uiState.confirmUpdateExtensionRequests[0];
+ return (
+
+ );
+ }
+ if (uiState.isThemeDialogOpen) {
+ return (
+
+ {uiState.themeError && (
+
+ {uiState.themeError}
+
+ )}
+
+
+ );
+ }
+ if (uiState.isSettingsDialogOpen) {
+ return (
+
+ uiActions.closeSettingsDialog()}
+ onRestartRequest={relaunchApp}
+ availableTerminalHeight={terminalHeight - staticExtraHeight}
+ />
+
+ );
+ }
+ if (uiState.isModelDialogOpen) {
+ return ;
+ }
+ if (uiState.isVoiceModelDialogOpen) {
+ return ;
+ }
+ if (
+ uiState.isAgentConfigDialogOpen &&
+ uiState.selectedAgentName &&
+ uiState.selectedAgentDisplayName &&
+ uiState.selectedAgentDefinition
+ ) {
+ return (
+
+ {
+ // Reload agent registry to pick up changes
+ const agentRegistry = config?.getAgentRegistry();
+ if (agentRegistry) {
+ await agentRegistry.reload();
+ }
+ }}
+ />
+
+ );
+ }
+ if (uiState.accountSuspensionInfo) {
+ return (
+
+ {
+ process.exit(1);
+ }}
+ onChangeAuth={() => {
+ uiActions.clearAccountSuspension();
+ }}
+ />
+
+ );
+ }
+ if (uiState.isAuthenticating) {
+ return (
+ {
+ uiActions.onAuthError('Authentication cancelled.');
+ }}
+ />
+ );
+ }
+ if (uiState.isAwaitingApiKeyInput) {
+ return (
+
+
+
+ );
+ }
+
+ if (uiState.isAwaitingLoginRestart) {
+ return (
+
+
+
+ );
+ }
+ if (uiState.isAuthDialogOpen) {
+ return (
+
+
+
+ );
+ }
+ if (uiState.isEditorDialogOpen) {
+ return (
+
+ {uiState.editorError && (
+
+ {uiState.editorError}
+
+ )}
+
+
+ );
+ }
+ if (uiState.showPrivacyNotice) {
+ return (
+ uiActions.exitPrivacyNotice()}
+ config={config}
+ />
+ );
+ }
+ if (uiState.isSessionBrowserOpen) {
+ return (
+
+ );
+ }
+
+ if (uiState.isPermissionsDialogOpen) {
+ return (
+
+ );
+ }
+
+ return null;
+};
diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7646ac4817bd4eeff45ae78ac2034710ee9710a1
--- /dev/null
+++ b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx
@@ -0,0 +1,219 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { renderWithProviders } from '../../test-utils/render.js';
+import { EditorSettingsDialog } from './EditorSettingsDialog.js';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { SettingScope, type LoadedSettings } from '../../config/settings.js';
+import { act } from 'react';
+import { waitFor } from '../../test-utils/async.js';
+import { debugLogger } from '@google/gemini-cli-core';
+
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ isEditorAvailable: () => true, // Mock to behave predictably in CI
+ };
+});
+
+// Mock editorSettingsManager
+vi.mock('../editors/editorSettingsManager.js', () => ({
+ editorSettingsManager: {
+ getAvailableEditorDisplays: () => [
+ { name: 'VS Code', type: 'vscode', disabled: false },
+ { name: 'Vim', type: 'vim', disabled: false },
+ ],
+ },
+}));
+
+describe('EditorSettingsDialog', () => {
+ const mockSettings = {
+ forScope: (scope: string) => ({
+ settings: {
+ general: {
+ preferredEditor: scope === SettingScope.User ? 'vscode' : undefined,
+ },
+ },
+ }),
+ merged: {
+ general: {
+ preferredEditor: 'vscode',
+ },
+ },
+ } as unknown as LoadedSettings;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ const renderWithProvider = async (ui: React.ReactElement) =>
+ renderWithProviders(ui);
+
+ it('renders correctly', async () => {
+ const { lastFrame } = await renderWithProvider(
+ ,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('calls onSelect when an editor is selected', async () => {
+ const onSelect = vi.fn();
+ const { lastFrame } = await renderWithProvider(
+ ,
+ );
+
+ expect(lastFrame()).toContain('VS Code');
+ });
+
+ it('switches focus between editor and scope sections on Tab', async () => {
+ const { lastFrame, stdin, waitUntilReady } = await renderWithProvider(
+ ,
+ );
+
+ // Initial focus on editor
+ expect(lastFrame()).toContain('> Select Editor');
+ expect(lastFrame()).not.toContain('> Apply To');
+
+ // Press Tab
+ await act(async () => {
+ stdin.write('\t');
+ });
+ await waitUntilReady();
+
+ // Focus should be on scope
+ await waitFor(() => {
+ const frame = lastFrame() || '';
+ if (!frame.includes('> Apply To')) {
+ debugLogger.debug(
+ 'Waiting for scope focus. Current frame:',
+ JSON.stringify(frame),
+ );
+ }
+ expect(frame).toContain('> Apply To');
+ });
+ expect(lastFrame()).toContain(' Select Editor');
+
+ // Press Tab again
+ await act(async () => {
+ stdin.write('\t');
+ });
+ await waitUntilReady();
+
+ // Focus should be back on editor
+ await waitFor(() => {
+ expect(lastFrame()).toContain('> Select Editor');
+ });
+ });
+
+ it('calls onExit when Escape is pressed', async () => {
+ const onExit = vi.fn();
+ const { stdin, waitUntilReady } = await renderWithProvider(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\u001B'); // Escape
+ });
+ await waitUntilReady();
+
+ await waitFor(() => {
+ expect(onExit).toHaveBeenCalled();
+ });
+ });
+
+ it('shows modified message when setting exists in other scope', async () => {
+ const settingsWithOtherScope = {
+ forScope: (_scope: string) => ({
+ settings: {
+ general: {
+ preferredEditor: 'vscode', // Both scopes have it set
+ },
+ },
+ }),
+ merged: {
+ general: {
+ preferredEditor: 'vscode',
+ },
+ },
+ } as unknown as LoadedSettings;
+
+ const { lastFrame } = await renderWithProvider(
+ ,
+ );
+
+ const frame = lastFrame() || '';
+ if (!frame.includes('(Also modified')) {
+ debugLogger.debug(
+ 'Modified message test failure. Frame:',
+ JSON.stringify(frame),
+ );
+ }
+ expect(frame).toContain('(Also modified');
+ });
+
+ it('emits error feedback only once when preferredEditor is invalid', async () => {
+ const mockEmitFeedback = vi.fn();
+ vi.spyOn(
+ await import('@google/gemini-cli-core').then((m) => m.coreEvents),
+ 'emitFeedback',
+ ).mockImplementation(mockEmitFeedback);
+
+ const invalidSettings = {
+ forScope: (_scope: string) => ({
+ settings: {
+ general: {
+ preferredEditor: 'invalideditor',
+ },
+ },
+ }),
+ merged: {
+ general: {
+ preferredEditor: 'invalideditor',
+ },
+ },
+ } as unknown as LoadedSettings;
+
+ const { unmount } = await renderWithProvider(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(mockEmitFeedback).toHaveBeenCalledWith(
+ 'error',
+ 'Editor is not supported: invalideditor',
+ );
+ });
+
+ expect(mockEmitFeedback).toHaveBeenCalledTimes(1);
+ unmount();
+ });
+});
diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a98042773f20c7352733ffb07d7e2f1289ee7396
--- /dev/null
+++ b/packages/cli/src/ui/components/EditorSettingsDialog.tsx
@@ -0,0 +1,216 @@
+/**
+ * @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 { theme } from '../semantic-colors.js';
+import {
+ editorSettingsManager,
+ type EditorDisplay,
+} from '../editors/editorSettingsManager.js';
+import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
+import {
+ SettingScope,
+ type LoadableSettingScope,
+ type LoadedSettings,
+} from '../../config/settings.js';
+import {
+ type EditorType,
+ isEditorAvailable,
+ EDITOR_DISPLAY_NAMES,
+ coreEvents,
+} from '@google/gemini-cli-core';
+import { useKeypress } from '../hooks/useKeypress.js';
+
+interface EditorDialogProps {
+ onSelect: (
+ editorType: EditorType | undefined,
+ scope: LoadableSettingScope,
+ ) => void;
+ settings: LoadedSettings;
+ onExit: () => void;
+}
+
+export function EditorSettingsDialog({
+ onSelect,
+ settings,
+ onExit,
+}: EditorDialogProps): React.JSX.Element {
+ const [selectedScope, setSelectedScope] = useState(
+ SettingScope.User,
+ );
+ const [focusedSection, setFocusedSection] = useState<'editor' | 'scope'>(
+ 'editor',
+ );
+ useKeypress(
+ (key) => {
+ if (key.name === 'tab') {
+ setFocusedSection((prev) => (prev === 'editor' ? 'scope' : 'editor'));
+ return true;
+ }
+ if (key.name === 'escape') {
+ onExit();
+ return true;
+ }
+ return false;
+ },
+ { isActive: true },
+ );
+
+ const editorItems: EditorDisplay[] =
+ editorSettingsManager.getAvailableEditorDisplays();
+
+ const currentPreference =
+ settings.forScope(selectedScope).settings.general?.preferredEditor;
+ let editorIndex = currentPreference
+ ? editorItems.findIndex(
+ (item: EditorDisplay) => item.type === currentPreference,
+ )
+ : 0;
+ const isUnsupportedEditor = editorIndex === -1;
+ if (isUnsupportedEditor) {
+ editorIndex = 0;
+ }
+
+ useEffect(() => {
+ if (isUnsupportedEditor && currentPreference) {
+ coreEvents.emitFeedback(
+ 'error',
+ `Editor is not supported: ${currentPreference}`,
+ );
+ }
+ }, [isUnsupportedEditor, currentPreference]);
+
+ const scopeItems: Array<{
+ label: string;
+ value: LoadableSettingScope;
+ key: string;
+ }> = [
+ {
+ label: 'User Settings',
+ value: SettingScope.User,
+ key: SettingScope.User,
+ },
+ {
+ label: 'Workspace Settings',
+ value: SettingScope.Workspace,
+ key: SettingScope.Workspace,
+ },
+ ];
+
+ const handleEditorSelect = (editorType: EditorType | 'not_set') => {
+ if (editorType === 'not_set') {
+ onSelect(undefined, selectedScope);
+ return;
+ }
+ onSelect(editorType, selectedScope);
+ };
+
+ const handleScopeSelect = (scope: LoadableSettingScope) => {
+ setSelectedScope(scope);
+ setFocusedSection('editor');
+ };
+
+ let otherScopeModifiedMessage = '';
+ const otherScope =
+ selectedScope === SettingScope.User
+ ? SettingScope.Workspace
+ : SettingScope.User;
+ if (
+ settings.forScope(otherScope).settings.general?.preferredEditor !==
+ undefined
+ ) {
+ otherScopeModifiedMessage =
+ settings.forScope(selectedScope).settings.general?.preferredEditor !==
+ undefined
+ ? `(Also modified in ${otherScope})`
+ : `(Modified in ${otherScope})`;
+ }
+
+ let mergedEditorName = 'None';
+ if (
+ settings.merged.general.preferredEditor &&
+ isEditorAvailable(settings.merged.general.preferredEditor)
+ ) {
+ mergedEditorName =
+ EDITOR_DISPLAY_NAMES[settings.merged.general.preferredEditor];
+ }
+
+ return (
+
+
+
+ {focusedSection === 'editor' ? '> ' : ' '}Select Editor{' '}
+ {otherScopeModifiedMessage}
+
+ ({
+ label: item.name,
+ value: item.type,
+ disabled: item.disabled,
+ key: item.type,
+ }))}
+ initialIndex={editorIndex}
+ onSelect={handleEditorSelect}
+ isFocused={focusedSection === 'editor'}
+ key={selectedScope}
+ maxItemsToShow={editorItems.length}
+ />
+
+
+
+ {focusedSection === 'scope' ? '> ' : ' '}Apply To
+
+
+
+
+
+
+ (Use Enter to select, Tab to change focus, Esc to close)
+
+
+
+
+
+
+ Editor Preference
+
+
+
+ These editors are currently supported. Please note that some editors
+ cannot be used in sandbox mode.
+
+
+ Your preferred editor is:{' '}
+
+ {mergedEditorName}
+
+ .
+
+
+
+
+ );
+}
diff --git a/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx b/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..74de1a8a417dd7b33d74a540d2fedf2e85925306
--- /dev/null
+++ b/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx
@@ -0,0 +1,207 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { renderWithProviders } from '../../test-utils/render.js';
+import { waitFor } from '../../test-utils/async.js';
+import { act } from 'react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { EmptyWalletDialog } from './EmptyWalletDialog.js';
+
+const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
+ act(() => {
+ stdin.write(key);
+ });
+};
+
+describe('EmptyWalletDialog', () => {
+ const mockOnChoice = vi.fn();
+ const mockOnGetCredits = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe('rendering', () => {
+ it('should match snapshot with fallback available', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
+ it('should match snapshot without fallback', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
+ it('should display the model name and usage limit message', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+
+ const output = lastFrame() ?? '';
+ expect(output).toContain('gemini-2.5-pro');
+ expect(output).toContain('Usage limit reached');
+ unmount();
+ });
+
+ it('should display purchase prompt and credits update notice', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+
+ const output = lastFrame() ?? '';
+ expect(output).toContain('purchase more AI Credits');
+ expect(output).toContain(
+ 'Newly purchased AI credits may take a few minutes to update',
+ );
+ unmount();
+ });
+
+ it('should display reset time when provided', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+
+ const output = lastFrame() ?? '';
+ expect(output).toContain('3:45 PM');
+ expect(output).toContain('Access resets at');
+ unmount();
+ });
+
+ it('should not display reset time when not provided', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+
+ const output = lastFrame() ?? '';
+ expect(output).not.toContain('Access resets at');
+ unmount();
+ });
+
+ it('should display slash command hints', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+
+ const output = lastFrame() ?? '';
+ expect(output).toContain('/stats');
+ expect(output).toContain('/model');
+ expect(output).toContain('/auth');
+ unmount();
+ });
+ });
+
+ describe('onChoice handling', () => {
+ it('should call onGetCredits and onChoice when get_credits is selected', async () => {
+ // get_credits is the first item, so just press Enter
+ const { unmount, stdin } = await renderWithProviders(
+ ,
+ );
+
+ writeKey(stdin, '\r');
+
+ await waitFor(() => {
+ expect(mockOnGetCredits).toHaveBeenCalled();
+ expect(mockOnChoice).toHaveBeenCalledWith('get_credits');
+ });
+ unmount();
+ });
+
+ it('should call onChoice without onGetCredits when onGetCredits is not provided', async () => {
+ const { unmount, stdin } = await renderWithProviders(
+ ,
+ );
+
+ writeKey(stdin, '\r');
+
+ await waitFor(() => {
+ expect(mockOnChoice).toHaveBeenCalledWith('get_credits');
+ });
+ unmount();
+ });
+
+ it('should call onChoice with use_fallback when selected', async () => {
+ // With fallback: items are [get_credits, use_fallback, stop]
+ // use_fallback is the second item: Down + Enter
+ const { unmount, stdin } = await renderWithProviders(
+ ,
+ );
+
+ writeKey(stdin, '\x1b[B'); // Down arrow
+ writeKey(stdin, '\r');
+
+ await waitFor(() => {
+ expect(mockOnChoice).toHaveBeenCalledWith('use_fallback');
+ });
+ unmount();
+ });
+
+ it('should call onChoice with stop when selected', async () => {
+ // Without fallback: items are [get_credits, stop]
+ // stop is the second item: Down + Enter
+ const { unmount, stdin } = await renderWithProviders(
+ ,
+ );
+
+ writeKey(stdin, '\x1b[B'); // Down arrow
+ writeKey(stdin, '\r');
+
+ await waitFor(() => {
+ expect(mockOnChoice).toHaveBeenCalledWith('stop');
+ });
+ unmount();
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/EmptyWalletDialog.tsx b/packages/cli/src/ui/components/EmptyWalletDialog.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..25d85829d3dd7f57d869a2e2f9d186e42b9dccbd
--- /dev/null
+++ b/packages/cli/src/ui/components/EmptyWalletDialog.tsx
@@ -0,0 +1,110 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Box, Text } from 'ink';
+import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
+import { theme } from '../semantic-colors.js';
+
+/** Available choices in the empty wallet dialog */
+export type EmptyWalletChoice = 'get_credits' | 'use_fallback' | 'stop';
+
+interface EmptyWalletDialogProps {
+ /** The model that hit the quota limit */
+ failedModel: string;
+ /** The fallback model to offer (omit if none available) */
+ fallbackModel?: string;
+ /** Time when access resets (human-readable) */
+ resetTime?: string;
+ /** Callback to log click and open the browser for purchasing credits */
+ onGetCredits?: () => void;
+ /** Callback when user makes a selection */
+ onChoice: (choice: EmptyWalletChoice) => void;
+}
+
+export function EmptyWalletDialog({
+ failedModel,
+ fallbackModel,
+ resetTime,
+ onGetCredits,
+ onChoice,
+}: EmptyWalletDialogProps): React.JSX.Element {
+ const items: Array<{
+ label: string;
+ value: EmptyWalletChoice;
+ key: string;
+ }> = [
+ {
+ label: 'Get AI Credits - Open browser to purchase credits',
+ value: 'get_credits',
+ key: 'get_credits',
+ },
+ ];
+
+ if (fallbackModel) {
+ items.push({
+ label: `Switch to ${fallbackModel}`,
+ value: 'use_fallback',
+ key: 'use_fallback',
+ });
+ }
+
+ items.push({
+ label: 'Stop - Abort request',
+ value: 'stop',
+ key: 'stop',
+ });
+
+ const handleSelect = (choice: EmptyWalletChoice) => {
+ if (choice === 'get_credits') {
+ onGetCredits?.();
+ }
+ onChoice(choice);
+ };
+
+ return (
+
+
+
+ Usage limit reached for {failedModel}.
+
+ {resetTime && Access resets at {resetTime}.}
+
+
+ /stats
+ {' '}
+ model for usage details
+
+
+
+ /model
+ {' '}
+ to switch models.
+
+
+
+ /auth
+ {' '}
+ to switch to API key.
+
+
+
+ To continue using this model now, purchase more AI Credits.
+
+
+
+ Newly purchased AI credits may take a few minutes to update.
+
+
+
+ How would you like to proceed?
+
+
+
+
+
+ );
+}
diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bdbd300ad7ea1e3cc2290e9ff0fa6d794c7148c3
--- /dev/null
+++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx
@@ -0,0 +1,620 @@
+/**
+ * @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 { waitFor } from '../../test-utils/async.js';
+import { ExitPlanModeDialog } from './ExitPlanModeDialog.js';
+import { useKeypress } from '../hooks/useKeypress.js';
+import { Command } from '../key/keyMatchers.js';
+import {
+ ApprovalMode,
+ validatePlanContent,
+ processSingleFileContent,
+ type FileSystemService,
+} from '@google/gemini-cli-core';
+import * as fs from 'node:fs';
+import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
+
+vi.mock('../utils/editorUtils.js', () => ({
+ openFileInEditor: vi.fn(),
+}));
+
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ validatePlanPath: vi.fn(async () => null),
+ validatePlanContent: vi.fn(async () => null),
+ processSingleFileContent: vi.fn(),
+ };
+});
+
+vi.mock('node:fs', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ existsSync: vi.fn(),
+ realpathSync: vi.fn((p) => p),
+ };
+});
+
+const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
+ act(() => {
+ stdin.write(key);
+ });
+ // Advance timers to simulate time passing between keystrokes.
+ // This avoids bufferFastReturn converting Enter to Shift+Enter.
+ if (vi.isFakeTimers()) {
+ act(() => {
+ vi.advanceTimersByTime(50);
+ });
+ }
+};
+
+describe('ExitPlanModeDialog', () => {
+ const mockTargetDir = '/mock/project';
+ const mockPlansDir = '/mock/project/plans';
+ const mockPlanFullPath = '/mock/project/plans/test-plan.md';
+
+ const samplePlanContent = `## Overview
+
+Add user authentication to the CLI application.
+
+## Implementation Steps
+
+1. Create \`src/auth/AuthService.ts\` with login/logout methods
+2. Add session storage in \`src/storage/SessionStore.ts\`
+3. Update \`src/commands/index.ts\` to check auth status
+4. Add tests in \`src/auth/__tests__/\`
+
+## Files to Modify
+
+- \`src/index.ts\` - Add auth middleware
+- \`src/config.ts\` - Add auth configuration options`;
+
+ const longPlanContent = `## Overview
+
+Implement a comprehensive authentication system with multiple providers.
+
+## Implementation Steps
+
+1. Create \`src/auth/AuthService.ts\` with login/logout methods
+2. Add session storage in \`src/storage/SessionStore.ts\`
+3. Update \`src/commands/index.ts\` to check auth status
+4. Add OAuth2 provider support in \`src/auth/providers/OAuth2Provider.ts\`
+5. Add SAML provider support in \`src/auth/providers/SAMLProvider.ts\`
+6. Add LDAP provider support in \`src/auth/providers/LDAPProvider.ts\`
+7. Create token refresh mechanism in \`src/auth/TokenManager.ts\`
+8. Add multi-factor authentication in \`src/auth/MFAService.ts\`
+9. Implement session timeout handling in \`src/auth/SessionManager.ts\`
+10. Add audit logging for auth events in \`src/auth/AuditLogger.ts\`
+11. Create user profile management in \`src/auth/UserProfile.ts\`
+12. Add role-based access control in \`src/auth/RBACService.ts\`
+13. Implement password policy enforcement in \`src/auth/PasswordPolicy.ts\`
+14. Add brute force protection in \`src/auth/BruteForceGuard.ts\`
+15. Create secure cookie handling in \`src/auth/CookieManager.ts\`
+
+## Files to Modify
+
+- \`src/index.ts\` - Add auth middleware
+- \`src/config.ts\` - Add auth configuration options
+- \`src/routes/api.ts\` - Add auth endpoints
+- \`src/middleware/cors.ts\` - Update CORS for auth headers
+- \`src/utils/crypto.ts\` - Add encryption utilities
+
+## Testing Strategy
+
+- Unit tests for each auth provider
+- Integration tests for full auth flows
+- Security penetration testing
+- Load testing for session management`;
+
+ let onApprove: ReturnType;
+ let onFeedback: ReturnType;
+ let onCancel: ReturnType;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.mocked(processSingleFileContent).mockResolvedValue({
+ llmContent: samplePlanContent,
+ returnDisplay: 'Read file',
+ });
+ vi.mocked(fs.existsSync).mockReturnValue(true);
+ vi.mocked(fs.realpathSync).mockImplementation((p) => p as string);
+ onApprove = vi.fn();
+ onFeedback = vi.fn();
+ onCancel = vi.fn();
+ });
+
+ afterEach(() => {
+ vi.runOnlyPendingTimers();
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ const renderDialog = async (options?: { useAlternateBuffer?: boolean }) => {
+ const useAlternateBuffer = options?.useAlternateBuffer ?? true;
+ return renderWithProviders(
+ ,
+ {
+ ...options,
+ config: {
+ getTargetDir: () => mockTargetDir,
+ getIdeMode: () => false,
+ isTrustedFolder: () => true,
+ getPreferredEditor: () => undefined,
+ getSessionId: () => 'test-session-id',
+ getProjectRoot: () => mockTargetDir,
+ storage: {
+ getPlansDir: () => mockPlansDir,
+ },
+ getFileSystemService: (): FileSystemService => ({
+ readTextFile: vi.fn(),
+ writeTextFile: vi.fn(),
+ }),
+ getUseAlternateBuffer: () => useAlternateBuffer,
+ getUseTerminalBuffer: () => false,
+ } as unknown as import('@google/gemini-cli-core').Config,
+ settings: createMockSettings({ ui: { useAlternateBuffer } }),
+ inputState: {
+ buffer: { text: '' } as never,
+ showEscapePrompt: false,
+ shellModeActive: false,
+ },
+ },
+ );
+ };
+
+ describe.each([{ useAlternateBuffer: true }, { useAlternateBuffer: false }])(
+ 'useAlternateBuffer: $useAlternateBuffer',
+ ({ useAlternateBuffer }) => {
+ it('renders correctly with plan content', async () => {
+ const { lastFrame } = await act(async () =>
+ renderDialog({ useAlternateBuffer }),
+ );
+
+ // Advance timers to pass the debounce period
+ await act(async () => {
+ vi.runAllTimers();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('Add user authentication');
+ });
+
+ await waitFor(() => {
+ expect(processSingleFileContent).toHaveBeenCalledWith(
+ mockPlanFullPath,
+ mockPlansDir,
+ expect.anything(),
+ );
+ });
+
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('calls onApprove with AUTO_EDIT when first option is selected', async () => {
+ const { stdin, lastFrame } = await act(async () =>
+ renderDialog({ useAlternateBuffer }),
+ );
+
+ await act(async () => {
+ vi.runAllTimers();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('Add user authentication');
+ });
+
+ writeKey(stdin, '\r');
+
+ await waitFor(() => {
+ expect(onApprove).toHaveBeenCalledWith(ApprovalMode.AUTO_EDIT);
+ });
+ });
+
+ it('calls onApprove with DEFAULT when second option is selected', async () => {
+ const { stdin, lastFrame } = await act(async () =>
+ renderDialog({ useAlternateBuffer }),
+ );
+
+ await act(async () => {
+ vi.runAllTimers();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('Add user authentication');
+ });
+
+ writeKey(stdin, '\x1b[B'); // Down arrow
+ writeKey(stdin, '\r');
+
+ await waitFor(() => {
+ expect(onApprove).toHaveBeenCalledWith(ApprovalMode.DEFAULT);
+ });
+ });
+
+ it('calls onFeedback when feedback is typed and submitted', async () => {
+ const { stdin, lastFrame } = await act(async () =>
+ renderDialog({ useAlternateBuffer }),
+ );
+
+ await act(async () => {
+ vi.runAllTimers();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('Add user authentication');
+ });
+
+ // Navigate to feedback option
+ writeKey(stdin, '\x1b[B'); // Down arrow
+ writeKey(stdin, '\x1b[B'); // Down arrow
+
+ // Type feedback
+ for (const char of 'Add tests') {
+ writeKey(stdin, char);
+ }
+
+ await waitFor(() => {
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ writeKey(stdin, '\r');
+
+ await waitFor(() => {
+ expect(onFeedback).toHaveBeenCalledWith('Add tests');
+ });
+ });
+
+ it('calls onCancel when Esc is pressed', async () => {
+ const { stdin, lastFrame } = await act(async () =>
+ renderDialog({ useAlternateBuffer }),
+ );
+
+ await act(async () => {
+ vi.runAllTimers();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('Add user authentication');
+ });
+
+ writeKey(stdin, '\x1b'); // Escape
+
+ await act(async () => {
+ vi.runAllTimers();
+ });
+
+ expect(onCancel).toHaveBeenCalled();
+ });
+
+ it('displays error state when file read fails', async () => {
+ vi.mocked(processSingleFileContent).mockResolvedValue({
+ llmContent: '',
+ returnDisplay: '',
+ error: 'File not found',
+ });
+
+ const { lastFrame } = await act(async () =>
+ renderDialog({ useAlternateBuffer }),
+ );
+
+ await act(async () => {
+ vi.runAllTimers();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('Error reading plan: File not found');
+ });
+
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('displays error state when plan file is empty', async () => {
+ vi.mocked(validatePlanContent).mockResolvedValue('Plan file is empty.');
+
+ const { lastFrame } = await act(async () =>
+ renderDialog({ useAlternateBuffer }),
+ );
+
+ await act(async () => {
+ vi.runAllTimers();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain(
+ 'Error reading plan: Plan file is empty.',
+ );
+ });
+ });
+
+ it('handles long plan content appropriately', async () => {
+ vi.mocked(processSingleFileContent).mockResolvedValue({
+ llmContent: longPlanContent,
+ returnDisplay: 'Read file',
+ });
+
+ const { lastFrame } = await act(async () =>
+ renderDialog({ useAlternateBuffer }),
+ );
+
+ await act(async () => {
+ vi.runAllTimers();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain(
+ 'Implement a comprehensive authentication system',
+ );
+ });
+
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('allows number key quick selection', async () => {
+ const { stdin, lastFrame } = await act(async () =>
+ renderDialog({ useAlternateBuffer }),
+ );
+
+ await act(async () => {
+ vi.runAllTimers();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('Add user authentication');
+ });
+
+ // Press '2' to select second option directly
+ writeKey(stdin, '2');
+
+ await waitFor(() => {
+ expect(onApprove).toHaveBeenCalledWith(ApprovalMode.DEFAULT);
+ });
+ });
+
+ it('clears feedback text when Ctrl+C is pressed while editing', async () => {
+ const { stdin, lastFrame } = await act(async () =>
+ renderDialog({ useAlternateBuffer }),
+ );
+
+ await act(async () => {
+ vi.runAllTimers();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('Add user authentication');
+ });
+
+ // Navigate to feedback option and start typing
+ writeKey(stdin, '\x1b[B'); // Down arrow
+ writeKey(stdin, '\x1b[B'); // Down arrow
+ writeKey(stdin, '\r'); // Select to focus input
+
+ // Type some feedback
+ for (const char of 'test feedback') {
+ writeKey(stdin, char);
+ }
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('test feedback');
+ });
+
+ // Press Ctrl+C to clear
+ writeKey(stdin, '\x03'); // Ctrl+C
+
+ await waitFor(() => {
+ expect(lastFrame()).not.toContain('test feedback');
+ expect(lastFrame()).toContain('Type your feedback...');
+ });
+
+ // Dialog should still be open (not cancelled)
+ expect(onCancel).not.toHaveBeenCalled();
+ });
+
+ it('bubbles up Ctrl+C when feedback is empty while editing', async () => {
+ const onBubbledQuit = vi.fn();
+
+ const BubbleListener = ({
+ children,
+ }: {
+ children: React.ReactNode;
+ }) => {
+ const keyMatchers = useKeyMatchers();
+ useKeypress(
+ (key) => {
+ if (keyMatchers[Command.QUIT](key)) {
+ onBubbledQuit();
+ }
+ return false;
+ },
+ { isActive: true },
+ );
+ return <>{children}>;
+ };
+
+ const { stdin, lastFrame } = await act(async () =>
+ renderWithProviders(
+
+
+ ,
+ {
+ config: {
+ getTargetDir: () => mockTargetDir,
+ getIdeMode: () => false,
+ isTrustedFolder: () => true,
+ getSessionId: () => 'test-session-id',
+ getProjectRoot: () => mockTargetDir,
+ storage: {
+ getPlansDir: () => mockPlansDir,
+ },
+ getFileSystemService: (): FileSystemService => ({
+ readTextFile: vi.fn(),
+ writeTextFile: vi.fn(),
+ }),
+ getUseAlternateBuffer: () => useAlternateBuffer ?? true,
+ getUseTerminalBuffer: () => false,
+ } as unknown as import('@google/gemini-cli-core').Config,
+ settings: createMockSettings({
+ ui: { useAlternateBuffer: useAlternateBuffer ?? true },
+ }),
+ inputState: {
+ buffer: { text: '' } as never,
+ showEscapePrompt: false,
+ shellModeActive: false,
+ },
+ },
+ ),
+ );
+
+ await act(async () => {
+ vi.runAllTimers();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('Add user authentication');
+ });
+
+ // Navigate to feedback option
+ writeKey(stdin, '\x1b[B'); // Down arrow
+ writeKey(stdin, '\x1b[B'); // Down arrow
+
+ // Type some feedback
+ for (const char of 'test') {
+ writeKey(stdin, char);
+ }
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('test');
+ });
+
+ // First Ctrl+C to clear text
+ writeKey(stdin, '\x03'); // Ctrl+C
+
+ await waitFor(() => {
+ expect(lastFrame()).toMatchSnapshot();
+ });
+ expect(onBubbledQuit).not.toHaveBeenCalled();
+
+ // Second Ctrl+C to exit (should bubble)
+ writeKey(stdin, '\x03'); // Ctrl+C
+
+ await waitFor(() => {
+ expect(onBubbledQuit).toHaveBeenCalled();
+ });
+ expect(onCancel).not.toHaveBeenCalled();
+ });
+
+ it('does not submit empty feedback when Enter is pressed', async () => {
+ const { stdin, lastFrame } = await act(async () =>
+ renderDialog({ useAlternateBuffer }),
+ );
+
+ await act(async () => {
+ vi.runAllTimers();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('Add user authentication');
+ });
+
+ // Navigate to feedback option
+ writeKey(stdin, '\x1b[B'); // Down arrow
+ writeKey(stdin, '\x1b[B'); // Down arrow
+
+ // Press Enter without typing anything
+ writeKey(stdin, '\r');
+
+ // Wait a bit to ensure no callback was triggered
+ await act(async () => {
+ vi.advanceTimersByTime(50);
+ });
+
+ expect(onFeedback).not.toHaveBeenCalled();
+ expect(onApprove).not.toHaveBeenCalled();
+ });
+
+ it('allows arrow navigation while typing feedback to change selection', async () => {
+ const { stdin, lastFrame } = await act(async () =>
+ renderDialog({ useAlternateBuffer }),
+ );
+
+ await act(async () => {
+ vi.runAllTimers();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('Add user authentication');
+ });
+
+ // Navigate to feedback option and start typing
+ writeKey(stdin, '\x1b[B'); // Down arrow
+ writeKey(stdin, '\x1b[B'); // Down arrow
+
+ // Type some feedback
+ for (const char of 'test') {
+ writeKey(stdin, char);
+ }
+
+ // Now use up arrow to navigate back to a different option
+ writeKey(stdin, '\x1b[A'); // Up arrow
+
+ // Press Enter to select the second option (manually accept edits)
+ writeKey(stdin, '\r');
+
+ await waitFor(() => {
+ expect(onApprove).toHaveBeenCalledWith(ApprovalMode.DEFAULT);
+ });
+ expect(onFeedback).not.toHaveBeenCalled();
+ });
+
+ it('automatically submits feedback when Ctrl+G is used to edit the plan', async () => {
+ const { stdin, lastFrame } = await act(async () =>
+ renderDialog({ useAlternateBuffer }),
+ );
+
+ await act(async () => {
+ vi.runAllTimers();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('Add user authentication');
+ });
+
+ // Press Ctrl+G
+ await act(async () => {
+ writeKey(stdin, '\x07'); // Ctrl+G
+ });
+
+ await waitFor(() => {
+ expect(onFeedback).toHaveBeenCalledWith(
+ 'I have edited the plan or annotated it with feedback. Review the edited plan, update if necessary, and present it again for approval.',
+ );
+ });
+ });
+ },
+ );
+});
diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8e27d6d1a2e813d3dbbd5eabfc736ff84cd1a922
--- /dev/null
+++ b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx
@@ -0,0 +1,285 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { useEffect, useState, useCallback } from 'react';
+import { Box, Text, useStdin } from 'ink';
+import {
+ ApprovalMode,
+ validatePlanPath,
+ validatePlanContent,
+ QuestionType,
+ type Config,
+ type EditorType,
+ processSingleFileContent,
+ debugLogger,
+} from '@google/gemini-cli-core';
+import { theme } from '../semantic-colors.js';
+import { useConfig } from '../contexts/ConfigContext.js';
+import { AskUserDialog } from './AskUserDialog.js';
+import { openFileInEditor } from '../utils/editorUtils.js';
+import { useKeypress } from '../hooks/useKeypress.js';
+import { Command } from '../key/keyMatchers.js';
+import { formatCommand } from '../key/keybindingUtils.js';
+import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
+import {
+ appEvents,
+ AppEvent,
+ TransientMessageType,
+} from '../../utils/events.js';
+
+export interface ExitPlanModeDialogProps {
+ planPath: string;
+ onApprove: (approvalMode: ApprovalMode) => void;
+ onFeedback: (feedback: string) => void;
+ onCancel: () => void;
+ getPreferredEditor: () => EditorType | undefined;
+ width: number;
+ availableHeight?: number;
+}
+
+enum PlanStatus {
+ Loading = 'loading',
+ Loaded = 'loaded',
+ Error = 'error',
+}
+
+interface PlanContentState {
+ status: PlanStatus;
+ content?: string;
+ error?: string;
+ refresh: () => void;
+}
+
+enum ApprovalOption {
+ Auto = 'Yes, automatically accept edits',
+ Manual = 'Yes, manually accept edits',
+}
+
+/**
+ * A tiny component for loading and error states with consistent styling.
+ */
+const StatusMessage: React.FC<{
+ children: React.ReactNode;
+}> = ({ children }) => {children};
+
+function usePlanContent(planPath: string, config: Config): PlanContentState {
+ const [version, setVersion] = useState(0);
+ const [state, setState] = useState>({
+ status: PlanStatus.Loading,
+ });
+
+ const refresh = useCallback(() => {
+ setVersion((v) => v + 1);
+ }, []);
+
+ useEffect(() => {
+ let ignore = false;
+ setState({ status: PlanStatus.Loading });
+
+ const load = async () => {
+ try {
+ const pathError = await validatePlanPath(
+ planPath,
+ config.storage.getPlansDir(),
+ config.getProjectRoot(),
+ );
+ if (ignore) return;
+ if (pathError) {
+ setState({ status: PlanStatus.Error, error: pathError });
+ return;
+ }
+
+ const contentError = await validatePlanContent(planPath);
+ if (ignore) return;
+ if (contentError) {
+ setState({ status: PlanStatus.Error, error: contentError });
+ return;
+ }
+
+ const result = await processSingleFileContent(
+ planPath,
+ config.storage.getPlansDir(),
+ config.getFileSystemService(),
+ );
+
+ if (ignore) return;
+
+ if (result.error) {
+ setState({ status: PlanStatus.Error, error: result.error });
+ return;
+ }
+
+ if (typeof result.llmContent !== 'string') {
+ setState({
+ status: PlanStatus.Error,
+ error: 'Plan file format not supported (binary or image).',
+ });
+ return;
+ }
+
+ const content = result.llmContent;
+ if (!content) {
+ setState({ status: PlanStatus.Error, error: 'Plan file is empty.' });
+ return;
+ }
+ setState({ status: PlanStatus.Loaded, content });
+ } catch (err: unknown) {
+ if (ignore) return;
+ const errorMessage = err instanceof Error ? err.message : String(err);
+ setState({ status: PlanStatus.Error, error: errorMessage });
+ }
+ };
+
+ void load();
+
+ return () => {
+ ignore = true;
+ };
+ }, [planPath, config, version]);
+
+ return { ...state, refresh };
+}
+
+export const ExitPlanModeDialog: React.FC = ({
+ planPath,
+ onApprove,
+ onFeedback,
+ onCancel,
+ getPreferredEditor,
+ width,
+ availableHeight,
+}) => {
+ const keyMatchers = useKeyMatchers();
+ const config = useConfig();
+ const { stdin, setRawMode } = useStdin();
+ const planState = usePlanContent(planPath, config);
+ const { refresh } = planState;
+ const [showLoading, setShowLoading] = useState(false);
+
+ const handleOpenEditor = useCallback(async () => {
+ try {
+ await openFileInEditor(planPath, stdin, setRawMode, getPreferredEditor());
+
+ onFeedback(
+ 'I have edited the plan or annotated it with feedback. Review the edited plan, update if necessary, and present it again for approval.',
+ );
+ refresh();
+ } catch (err) {
+ debugLogger.error('Failed to open plan in editor:', err);
+ }
+ }, [planPath, stdin, setRawMode, getPreferredEditor, refresh, onFeedback]);
+
+ useKeypress(
+ (key) => {
+ if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
+ void handleOpenEditor();
+ return true;
+ }
+ if (keyMatchers[Command.DEPRECATED_OPEN_EXTERNAL_EDITOR](key)) {
+ const cmdKey = formatCommand(Command.OPEN_EXTERNAL_EDITOR);
+ appEvents.emit(AppEvent.TransientMessage, {
+ message: `Use ${cmdKey} to open the external editor.`,
+ type: TransientMessageType.Hint,
+ });
+ return true;
+ }
+ return false;
+ },
+ { isActive: true, priority: true },
+ );
+
+ useEffect(() => {
+ if (planState.status !== PlanStatus.Loading) {
+ setShowLoading(false);
+ return;
+ }
+
+ const timer = setTimeout(() => {
+ setShowLoading(true);
+ }, 200);
+
+ return () => clearTimeout(timer);
+ }, [planState.status]);
+
+ if (planState.status === PlanStatus.Loading) {
+ if (!showLoading) {
+ return null;
+ }
+
+ return (
+
+
+ Loading plan...
+
+
+ );
+ }
+
+ if (planState.status === PlanStatus.Error) {
+ return (
+
+
+ Error reading plan: {planState.error}
+
+
+ );
+ }
+
+ const planContent = planState.content?.trim();
+ if (!planContent) {
+ return (
+
+ Error: Plan content is empty.
+
+ );
+ }
+
+ const editHint = formatCommand(Command.OPEN_EXTERNAL_EDITOR);
+
+ return (
+
+ {
+ const answer = answers['0'];
+ if (answer === ApprovalOption.Auto) {
+ onApprove(ApprovalMode.AUTO_EDIT);
+ } else if (answer === ApprovalOption.Manual) {
+ onApprove(ApprovalMode.DEFAULT);
+ } else if (answer) {
+ onFeedback(answer);
+ }
+ }}
+ onCancel={onCancel}
+ width={width}
+ availableHeight={availableHeight}
+ extraParts={[`${editHint} to edit plan`]}
+ />
+
+ );
+};
diff --git a/packages/cli/src/ui/components/ExitWarning.test.tsx b/packages/cli/src/ui/components/ExitWarning.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a504670d03caebeddd1cfacdeec4f8e4bf0b38f2
--- /dev/null
+++ b/packages/cli/src/ui/components/ExitWarning.test.tsx
@@ -0,0 +1,64 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { ExitWarning } from './ExitWarning.js';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { useUIState, type UIState } from '../contexts/UIStateContext.js';
+
+vi.mock('../contexts/UIStateContext.js');
+
+describe('ExitWarning', () => {
+ const mockUseUIState = vi.mocked(useUIState);
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders nothing by default', async () => {
+ mockUseUIState.mockReturnValue({
+ dialogsVisible: false,
+ ctrlCPressedOnce: false,
+ ctrlDPressedOnce: false,
+ } as unknown as UIState);
+ const { lastFrame, unmount } = await render();
+ expect(lastFrame({ allowEmpty: true })).toBe('');
+ unmount();
+ });
+
+ it('renders Ctrl+C warning when pressed once and dialogs visible', async () => {
+ mockUseUIState.mockReturnValue({
+ dialogsVisible: true,
+ ctrlCPressedOnce: true,
+ ctrlDPressedOnce: false,
+ } as unknown as UIState);
+ const { lastFrame, unmount } = await render();
+ expect(lastFrame()).toContain('Press Ctrl+C again to exit');
+ unmount();
+ });
+
+ it('renders Ctrl+D warning when pressed once and dialogs visible', async () => {
+ mockUseUIState.mockReturnValue({
+ dialogsVisible: true,
+ ctrlCPressedOnce: false,
+ ctrlDPressedOnce: true,
+ } as unknown as UIState);
+ const { lastFrame, unmount } = await render();
+ expect(lastFrame()).toContain('Press Ctrl+D again to exit');
+ unmount();
+ });
+
+ it('renders nothing if dialogs are not visible', async () => {
+ mockUseUIState.mockReturnValue({
+ dialogsVisible: false,
+ ctrlCPressedOnce: true,
+ ctrlDPressedOnce: true,
+ } as unknown as UIState);
+ const { lastFrame, unmount } = await render();
+ expect(lastFrame({ allowEmpty: true })).toBe('');
+ unmount();
+ });
+});
diff --git a/packages/cli/src/ui/components/ExitWarning.tsx b/packages/cli/src/ui/components/ExitWarning.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..598ff2fab41482db26814a071ffa447382cff027
--- /dev/null
+++ b/packages/cli/src/ui/components/ExitWarning.tsx
@@ -0,0 +1,29 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Box, Text } from 'ink';
+import { useUIState } from '../contexts/UIStateContext.js';
+import { theme } from '../semantic-colors.js';
+
+export const ExitWarning: React.FC = () => {
+ const uiState = useUIState();
+ return (
+ <>
+ {uiState.dialogsVisible && uiState.ctrlCPressedOnce && (
+
+ Press Ctrl+C again to exit.
+
+ )}
+
+ {uiState.dialogsVisible && uiState.ctrlDPressedOnce && (
+
+ Press Ctrl+D again to exit.
+
+ )}
+ >
+ );
+};
diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..02977c68c0c8ab5a5b9540ce1c2e0573a55207d3
--- /dev/null
+++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx
@@ -0,0 +1,476 @@
+/**
+ * @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 { makeFakeConfig, ExitCodes } from '@google/gemini-cli-core';
+import { waitFor } from '../../test-utils/async.js';
+import { act } from 'react';
+import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { FolderTrustDialog } from './FolderTrustDialog.js';
+import * as processUtils from '../../utils/processUtils.js';
+
+vi.mock('../../utils/processUtils.js', () => ({
+ relaunchApp: vi.fn(),
+}));
+
+const mockedExit = vi.hoisted(() => vi.fn());
+const mockedCwd = vi.hoisted(() => vi.fn().mockReturnValue('/mock/cwd'));
+const mockedRows = vi.hoisted(() => ({ current: 24 }));
+
+vi.mock('node:process', async () => {
+ const actual =
+ await vi.importActual('node:process');
+ return {
+ ...actual,
+ exit: mockedExit,
+ cwd: mockedCwd,
+ };
+});
+
+vi.mock('../hooks/useTerminalSize.js', () => ({
+ useTerminalSize: () => ({ columns: 80, terminalHeight: mockedRows.current }),
+}));
+
+describe('FolderTrustDialog', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useRealTimers();
+ mockedCwd.mockReturnValue('/home/user/project');
+ mockedRows.current = 24;
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should render the dialog with title and description', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+
+ expect(lastFrame()).toContain('Do you trust the files in this folder?');
+ expect(lastFrame()).toContain(
+ 'Trusting a folder allows Gemini CLI to load its local configurations',
+ );
+ unmount();
+ });
+
+ it('should truncate discovery results when they exceed maxDiscoveryHeight', async () => {
+ // maxDiscoveryHeight = 24 - 15 = 9.
+ const discoveryResults = {
+ commands: Array.from({ length: 10 }, (_, i) => `cmd${i}`),
+ mcps: Array.from({ length: 10 }, (_, i) => `mcp${i}`),
+ hooks: Array.from({ length: 10 }, (_, i) => `hook${i}`),
+ skills: Array.from({ length: 10 }, (_, i) => `skill${i}`),
+ agents: [],
+ settings: Array.from({ length: 10 }, (_, i) => `setting${i}`),
+ discoveryErrors: [],
+ securityWarnings: [],
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ width: 80,
+ config: makeFakeConfig({ useAlternateBuffer: false }),
+ settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
+ uiState: { constrainHeight: true, terminalHeight: 24 },
+ },
+ );
+
+ expect(lastFrame()).toContain('This folder contains:');
+ expect(lastFrame()).not.toContain('cmd9');
+ unmount();
+ });
+
+ it('should adjust maxHeight based on terminal rows', async () => {
+ mockedRows.current = 14; // maxHeight = 14 - 10 = 4
+ const discoveryResults = {
+ commands: ['cmd1', 'cmd2', 'cmd3', 'cmd4', 'cmd5'],
+ mcps: [],
+ hooks: [],
+ skills: [],
+ agents: [],
+ settings: [],
+ discoveryErrors: [],
+ securityWarnings: [],
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ width: 80,
+ config: makeFakeConfig({ useAlternateBuffer: false }),
+ settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
+ uiState: { constrainHeight: true, terminalHeight: 14 },
+ },
+ );
+
+ // With maxHeight=4, the intro text (4 lines) will take most of the space.
+ // The discovery results will likely be hidden.
+ expect(lastFrame()).not.toContain('cmd1');
+ unmount();
+ });
+
+ it('should use minimum maxHeight of 4', async () => {
+ mockedRows.current = 8; // 8 - 10 = -2, should use 4
+ const discoveryResults = {
+ commands: ['cmd1', 'cmd2', 'cmd3', 'cmd4', 'cmd5'],
+ mcps: [],
+ hooks: [],
+ skills: [],
+ agents: [],
+ settings: [],
+ discoveryErrors: [],
+ securityWarnings: [],
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ width: 80,
+ config: makeFakeConfig({ useAlternateBuffer: false }),
+ settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
+ uiState: { constrainHeight: true, terminalHeight: 10 },
+ },
+ );
+
+ expect(lastFrame()).not.toContain('cmd1');
+ unmount();
+ });
+
+ it('should toggle expansion when global Ctrl+O is handled', async () => {
+ const discoveryResults = {
+ commands: Array.from({ length: 10 }, (_, i) => `cmd${i}`),
+ mcps: [],
+ hooks: [],
+ skills: [],
+ agents: [],
+ settings: [],
+ discoveryErrors: [],
+ securityWarnings: [],
+ };
+
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ width: 80,
+ config: makeFakeConfig({ useAlternateBuffer: false }),
+ settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
+ // Initially constrained
+ uiState: { constrainHeight: true, terminalHeight: 24 },
+ },
+ );
+
+ // Initial state: truncated
+ await waitFor(() => {
+ expect(lastFrame()).toContain('Do you trust the files in this folder?');
+ expect(lastFrame()).not.toContain('cmd9');
+ });
+
+ unmount();
+
+ // We can't easily simulate global Ctrl+O toggle in this unit test
+ // because it's handled in AppContainer.
+ // But we can re-render with constrainHeight: false.
+ const { lastFrame: lastFrameExpanded, unmount: unmountExpanded } =
+ await renderWithProviders(
+ ,
+ {
+ width: 80,
+ config: makeFakeConfig({ useAlternateBuffer: false }),
+ settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
+ uiState: { constrainHeight: false, terminalHeight: 50 },
+ },
+ );
+
+ await waitFor(() => {
+ expect(lastFrameExpanded()).not.toContain('hidden');
+ expect(lastFrameExpanded()).toContain('- cmd9');
+ expect(lastFrameExpanded()).toContain('- cmd4');
+ });
+
+ unmountExpanded();
+ });
+
+ it('should display exit message and call process.exit and not call onSelect when escape is pressed', async () => {
+ const onSelect = vi.fn();
+ const { lastFrame, stdin, waitUntilReady, unmount } =
+ await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\u001b[27u'); // Press kitty escape key
+ });
+ // Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act
+ await act(async () => {
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain(
+ 'A folder trust level must be selected to continue. Exiting since escape was pressed.',
+ );
+ });
+ await waitFor(() => {
+ expect(mockedExit).toHaveBeenCalledWith(
+ ExitCodes.FATAL_CANCELLATION_ERROR,
+ );
+ });
+ expect(onSelect).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should display restart message when isRestarting is true', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+
+ expect(lastFrame()).toContain('Gemini CLI is restarting');
+ unmount();
+ });
+
+ it('should call relaunchApp when isRestarting is true', async () => {
+ vi.useFakeTimers();
+ const relaunchApp = vi
+ .spyOn(processUtils, 'relaunchApp')
+ .mockResolvedValue(undefined);
+ const { unmount } = await renderWithProviders(
+ ,
+ );
+ await vi.advanceTimersByTimeAsync(250);
+ expect(relaunchApp).toHaveBeenCalled();
+ unmount();
+ vi.useRealTimers();
+ });
+
+ it('should not call relaunchApp if unmounted before timeout', async () => {
+ vi.useFakeTimers();
+ const relaunchApp = vi
+ .spyOn(processUtils, 'relaunchApp')
+ .mockResolvedValue(undefined);
+ const { unmount } = await renderWithProviders(
+ ,
+ );
+
+ // Unmount immediately (before 250ms)
+ unmount();
+
+ await vi.advanceTimersByTimeAsync(250);
+ expect(relaunchApp).not.toHaveBeenCalled();
+ vi.useRealTimers();
+ });
+
+ it('should not call process.exit when "r" is pressed and isRestarting is false', async () => {
+ const { stdin, waitUntilReady, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('r');
+ });
+ await waitUntilReady();
+
+ await waitFor(() => {
+ expect(mockedExit).not.toHaveBeenCalled();
+ });
+ unmount();
+ });
+
+ describe('directory display', () => {
+ it('should correctly display the folder name for a nested directory', async () => {
+ mockedCwd.mockReturnValue('/home/user/project');
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ expect(lastFrame()).toContain('Trust folder (project)');
+ unmount();
+ });
+
+ it('should correctly display the parent folder name for a nested directory', async () => {
+ mockedCwd.mockReturnValue('/home/user/project');
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ expect(lastFrame()).toContain('Trust parent folder (user)');
+ unmount();
+ });
+
+ it('should correctly display an empty parent folder name for a directory directly under root', async () => {
+ mockedCwd.mockReturnValue('/project');
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ expect(lastFrame()).toContain('Trust parent folder ()');
+ unmount();
+ });
+
+ it('should display discovery results when provided', async () => {
+ mockedRows.current = 40; // Increase height to show all results
+ const discoveryResults = {
+ commands: ['cmd1', 'cmd2'],
+ mcps: ['mcp1'],
+ hooks: ['hook1'],
+ skills: ['skill1'],
+ agents: ['agent1'],
+ settings: ['general', 'ui'],
+ discoveryErrors: [],
+ securityWarnings: [],
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ { width: 80 },
+ );
+
+ expect(lastFrame()).toContain('This folder contains:');
+ expect(lastFrame()).toContain('• Commands (2):');
+ expect(lastFrame()).toContain('- cmd1');
+ expect(lastFrame()).toContain('- cmd2');
+ expect(lastFrame()).toContain('• MCP Servers (1):');
+ expect(lastFrame()).toContain('- mcp1');
+ expect(lastFrame()).toContain('• Hooks (1):');
+ expect(lastFrame()).toContain('- hook1');
+ expect(lastFrame()).toContain('• Skills (1):');
+ expect(lastFrame()).toContain('- skill1');
+ expect(lastFrame()).toContain('• Agents (1):');
+ expect(lastFrame()).toContain('- agent1');
+ expect(lastFrame()).toContain('• Setting overrides (2):');
+ expect(lastFrame()).toContain('- general');
+ expect(lastFrame()).toContain('- ui');
+ unmount();
+ });
+
+ it('should display security warnings when provided', async () => {
+ const discoveryResults = {
+ commands: [],
+ mcps: [],
+ hooks: [],
+ skills: [],
+ agents: [],
+ settings: [],
+ discoveryErrors: [],
+ securityWarnings: ['Dangerous setting detected!'],
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+
+ expect(lastFrame()).toContain('Security Warnings:');
+ expect(lastFrame()).toContain('Dangerous setting detected!');
+ unmount();
+ });
+
+ it('should display discovery errors when provided', async () => {
+ const discoveryResults = {
+ commands: [],
+ mcps: [],
+ hooks: [],
+ skills: [],
+ agents: [],
+ settings: [],
+ discoveryErrors: ['Failed to load custom commands'],
+ securityWarnings: [],
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+
+ expect(lastFrame()).toContain('Discovery Errors:');
+ expect(lastFrame()).toContain('Failed to load custom commands');
+ unmount();
+ });
+
+ it('should use scrolling instead of truncation when alternate buffer is enabled and expanded', async () => {
+ const discoveryResults = {
+ commands: Array.from({ length: 20 }, (_, i) => `cmd${i}`),
+ mcps: [],
+ hooks: [],
+ skills: [],
+ agents: [],
+ settings: [],
+ discoveryErrors: [],
+ securityWarnings: [],
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ width: 80,
+ config: makeFakeConfig({ useAlternateBuffer: true }),
+ settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
+ uiState: { constrainHeight: false, terminalHeight: 15 },
+ },
+ );
+
+ // In alternate buffer + expanded, the title should be visible (StickyHeader)
+ expect(lastFrame()).toContain('Do you trust the files in this folder?');
+ // And it should NOT use MaxSizedBox truncation
+ expect(lastFrame()).not.toContain('hidden');
+ unmount();
+ });
+
+ it('should strip ANSI codes from discovery results', async () => {
+ const ansiRed = '\u001b[31m';
+ const ansiReset = '\u001b[39m';
+
+ const discoveryResults = {
+ commands: [`${ansiRed}cmd-with-ansi${ansiReset}`],
+ mcps: [`${ansiRed}mcp-with-ansi${ansiReset}`],
+ hooks: [`${ansiRed}hook-with-ansi${ansiReset}`],
+ skills: [`${ansiRed}skill-with-ansi${ansiReset}`],
+ agents: [],
+ settings: [`${ansiRed}setting-with-ansi${ansiReset}`],
+ discoveryErrors: [`${ansiRed}error-with-ansi${ansiReset}`],
+ securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`],
+ };
+
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ { width: 100, uiState: { terminalHeight: 40 } },
+ );
+
+ const output = lastFrame();
+
+ expect(output).toContain('cmd-with-ansi');
+ expect(output).toContain('mcp-with-ansi');
+ expect(output).toContain('hook-with-ansi');
+ expect(output).toContain('skill-with-ansi');
+ expect(output).toContain('setting-with-ansi');
+ expect(output).toContain('error-with-ansi');
+ expect(output).toContain('warning-with-ansi');
+
+ unmount();
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/FolderTrustDialog.tsx b/packages/cli/src/ui/components/FolderTrustDialog.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5f226b7d15569fa0bc8a482c0c70934a3bdc648e
--- /dev/null
+++ b/packages/cli/src/ui/components/FolderTrustDialog.tsx
@@ -0,0 +1,318 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { Box, Text } from 'ink';
+import type React from 'react';
+import { useEffect, useState, useCallback } from 'react';
+import { theme } from '../semantic-colors.js';
+import stripAnsi from 'strip-ansi';
+import {
+ RadioButtonSelect,
+ type RadioSelectItem,
+} from './shared/RadioButtonSelect.js';
+import { MaxSizedBox } from './shared/MaxSizedBox.js';
+import { Scrollable } from './shared/Scrollable.js';
+import { useKeypress } from '../hooks/useKeypress.js';
+import * as process from 'node:process';
+import * as path from 'node:path';
+import { relaunchApp } from '../../utils/processUtils.js';
+import { runExitCleanup } from '../../utils/cleanup.js';
+import {
+ ExitCodes,
+ type FolderDiscoveryResults,
+} from '@google/gemini-cli-core';
+import { useUIState } from '../contexts/UIStateContext.js';
+import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
+import { OverflowProvider } from '../contexts/OverflowContext.js';
+import { ShowMoreLines } from './ShowMoreLines.js';
+import { StickyHeader } from './StickyHeader.js';
+
+export enum FolderTrustChoice {
+ TRUST_FOLDER = 'trust_folder',
+ TRUST_PARENT = 'trust_parent',
+ DO_NOT_TRUST = 'do_not_trust',
+}
+
+interface FolderTrustDialogProps {
+ onSelect: (choice: FolderTrustChoice) => void;
+ isRestarting?: boolean;
+ discoveryResults?: FolderDiscoveryResults | null;
+}
+
+export const FolderTrustDialog: React.FC = ({
+ onSelect,
+ isRestarting,
+ discoveryResults,
+}) => {
+ const [exiting, setExiting] = useState(false);
+ const { terminalHeight, terminalWidth, constrainHeight } = useUIState();
+ const isAlternateBuffer = useAlternateBuffer();
+
+ const isExpanded = !constrainHeight;
+
+ useEffect(() => {
+ let timer: ReturnType;
+ if (isRestarting) {
+ timer = setTimeout(relaunchApp, 250);
+ }
+ return () => {
+ if (timer) clearTimeout(timer);
+ };
+ }, [isRestarting]);
+
+ const handleExit = useCallback(() => {
+ setExiting(true);
+ // Give time for the UI to render the exiting message
+ setTimeout(async () => {
+ await runExitCleanup();
+ process.exit(ExitCodes.FATAL_CANCELLATION_ERROR);
+ }, 100);
+ }, []);
+
+ useKeypress(
+ (key) => {
+ if (key.name === 'escape') {
+ handleExit();
+ return true;
+ }
+ return false;
+ },
+ { isActive: !isRestarting },
+ );
+
+ const dirName = path.basename(process.cwd());
+ const parentFolder = path.basename(path.dirname(process.cwd()));
+
+ const options: Array> = [
+ {
+ label: `Trust folder (${dirName})`,
+ value: FolderTrustChoice.TRUST_FOLDER,
+ key: `Trust folder (${dirName})`,
+ },
+ {
+ label: `Trust parent folder (${parentFolder})`,
+ value: FolderTrustChoice.TRUST_PARENT,
+ key: `Trust parent folder (${parentFolder})`,
+ },
+ {
+ label: "Don't trust",
+ value: FolderTrustChoice.DO_NOT_TRUST,
+ key: "Don't trust",
+ },
+ ];
+
+ const hasDiscovery =
+ discoveryResults &&
+ (discoveryResults.commands.length > 0 ||
+ discoveryResults.mcps.length > 0 ||
+ discoveryResults.hooks.length > 0 ||
+ discoveryResults.skills.length > 0 ||
+ discoveryResults.settings.length > 0);
+
+ const hasWarnings =
+ discoveryResults && discoveryResults.securityWarnings.length > 0;
+
+ const hasErrors =
+ discoveryResults &&
+ discoveryResults.discoveryErrors &&
+ discoveryResults.discoveryErrors.length > 0;
+
+ const dialogWidth = terminalWidth - 2;
+ const borderColor = theme.status.warning;
+
+ // Header: 3 lines
+ // Options: options.length + 2 lines for margins
+ // Footer: 1 line
+ // Safety margin: 2 lines
+ const overhead = 3 + options.length + 2 + 1 + 2;
+ const scrollableHeight = Math.max(4, terminalHeight - overhead);
+
+ const groups = [
+ { label: 'Commands', items: discoveryResults?.commands ?? [] },
+ { label: 'MCP Servers', items: discoveryResults?.mcps ?? [] },
+ { label: 'Hooks', items: discoveryResults?.hooks ?? [] },
+ { label: 'Skills', items: discoveryResults?.skills ?? [] },
+ { label: 'Agents', items: discoveryResults?.agents ?? [] },
+ { label: 'Setting overrides', items: discoveryResults?.settings ?? [] },
+ ].filter((g) => g.items.length > 0);
+
+ const discoveryContent = (
+
+
+
+ Trusting a folder allows Gemini CLI to load its local configurations,
+ including custom commands, hooks, MCP servers, agent skills, and
+ settings. These configurations could execute code on your behalf or
+ change the behavior of the CLI.
+
+
+
+ {hasErrors && (
+
+
+ ❌ Discovery Errors:
+
+ {discoveryResults.discoveryErrors.map((error, i) => (
+
+ • {stripAnsi(error)}
+
+ ))}
+
+ )}
+
+ {hasWarnings && (
+
+
+ ⚠️ Security Warnings:
+
+ {discoveryResults.securityWarnings.map((warning, i) => (
+
+ • {stripAnsi(warning)}
+
+ ))}
+
+ )}
+
+ {hasDiscovery && (
+
+
+ This folder contains:
+
+ {groups.map((group) => (
+
+
+ • {group.label} ({group.items.length}):
+
+ {group.items.map((item, idx) => (
+
+ - {stripAnsi(item)}
+
+ ))}
+
+ ))}
+
+ )}
+
+ );
+
+ const title = (
+
+ Do you trust the files in this folder?
+
+ );
+
+ const selectOptions = (
+
+ );
+
+ const renderContent = () => {
+ if (isAlternateBuffer) {
+ return (
+
+
+ {title}
+
+
+
+
+
+ {discoveryContent}
+
+
+
+
+ {selectOptions}
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {title}
+
+
+ {discoveryContent}
+
+
+ {selectOptions}
+
+ );
+ };
+
+ const content = (
+
+
+ {renderContent()}
+
+
+
+
+
+
+ {isRestarting && (
+
+
+ Gemini CLI is restarting to apply the trust changes...
+
+
+ )}
+ {exiting && (
+
+
+ A folder trust level must be selected to continue. Exiting since
+ escape was pressed.
+
+
+ )}
+
+ );
+
+ return {content};
+};
diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ab242928aa711f98bbcfef88bcf175a6d8a0761e
--- /dev/null
+++ b/packages/cli/src/ui/components/Footer.test.tsx
@@ -0,0 +1,897 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
+import { renderWithProviders } from '../../test-utils/render.js';
+import { Footer } from './Footer.js';
+import { createMockSettings } from '../../test-utils/settings.js';
+import {
+ type Config,
+ UserAccountManager,
+ AuthType,
+} from '@google/gemini-cli-core';
+import path from 'node:path';
+
+// Normalize paths to POSIX slashes for stable cross-platform snapshots.
+const normalizeFrame = (frame: string | undefined) => {
+ if (!frame) return frame;
+ return frame.replace(/\\/g, '/');
+};
+
+const { mocks } = vi.hoisted(() => ({
+ mocks: {
+ isDevelopment: false,
+ },
+}));
+
+vi.mock('../../utils/installationInfo.js', async (importOriginal) => {
+ const original =
+ await importOriginal();
+ return {
+ ...original,
+ get isDevelopment() {
+ return mocks.isDevelopment;
+ },
+ };
+});
+
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const original =
+ await importOriginal();
+ return {
+ ...original,
+ shortenPath: (p: string, len: number) => {
+ if (p.length > len) {
+ return '...' + p.slice(p.length - len + 3);
+ }
+ return p;
+ },
+ };
+});
+
+const defaultProps = {
+ model: 'gemini-pro',
+ targetDir: path.join(
+ path.parse(process.cwd()).root,
+ 'Users',
+ 'test',
+ 'project',
+ 'foo',
+ 'bar',
+ 'and',
+ 'some',
+ 'more',
+ 'directories',
+ 'to',
+ 'make',
+ 'it',
+ 'long',
+ ),
+ branchName: 'main',
+};
+
+const mockConfigPlain = {
+ getTargetDir: () => defaultProps.targetDir,
+ getDebugMode: () => false,
+ getModel: () => defaultProps.model,
+ getIdeMode: () => false,
+ isTrustedFolder: () => true,
+ getExtensionRegistryURI: () => undefined,
+ getContentGeneratorConfig: () => ({ authType: undefined }),
+ getSandboxEnabled: () => false,
+ getSessionId: () => 'test-session-id',
+};
+
+const mockConfig = mockConfigPlain as unknown as Config;
+
+const mockSessionStats = {
+ sessionId: 'test-session-id',
+ sessionStartTime: new Date(),
+ promptCount: 0,
+ lastPromptTokenCount: 150000,
+ metrics: {
+ files: {
+ totalLinesAdded: 12,
+ totalLinesRemoved: 4,
+ },
+ tools: {
+ count: 0,
+ totalCalls: 0,
+ totalSuccess: 0,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: {
+ accept: 0,
+ reject: 0,
+ modify: 0,
+ auto_accept: 0,
+ },
+ byName: {},
+ latency: { avg: 0, max: 0, min: 0 },
+ },
+ models: {
+ 'gemini-pro': {
+ api: {
+ totalRequests: 0,
+ totalErrors: 0,
+ totalLatencyMs: 0,
+ },
+ tokens: {
+ input: 0,
+ prompt: 0,
+ candidates: 0,
+ total: 1500,
+ cached: 0,
+ thoughts: 0,
+ tool: 0,
+ },
+ roles: {},
+ },
+ },
+ },
+};
+
+describe('', () => {
+ beforeEach(() => {
+ const root = path.parse(process.cwd()).root;
+ vi.stubEnv('GEMINI_CLI_HOME', path.join(root, 'Users', 'test'));
+ vi.stubEnv('SANDBOX', '');
+ vi.stubEnv('SEATBELT_PROFILE', '');
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('renders the component', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: {
+ branchName: defaultProps.branchName,
+ sessionStats: mockSessionStats,
+ },
+ });
+ expect(lastFrame()).toBeDefined();
+ unmount();
+ });
+
+ describe('path display', () => {
+ it('should display a shortened path on a narrow terminal', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 79,
+ uiState: { sessionStats: mockSessionStats },
+ });
+ const output = lastFrame();
+ expect(output).toBeDefined();
+ // Should contain some part of the path, likely shortened
+ expect(output).toContain(path.join('make', 'it'));
+ unmount();
+ });
+
+ it('should use wide layout at 80 columns', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 80,
+ uiState: { sessionStats: mockSessionStats },
+ });
+ const output = lastFrame();
+ expect(output).toBeDefined();
+ expect(output).toContain(path.join('make', 'it'));
+ unmount();
+ });
+
+ it('should not truncate high-priority items on narrow terminals (regression)', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 60,
+ uiState: {
+ sessionStats: mockSessionStats,
+ },
+ settings: createMockSettings({
+ general: {
+ vimMode: true,
+ },
+ ui: {
+ footer: {
+ showLabels: true,
+ items: ['workspace', 'model-name'],
+ },
+ },
+ }),
+ });
+ const output = lastFrame();
+ // [INSERT] is high priority and should be fully visible
+ // (Note: VimModeProvider defaults to 'INSERT' mode when enabled)
+ expect(output).toContain('[INSERT]');
+ // Other items should be present but might be shortened
+ expect(output).toContain('gemini-pro');
+ unmount();
+ });
+ });
+
+ it('displays the branch name when provided', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: {
+ branchName: defaultProps.branchName,
+ sessionStats: mockSessionStats,
+ },
+ });
+ expect(lastFrame()).toContain(defaultProps.branchName);
+ unmount();
+ });
+
+ it('does not display the branch name when not provided', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: { branchName: undefined, sessionStats: mockSessionStats },
+ });
+ expect(lastFrame()).not.toContain('Branch');
+ unmount();
+ });
+
+ it('displays the model name and context percentage', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: {
+ currentModel: defaultProps.model,
+ sessionStats: {
+ ...mockSessionStats,
+ lastPromptTokenCount: 1000,
+ },
+ },
+ settings: createMockSettings({
+ ui: {
+ footer: {
+ hideContextPercentage: false,
+ },
+ },
+ }),
+ });
+ expect(lastFrame()).toContain(defaultProps.model);
+ expect(lastFrame()).toMatch(/\d+% used/);
+ unmount();
+ });
+
+ it('displays the usage indicator when usage is low', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: {
+ sessionStats: mockSessionStats,
+ },
+ quotaState: {
+ stats: {
+ remaining: 15,
+ limit: 100,
+ resetTime: undefined,
+ },
+ },
+ });
+ expect(lastFrame()).toContain('85% used');
+ expect(normalizeFrame(lastFrame())).toMatchSnapshot();
+ unmount();
+ });
+
+ it('hides the usage indicator when usage is not near limit', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: {
+ sessionStats: mockSessionStats,
+ },
+ quotaState: {
+ stats: {
+ remaining: 85,
+ limit: 100,
+ resetTime: undefined,
+ },
+ },
+ });
+ expect(normalizeFrame(lastFrame())).toContain('15% used');
+ expect(normalizeFrame(lastFrame())).toMatchSnapshot();
+ unmount();
+ });
+
+ it('displays "Limit reached" message when remaining is 0', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: {
+ sessionStats: mockSessionStats,
+ },
+ quotaState: {
+ stats: {
+ remaining: 0,
+ limit: 100,
+ resetTime: undefined,
+ },
+ },
+ });
+ expect(lastFrame()?.toLowerCase()).toContain('limit reached');
+ expect(normalizeFrame(lastFrame())).toMatchSnapshot();
+ unmount();
+ });
+
+ it('displays the model name and abbreviated context used label on narrow terminals', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 99,
+ uiState: { sessionStats: mockSessionStats },
+ settings: createMockSettings({
+ ui: {
+ footer: {
+ hideContextPercentage: false,
+ },
+ },
+ }),
+ });
+ expect(lastFrame()).toContain(defaultProps.model);
+ expect(lastFrame()).toMatch(/\d+%/);
+ expect(lastFrame()).not.toContain('context used');
+ unmount();
+ });
+
+ describe('sandbox and trust info', () => {
+ it('should display untrusted when isTrustedFolder is false', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
+ });
+ expect(lastFrame()).toContain('untrusted');
+ unmount();
+ });
+
+ it('should display "current process" for custom sandbox when SANDBOX env is set', async () => {
+ vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: {
+ isTrustedFolder: undefined,
+ sessionStats: mockSessionStats,
+ },
+ });
+ expect(lastFrame()).toContain('current process');
+ vi.unstubAllEnvs();
+ unmount();
+ });
+
+ it('should display "current process" for macOS Seatbelt when SANDBOX is sandbox-exec', async () => {
+ vi.stubEnv('SANDBOX', 'sandbox-exec');
+ vi.stubEnv('SEATBELT_PROFILE', 'test-profile');
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
+ });
+ expect(lastFrame()).toContain('current process');
+ vi.unstubAllEnvs();
+ unmount();
+ });
+
+ it('should display "no sandbox" when SANDBOX is not set and folder is trusted', async () => {
+ // Clear any SANDBOX env var that might be set.
+ vi.stubEnv('SANDBOX', '');
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
+ });
+ expect(lastFrame()).toContain('no sandbox');
+ vi.unstubAllEnvs();
+ unmount();
+ });
+
+ it('should display "all tools" when tool sandboxing is enabled and agent is local', async () => {
+ vi.stubEnv('SANDBOX', '');
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: Object.assign(
+ Object.create(Object.getPrototypeOf(mockConfig)),
+ mockConfig,
+ {
+ getSandboxEnabled: () => true,
+ },
+ ),
+ width: 120,
+ uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
+ });
+ expect(lastFrame()).toContain('all tools');
+ vi.unstubAllEnvs();
+ unmount();
+ });
+
+ it('should prioritize untrusted message over sandbox info', async () => {
+ vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
+ });
+ expect(lastFrame()).toContain('untrusted');
+ expect(lastFrame()).not.toMatch(/test-sandbox/s);
+ vi.unstubAllEnvs();
+ unmount();
+ });
+ });
+
+ describe('footer configuration filtering (golden snapshots)', () => {
+ it('renders complete footer with all sections visible (baseline)', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: { sessionStats: mockSessionStats },
+ settings: createMockSettings({
+ ui: {
+ footer: {
+ hideContextPercentage: false,
+ },
+ },
+ }),
+ });
+ expect(normalizeFrame(lastFrame())).toMatchSnapshot(
+ 'complete-footer-wide',
+ );
+ unmount();
+ });
+
+ it('renders footer with all optional sections hidden (minimal footer)', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: { sessionStats: mockSessionStats },
+ settings: createMockSettings({
+ ui: {
+ footer: {
+ hideCWD: true,
+ hideSandboxStatus: true,
+ hideModelInfo: true,
+ },
+ },
+ }),
+ });
+ // Wait for Ink to render
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ expect(normalizeFrame(lastFrame({ allowEmpty: true }))).toMatchSnapshot(
+ 'footer-minimal',
+ );
+ unmount();
+ });
+
+ it('renders footer with only model info hidden (partial filtering)', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: { sessionStats: mockSessionStats },
+ settings: createMockSettings({
+ ui: {
+ footer: {
+ hideCWD: false,
+ hideSandboxStatus: false,
+ hideModelInfo: true,
+ },
+ },
+ }),
+ });
+ expect(normalizeFrame(lastFrame())).toMatchSnapshot('footer-no-model');
+ unmount();
+ });
+
+ it('renders footer with CWD and model info hidden to test alignment (only sandbox visible)', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: { sessionStats: mockSessionStats },
+ settings: createMockSettings({
+ ui: {
+ footer: {
+ hideCWD: true,
+ hideSandboxStatus: false,
+ hideModelInfo: true,
+ },
+ },
+ }),
+ });
+ expect(normalizeFrame(lastFrame())).toMatchSnapshot(
+ 'footer-only-sandbox',
+ );
+ unmount();
+ });
+
+ it('hides the context percentage when hideContextPercentage is true', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: { sessionStats: mockSessionStats },
+ settings: createMockSettings({
+ ui: {
+ footer: {
+ hideContextPercentage: true,
+ },
+ },
+ }),
+ });
+ expect(lastFrame()).toContain(defaultProps.model);
+ expect(lastFrame()).not.toMatch(/\d+% used/);
+ unmount();
+ });
+ it('shows the context percentage when hideContextPercentage is false', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: { sessionStats: mockSessionStats },
+ settings: createMockSettings({
+ ui: {
+ footer: {
+ hideContextPercentage: false,
+ },
+ },
+ }),
+ });
+ expect(lastFrame()).toContain(defaultProps.model);
+ expect(lastFrame()).toMatch(/\d+% used/);
+ unmount();
+ });
+ it('renders complete footer in narrow terminal (baseline narrow)', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 79,
+ uiState: { sessionStats: mockSessionStats },
+ settings: createMockSettings({
+ ui: {
+ footer: {
+ hideContextPercentage: false,
+ },
+ },
+ }),
+ });
+ expect(normalizeFrame(lastFrame())).toMatchSnapshot(
+ 'complete-footer-narrow',
+ );
+ unmount();
+ });
+ });
+
+ describe('Footer Token Formatting', () => {
+ const renderWithTokens = async (tokens: number) => {
+ const result = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: {
+ sessionStats: {
+ ...mockSessionStats,
+ metrics: {
+ ...mockSessionStats.metrics,
+ models: {
+ 'gemini-pro': {
+ api: {
+ totalRequests: 0,
+ totalErrors: 0,
+ totalLatencyMs: 0,
+ },
+ tokens: {
+ input: 0,
+ prompt: 0,
+ candidates: 0,
+ total: tokens,
+ cached: 0,
+ thoughts: 0,
+ tool: 0,
+ },
+ roles: {},
+ },
+ },
+ },
+ },
+ },
+ settings: createMockSettings({
+ ui: {
+ footer: {
+ items: ['token-count'],
+ },
+ },
+ }),
+ });
+ await result.waitUntilReady();
+ return result;
+ };
+
+ it('formats thousands with k', async () => {
+ const { lastFrame, unmount } = await renderWithTokens(1500);
+ expect(lastFrame()).toContain('1.5k tokens');
+ unmount();
+ });
+
+ it('formats millions with m', async () => {
+ const { lastFrame, unmount } = await renderWithTokens(1500000);
+ expect(lastFrame()).toContain('1.5m tokens');
+ unmount();
+ });
+
+ it('formats billions with b', async () => {
+ const { lastFrame, unmount } = await renderWithTokens(1500000000);
+ expect(lastFrame()).toContain('1.5b tokens');
+ unmount();
+ });
+
+ it('formats small numbers without suffix', async () => {
+ const { lastFrame, unmount } = await renderWithTokens(500);
+ expect(lastFrame()).toContain('500 tokens');
+ unmount();
+ });
+ });
+
+ describe('error summary visibility', () => {
+ beforeEach(() => {
+ mocks.isDevelopment = false;
+ });
+
+ afterEach(() => {
+ mocks.isDevelopment = false;
+ });
+
+ it('hides error summary in low verbosity mode out of dev mode', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: {
+ sessionStats: mockSessionStats,
+ errorCount: 2,
+ showErrorDetails: false,
+ },
+ settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
+ });
+ expect(lastFrame()).not.toContain('F12 for details');
+ unmount();
+ });
+
+ it('shows error summary in low verbosity mode in dev mode', async () => {
+ mocks.isDevelopment = true;
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: {
+ sessionStats: mockSessionStats,
+ errorCount: 2,
+ showErrorDetails: false,
+ },
+ settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
+ });
+ expect(lastFrame()).toContain('F12 for details');
+ expect(lastFrame()).toContain('2 errors');
+ unmount();
+ });
+
+ it('shows error summary in full verbosity mode', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: {
+ sessionStats: mockSessionStats,
+ errorCount: 2,
+ showErrorDetails: false,
+ },
+ settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
+ });
+ expect(lastFrame()).toContain('F12 for details');
+ expect(lastFrame()).toContain('2 errors');
+ unmount();
+ });
+ });
+
+ describe('Footer Custom Items', () => {
+ it('renders auth item with email', async () => {
+ const authConfig = {
+ ...mockConfigPlain,
+ getContentGeneratorConfig: () => ({
+ authType: AuthType.LOGIN_WITH_GOOGLE,
+ }),
+ } as unknown as Config;
+ const getCachedAccountSpy = vi
+ .spyOn(UserAccountManager.prototype, 'getCachedGoogleAccount')
+ .mockReturnValue('test@example.com');
+
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: authConfig,
+ width: 120,
+ uiState: {
+ currentModel: 'gemini-pro',
+ sessionStats: mockSessionStats,
+ },
+ settings: createMockSettings({
+ ui: {
+ footer: {
+ items: ['auth'],
+ },
+ },
+ }),
+ });
+
+ expect(lastFrame()).toContain('auth');
+ expect(lastFrame()).toContain('test@example.com');
+ unmount();
+ getCachedAccountSpy.mockRestore();
+ });
+
+ it('does NOT render auth item when showUserIdentity is false', async () => {
+ const authConfig = {
+ ...mockConfigPlain,
+ getContentGeneratorConfig: () => ({
+ authType: AuthType.LOGIN_WITH_GOOGLE,
+ }),
+ } as unknown as Config;
+ const getCachedAccountSpy = vi
+ .spyOn(UserAccountManager.prototype, 'getCachedGoogleAccount')
+ .mockReturnValue('test@example.com');
+
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: authConfig,
+ width: 120,
+ uiState: {
+ currentModel: 'gemini-pro',
+ sessionStats: mockSessionStats,
+ },
+ settings: createMockSettings({
+ ui: {
+ showUserIdentity: false,
+ footer: {
+ items: ['workspace', 'auth'],
+ },
+ },
+ }),
+ });
+
+ const output = lastFrame();
+ expect(output).toContain('workspace');
+ expect(output).not.toContain('auth');
+ expect(output).not.toContain('test@example.com');
+ unmount();
+ getCachedAccountSpy.mockRestore();
+ });
+
+ it('renders items in the specified order', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: {
+ currentModel: 'gemini-pro',
+ sessionStats: mockSessionStats,
+ },
+ settings: createMockSettings({
+ ui: {
+ footer: {
+ items: ['model-name', 'workspace'],
+ },
+ },
+ }),
+ });
+
+ const output = lastFrame();
+ const modelIdx = output.indexOf('/model');
+ const cwdIdx = output.indexOf('workspace (/directory)');
+ expect(modelIdx).toBeLessThan(cwdIdx);
+ unmount();
+ });
+
+ it('renders multiple items with proper alignment', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: {
+ sessionStats: mockSessionStats,
+ branchName: 'main',
+ },
+ settings: createMockSettings({
+ vimMode: {
+ vimMode: true,
+ },
+ ui: {
+ footer: {
+ items: ['workspace', 'git-branch', 'sandbox', 'model-name'],
+ },
+ },
+ }),
+ });
+
+ const output = lastFrame();
+ expect(output).toBeDefined();
+ // Headers should be present
+ expect(output).toContain('workspace (/directory)');
+ expect(output).toContain('branch');
+ expect(output).toContain('sandbox');
+ expect(output).toContain('/model');
+ // Data should be present
+ expect(output).toContain('main');
+ expect(output).toContain('gemini-pro');
+ unmount();
+ });
+
+ it('handles empty items array', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: { sessionStats: mockSessionStats },
+ settings: createMockSettings({
+ ui: {
+ footer: {
+ items: [],
+ },
+ },
+ }),
+ });
+ // Wait for Ink to render
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ const output = lastFrame({ allowEmpty: true });
+ expect(output).toBeDefined();
+ expect(output.trim()).toBe('');
+ unmount();
+ });
+
+ it('does not render items that are conditionally hidden', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: {
+ sessionStats: mockSessionStats,
+ branchName: undefined, // No branch
+ },
+ settings: createMockSettings({
+ ui: {
+ footer: {
+ items: ['workspace', 'git-branch', 'model-name'],
+ },
+ },
+ }),
+ });
+
+ const output = lastFrame();
+ expect(output).toBeDefined();
+ expect(output).not.toContain('branch');
+ expect(output).toContain('workspace (/directory)');
+ expect(output).toContain('/model');
+ unmount();
+ });
+ });
+
+ describe('fallback mode display', () => {
+ it('should display Flash model when in fallback mode, not the configured Pro model', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: {
+ sessionStats: mockSessionStats,
+ currentModel: 'gemini-2.5-flash', // Fallback active, showing Flash
+ },
+ });
+
+ // Footer should show the effective model (Flash), not the config model (Pro)
+ expect(lastFrame()).toContain('gemini-2.5-flash');
+ expect(lastFrame()).not.toContain('gemini-2.5-pro');
+ unmount();
+ });
+
+ it('should display Pro model when NOT in fallback mode', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ config: mockConfig,
+ width: 120,
+ uiState: {
+ sessionStats: mockSessionStats,
+ currentModel: 'gemini-2.5-pro', // Normal mode, showing Pro
+ },
+ });
+
+ expect(lastFrame()).toContain('gemini-2.5-pro');
+ unmount();
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..378e89e453ea984a7889801f1fc03c78aa4b4605
--- /dev/null
+++ b/packages/cli/src/ui/components/Footer.tsx
@@ -0,0 +1,543 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { useState, useEffect } from 'react';
+import { Box, Text } from 'ink';
+import { theme } from '../semantic-colors.js';
+import {
+ shortenPath,
+ tildeifyPath,
+ getDisplayString,
+ checkExhaustive,
+ AuthType,
+ UserAccountManager,
+} from '@google/gemini-cli-core';
+import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
+import process from 'node:process';
+import os from 'node:os';
+import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
+import { ContextUsageDisplay } from './ContextUsageDisplay.js';
+import { QuotaDisplay } from './QuotaDisplay.js';
+import { DebugProfiler } from './DebugProfiler.js';
+import { useUIState } from '../contexts/UIStateContext.js';
+import { useQuotaState } from '../contexts/QuotaContext.js';
+import { useConfig } from '../contexts/ConfigContext.js';
+import { useSettings } from '../contexts/SettingsContext.js';
+import { useVimMode } from '../contexts/VimModeContext.js';
+import { useInputState } from '../contexts/InputContext.js';
+import {
+ ALL_ITEMS,
+ type FooterItemId,
+ deriveItemsFromLegacySettings,
+} from '../../config/footerItems.js';
+import { isDevelopment } from '../../utils/installationInfo.js';
+
+const HOSTNAME = os.hostname();
+
+interface CwdIndicatorProps {
+ targetDir: string;
+ maxWidth: number;
+ debugMode?: boolean;
+ debugMessage?: string;
+ color?: string;
+}
+
+const CwdIndicator: React.FC = ({
+ targetDir,
+ maxWidth,
+ debugMode,
+ debugMessage,
+ color = theme.text.primary,
+}) => {
+ const debugSuffix = debugMode ? ' ' + (debugMessage || '--debug') : '';
+ const availableForPath = Math.max(10, maxWidth - debugSuffix.length);
+ const displayPath = shortenPath(tildeifyPath(targetDir), availableForPath);
+
+ return (
+
+ {displayPath}
+ {debugMode && {debugSuffix}}
+
+ );
+};
+
+interface SandboxIndicatorProps {
+ isTrustedFolder: boolean | undefined;
+}
+
+const SandboxIndicator: React.FC = ({
+ isTrustedFolder,
+}) => {
+ const config = useConfig();
+ const sandboxEnabled = config.getSandboxEnabled();
+ if (isTrustedFolder === false) {
+ return untrusted;
+ }
+
+ const sandbox = process.env['SANDBOX'];
+ if (sandbox) {
+ return current process;
+ }
+
+ if (sandboxEnabled) {
+ return all tools;
+ }
+
+ return no sandbox;
+};
+
+const CorgiIndicator: React.FC = () => (
+
+ ▼
+ (´
+ ᴥ
+ `)
+ ▼
+
+);
+
+export interface FooterRowItem {
+ key: string;
+ header: string;
+ element: React.ReactNode;
+ flexGrow?: number;
+ flexShrink?: number;
+ isFocused?: boolean;
+ alignItems?: 'flex-start' | 'center' | 'flex-end';
+}
+
+const COLUMN_GAP = 3;
+
+export const FooterRow: React.FC<{
+ items: FooterRowItem[];
+ showLabels: boolean;
+}> = ({ items, showLabels }) => {
+ const elements: React.ReactNode[] = [];
+
+ items.forEach((item, idx) => {
+ if (idx > 0) {
+ elements.push(
+
+ {!showLabels && · }
+ ,
+ );
+ }
+
+ elements.push(
+
+ {showLabels && (
+
+
+ {item.header}
+
+
+ )}
+ {item.element}
+ ,
+ );
+ });
+
+ return (
+
+ {elements}
+
+ );
+};
+
+function isFooterItemId(id: string): id is FooterItemId {
+ return ALL_ITEMS.some((i) => i.id === id);
+}
+
+interface FooterColumn {
+ id: string;
+ header: string;
+ element: (maxWidth: number) => React.ReactNode;
+ width: number;
+ isHighPriority: boolean;
+}
+
+export const Footer: React.FC = () => {
+ const uiState = useUIState();
+ const quotaState = useQuotaState();
+ const { copyModeEnabled } = useInputState();
+ const config = useConfig();
+ const settings = useSettings();
+ const { vimEnabled, vimMode } = useVimMode();
+
+ const authType = config.getContentGeneratorConfig()?.authType;
+ const [email, setEmail] = useState();
+
+ useEffect(() => {
+ if (authType) {
+ const userAccountManager = new UserAccountManager();
+ setEmail(userAccountManager.getCachedGoogleAccount() ?? undefined);
+ } else {
+ setEmail(undefined);
+ }
+ }, [authType]);
+
+ const {
+ model,
+ targetDir,
+ debugMode,
+ branchName,
+ debugMessage,
+ corgiMode,
+ errorCount,
+ showErrorDetails,
+ promptTokenCount,
+ isTrustedFolder,
+ terminalWidth,
+ } = {
+ model: uiState.currentModel,
+ targetDir: config.getTargetDir(),
+ debugMode: config.getDebugMode(),
+ branchName: uiState.branchName,
+ debugMessage: uiState.debugMessage,
+ corgiMode: uiState.corgiMode,
+ errorCount: uiState.errorCount,
+ showErrorDetails: uiState.showErrorDetails,
+ promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
+ isTrustedFolder: uiState.isTrustedFolder,
+ terminalWidth: uiState.terminalWidth,
+ };
+
+ const quotaStats = quotaState.stats;
+
+ const isFullErrorVerbosity = settings.merged.ui.errorVerbosity === 'full';
+ const showErrorSummary =
+ !showErrorDetails &&
+ errorCount > 0 &&
+ (isFullErrorVerbosity || debugMode || isDevelopment);
+ const displayVimMode = vimEnabled ? vimMode : undefined;
+
+ const items =
+ settings.merged.ui.footer.items ??
+ deriveItemsFromLegacySettings(settings.merged);
+ const showLabels = settings.merged.ui.footer.showLabels !== false;
+ const itemColor = showLabels ? theme.text.primary : theme.ui.comment;
+
+ const potentialColumns: FooterColumn[] = [];
+
+ const addCol = (
+ id: string,
+ header: string,
+ element: (maxWidth: number) => React.ReactNode,
+ dataWidth: number,
+ isHighPriority = false,
+ ) => {
+ potentialColumns.push({
+ id,
+ header: showLabels ? header : '',
+ element,
+ width: Math.max(dataWidth, showLabels ? header.length : 0),
+ isHighPriority,
+ });
+ };
+
+ // 1. System Indicators (Far Left, high priority)
+ if (uiState.showDebugProfiler) {
+ addCol('debug', '', () => , 45, true);
+ }
+ if (displayVimMode) {
+ const vimStr = `[${displayVimMode}]`;
+ addCol(
+ 'vim',
+ '',
+ () => {vimStr},
+ vimStr.length,
+ true,
+ );
+ }
+
+ // 2. Main Configurable Items
+ for (const id of items) {
+ if (!isFooterItemId(id)) continue;
+ const itemConfig = ALL_ITEMS.find((i) => i.id === id);
+ const header = itemConfig?.header ?? id;
+
+ switch (id) {
+ case 'workspace': {
+ const fullPath = tildeifyPath(targetDir);
+ const debugSuffix = debugMode ? ' ' + (debugMessage || '--debug') : '';
+ addCol(
+ id,
+ header,
+ (maxWidth) => (
+
+ ),
+ fullPath.length + debugSuffix.length,
+ );
+ break;
+ }
+ case 'git-branch': {
+ if (branchName) {
+ addCol(
+ id,
+ header,
+ () => {branchName},
+ branchName.length,
+ );
+ }
+ break;
+ }
+ case 'sandbox': {
+ let str = 'no sandbox';
+ const sandbox = process.env['SANDBOX'];
+ if (isTrustedFolder === false) str = 'untrusted';
+ else if (sandbox) str = 'current process';
+ else if (config.getSandboxEnabled()) str = 'all tools';
+
+ addCol(
+ id,
+ header,
+ () => ,
+ str.length,
+ );
+ break;
+ }
+ case 'model-name': {
+ const str = getDisplayString(model);
+ addCol(
+ id,
+ header,
+ () => {str},
+ str.length,
+ );
+ break;
+ }
+ case 'context-used': {
+ addCol(
+ id,
+ header,
+ () => (
+
+ ),
+ 10, // "100% used" is 9 chars
+ );
+ break;
+ }
+ case 'quota': {
+ if (quotaStats?.remaining !== undefined && quotaStats.limit) {
+ addCol(
+ id,
+ header,
+ () => (
+
+ ),
+ 9, // "100% used" is 9 chars
+ );
+ }
+ break;
+ }
+ case 'memory-usage': {
+ addCol(
+ id,
+ header,
+ () => (
+
+ ),
+ 10,
+ );
+ break;
+ }
+ case 'session-id': {
+ addCol(
+ id,
+ header,
+ () => (
+
+ {uiState.sessionStats.sessionId.slice(0, 8)}
+
+ ),
+ 8,
+ );
+ break;
+ }
+ case 'hostname': {
+ addCol(
+ id,
+ header,
+ () => {HOSTNAME},
+ HOSTNAME.length,
+ );
+ break;
+ }
+ case 'auth': {
+ if (!settings.merged.ui.showUserIdentity) break;
+ if (!authType) break;
+ const displayStr =
+ authType === AuthType.LOGIN_WITH_GOOGLE
+ ? (email ?? 'google')
+ : authType;
+ addCol(
+ id,
+ header,
+ () => (
+
+ {displayStr}
+
+ ),
+ displayStr.length,
+ );
+ break;
+ }
+ case 'code-changes': {
+ const added = uiState.sessionStats.metrics.files.totalLinesAdded;
+ const removed = uiState.sessionStats.metrics.files.totalLinesRemoved;
+ if (added > 0 || removed > 0) {
+ const str = `+${added} -${removed}`;
+ addCol(
+ id,
+ header,
+ () => (
+
+ +{added}{' '}
+ -{removed}
+
+ ),
+ str.length,
+ );
+ }
+ break;
+ }
+ case 'token-count': {
+ let total = 0;
+ for (const m of Object.values(uiState.sessionStats.metrics.models))
+ total += m.tokens.total;
+ if (total > 0) {
+ const formatter = new Intl.NumberFormat('en-US', {
+ notation: 'compact',
+ maximumFractionDigits: 1,
+ });
+ const formatted = formatter.format(total).toLowerCase();
+ addCol(
+ id,
+ header,
+ () => {formatted} tokens,
+ formatted.length + 7,
+ );
+ }
+ break;
+ }
+ default:
+ checkExhaustive(id);
+ break;
+ }
+ }
+
+ // 3. Transients
+ if (corgiMode) addCol('corgi', '', () => , 5);
+ if (showErrorSummary) {
+ addCol(
+ 'error-count',
+ '',
+ () => ,
+ 12,
+ true,
+ );
+ }
+
+ // --- Width Fitting Logic ---
+ const columnsToRender: FooterColumn[] = [];
+ let droppedAny = false;
+ let currentUsedWidth = 2; // Initial padding
+
+ for (const col of potentialColumns) {
+ const gap = columnsToRender.length > 0 ? (showLabels ? COLUMN_GAP : 3) : 0;
+ const budgetWidth = col.id === 'workspace' ? 20 : col.width;
+
+ if (
+ col.isHighPriority ||
+ currentUsedWidth + gap + budgetWidth <= terminalWidth - 2
+ ) {
+ columnsToRender.push(col);
+ currentUsedWidth += gap + budgetWidth;
+ } else {
+ droppedAny = true;
+ }
+ }
+
+ const rowItems: FooterRowItem[] = columnsToRender.map((col, index) => {
+ const isWorkspace = col.id === 'workspace';
+ const isLast = index === columnsToRender.length - 1;
+
+ // Calculate exact space available for growth to prevent over-estimation truncation
+ const otherItemsWidth = columnsToRender
+ .filter((c) => c.id !== 'workspace')
+ .reduce((sum, c) => sum + c.width, 0);
+ const numItems = columnsToRender.length + (droppedAny ? 1 : 0);
+ const numGaps = numItems > 1 ? numItems - 1 : 0;
+ const gapsWidth = numGaps * (showLabels ? COLUMN_GAP : 3);
+ const ellipsisWidth = droppedAny ? 1 : 0;
+
+ const availableForWorkspace = Math.max(
+ 20,
+ terminalWidth - 2 - gapsWidth - otherItemsWidth - ellipsisWidth,
+ );
+
+ const estimatedWidth = isWorkspace ? availableForWorkspace : col.width;
+
+ return {
+ key: col.id,
+ header: col.header,
+ element: col.element(estimatedWidth),
+ flexGrow: 0,
+ flexShrink: isWorkspace ? 1 : 0,
+ alignItems:
+ isLast && !droppedAny && index > 0 ? 'flex-end' : 'flex-start',
+ };
+ });
+
+ if (droppedAny) {
+ rowItems.push({
+ key: 'ellipsis',
+ header: '',
+ element: …,
+ flexGrow: 0,
+ flexShrink: 0,
+ alignItems: 'flex-end',
+ });
+ }
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/cli/src/ui/components/FooterConfigDialog.test.tsx b/packages/cli/src/ui/components/FooterConfigDialog.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a2ba542cdd98ddbc942acf8c3ee5f1513ac5c862
--- /dev/null
+++ b/packages/cli/src/ui/components/FooterConfigDialog.test.tsx
@@ -0,0 +1,264 @@
+/**
+ * @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 { waitFor } from '../../test-utils/async.js';
+import { FooterConfigDialog } from './FooterConfigDialog.js';
+import { createMockSettings } from '../../test-utils/settings.js';
+import { ALL_ITEMS } from '../../config/footerItems.js';
+import { act } from 'react';
+
+describe('', () => {
+ const mockOnClose = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renders correctly with default settings', async () => {
+ const settings = createMockSettings();
+ const renderResult = await renderWithProviders(
+ ,
+ { settings },
+ );
+
+ expect(renderResult.lastFrame()).toMatchSnapshot();
+ await expect(renderResult).toMatchSvgSnapshot();
+ });
+
+ it('toggles an item when enter is pressed', async () => {
+ const settings = createMockSettings();
+ const { lastFrame, stdin } = await renderWithProviders(
+ ,
+ { settings },
+ );
+
+ act(() => {
+ stdin.write('\r'); // Enter to toggle
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('[ ] workspace');
+ });
+
+ act(() => {
+ stdin.write('\r');
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('[✓] workspace');
+ });
+ });
+
+ it('reorders items with arrow keys', async () => {
+ const settings = createMockSettings();
+ const { lastFrame, stdin } = await renderWithProviders(
+ ,
+ { settings },
+ );
+
+ // Initial order: workspace, git-branch, ...
+ const output = lastFrame();
+ const cwdIdx = output.indexOf('] workspace');
+ const branchIdx = output.indexOf('] git-branch');
+ expect(cwdIdx).toBeGreaterThan(-1);
+ expect(branchIdx).toBeGreaterThan(-1);
+ expect(cwdIdx).toBeLessThan(branchIdx);
+
+ // Move workspace down (right arrow)
+ act(() => {
+ stdin.write('\u001b[C'); // Right arrow
+ });
+
+ await waitFor(() => {
+ const outputAfter = lastFrame();
+ const cwdIdxAfter = outputAfter.indexOf('] workspace');
+ const branchIdxAfter = outputAfter.indexOf('] git-branch');
+ expect(cwdIdxAfter).toBeGreaterThan(-1);
+ expect(branchIdxAfter).toBeGreaterThan(-1);
+ expect(branchIdxAfter).toBeLessThan(cwdIdxAfter);
+ });
+ });
+
+ it('closes on Esc', async () => {
+ const settings = createMockSettings();
+ const { stdin } = await renderWithProviders(
+ ,
+ { settings },
+ );
+
+ act(() => {
+ stdin.write('\x1b'); // Esc
+ });
+
+ await waitFor(() => {
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+ });
+
+ it('highlights the active item in the preview', async () => {
+ const settings = createMockSettings();
+ const renderResult = await renderWithProviders(
+ ,
+ { settings },
+ );
+
+ const { lastFrame, stdin } = renderResult;
+
+ expect(lastFrame()).toContain('~/project/path');
+
+ // Move focus down to 'code-changes' (which has colored elements)
+ for (let i = 0; i < 10; i++) {
+ act(() => {
+ stdin.write('\u001b[B'); // Down arrow
+ });
+ }
+
+ await waitFor(() => {
+ // The selected indicator should be next to 'code-changes'
+ expect(lastFrame()).toMatch(/> \[ \] code-changes/);
+ });
+
+ // Toggle it on
+ act(() => {
+ stdin.write('\r');
+ });
+
+ await waitFor(() => {
+ // It should now be checked and appear in the preview
+ expect(lastFrame()).toMatch(/> \[✓\] code-changes/);
+ expect(lastFrame()).toContain('+12 -4');
+ });
+
+ await expect(renderResult).toMatchSvgSnapshot();
+ });
+
+ it('shows an empty preview when all items are deselected', async () => {
+ const settings = createMockSettings();
+ const { lastFrame, stdin } = await renderWithProviders(
+ ,
+ { settings },
+ );
+
+ // Default items are the first 5. We toggle them off.
+ for (let i = 0; i < 5; i++) {
+ act(() => {
+ stdin.write('\r'); // Toggle off
+ });
+ act(() => {
+ stdin.write('\u001b[B'); // Down arrow
+ });
+ }
+
+ await waitFor(
+ () => {
+ const output = lastFrame();
+ expect(output).toContain('Preview:');
+ expect(output).not.toContain('~/project/path');
+ expect(output).not.toContain('docker');
+ },
+ { timeout: 2000 },
+ );
+ });
+
+ it('moves item correctly after trying to move up at the top', async () => {
+ const settings = createMockSettings();
+ const { lastFrame, stdin } = await renderWithProviders(
+ ,
+ { settings },
+ );
+
+ // Default initial items in mock settings are 'git-branch', 'workspace', ...
+ await waitFor(() => {
+ const output = lastFrame();
+ expect(output).toContain('] git-branch');
+ expect(output).toContain('] workspace');
+ });
+
+ const output = lastFrame();
+ const branchIdx = output.indexOf('] git-branch');
+ const workspaceIdx = output.indexOf('] workspace');
+ expect(workspaceIdx).toBeLessThan(branchIdx);
+
+ // Try to move workspace up (left arrow) while it's at the top
+ act(() => {
+ stdin.write('\u001b[D'); // Left arrow
+ });
+
+ // Move workspace down (right arrow)
+ act(() => {
+ stdin.write('\u001b[C'); // Right arrow
+ });
+
+ await waitFor(() => {
+ const outputAfter = lastFrame();
+ const bIdxAfter = outputAfter.indexOf('] git-branch');
+ const wIdxAfter = outputAfter.indexOf('] workspace');
+ // workspace should now be after git-branch
+ expect(bIdxAfter).toBeLessThan(wIdxAfter);
+ });
+ });
+
+ it('updates the preview when Show footer labels is toggled off', async () => {
+ const settings = createMockSettings();
+ const renderResult = await renderWithProviders(
+ ,
+ { settings },
+ );
+
+ const { lastFrame, stdin } = renderResult;
+
+ // By default labels are on
+ expect(lastFrame()).toContain('workspace (/directory)');
+ expect(lastFrame()).toContain('sandbox');
+ expect(lastFrame()).toContain('/model');
+
+ // Move to "Show footer labels" (which is the second to last item)
+ for (let i = 0; i < ALL_ITEMS.length; i++) {
+ act(() => {
+ stdin.write('\u001b[B'); // Down arrow
+ });
+ }
+
+ await waitFor(() => {
+ expect(lastFrame()).toMatch(/> \[✓\] Show footer labels/);
+ });
+
+ // Toggle it off
+ act(() => {
+ stdin.write('\r');
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toMatch(/> \[ \] Show footer labels/);
+ // The headers should no longer be in the preview
+ expect(lastFrame()).not.toContain('workspace (/directory)');
+ expect(lastFrame()).not.toContain('/model');
+
+ // We can't strictly search for "sandbox" because the menu item also says "sandbox".
+ // Let's assert that the spacer dots are now present in the preview instead.
+ const previewLine =
+ lastFrame()
+ .split('\n')
+ .find((line) => line.includes('Preview:')) || '';
+ const nextLine =
+ lastFrame().split('\n')[
+ lastFrame().split('\n').indexOf(previewLine) + 1
+ ] || '';
+ expect(nextLine).toContain('·');
+ expect(nextLine).toContain('~/project/path');
+ expect(nextLine).toContain('docker');
+ expect(nextLine).toContain('42% used');
+ });
+
+ await expect(renderResult).toMatchSvgSnapshot();
+ });
+});
diff --git a/packages/cli/src/ui/components/FooterConfigDialog.tsx b/packages/cli/src/ui/components/FooterConfigDialog.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..10c4e71802f972cb7c792343f7270f858179e376
--- /dev/null
+++ b/packages/cli/src/ui/components/FooterConfigDialog.tsx
@@ -0,0 +1,402 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { useCallback, useMemo, useReducer, useState } from 'react';
+import { Box, Text } from 'ink';
+import { theme } from '../semantic-colors.js';
+import { useSettingsStore } from '../contexts/SettingsContext.js';
+import { useUIState } from '../contexts/UIStateContext.js';
+import { useKeypress, type Key } from '../hooks/useKeypress.js';
+import { Command } from '../key/keyMatchers.js';
+import { FooterRow, type FooterRowItem } from './Footer.js';
+import {
+ ALL_ITEMS,
+ resolveFooterState,
+ deriveItemsFromLegacySettings,
+} from '../../config/footerItems.js';
+import { SettingScope } from '../../config/settings.js';
+import { BaseSelectionList } from './shared/BaseSelectionList.js';
+import type { SelectionListItem } from '../hooks/useSelectionList.js';
+import { DialogFooter } from './shared/DialogFooter.js';
+import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
+
+interface FooterConfigDialogProps {
+ onClose?: () => void;
+}
+
+interface FooterConfigItem {
+ key: string;
+ id: string;
+ label: string;
+ description?: string;
+ type: 'config' | 'labels-toggle' | 'reset';
+}
+
+interface FooterConfigState {
+ orderedIds: string[];
+ selectedIds: Set;
+}
+
+type FooterConfigAction =
+ | { type: 'MOVE_ITEM'; id: string; direction: number }
+ | { type: 'TOGGLE_ITEM'; id: string }
+ | { type: 'SET_STATE'; payload: Partial };
+
+function footerConfigReducer(
+ state: FooterConfigState,
+ action: FooterConfigAction,
+): FooterConfigState {
+ switch (action.type) {
+ case 'MOVE_ITEM': {
+ const currentIndex = state.orderedIds.indexOf(action.id);
+ const newIndex = currentIndex + action.direction;
+ if (
+ currentIndex === -1 ||
+ newIndex < 0 ||
+ newIndex >= state.orderedIds.length
+ ) {
+ return state;
+ }
+ const newOrderedIds = [...state.orderedIds];
+ [newOrderedIds[currentIndex], newOrderedIds[newIndex]] = [
+ newOrderedIds[newIndex],
+ newOrderedIds[currentIndex],
+ ];
+ return { ...state, orderedIds: newOrderedIds };
+ }
+ case 'TOGGLE_ITEM': {
+ const nextSelected = new Set(state.selectedIds);
+ if (nextSelected.has(action.id)) {
+ nextSelected.delete(action.id);
+ } else {
+ nextSelected.add(action.id);
+ }
+ return { ...state, selectedIds: nextSelected };
+ }
+ case 'SET_STATE':
+ return { ...state, ...action.payload };
+ default:
+ return state;
+ }
+}
+
+export const FooterConfigDialog: React.FC = ({
+ onClose,
+}) => {
+ const keyMatchers = useKeyMatchers();
+ const { settings, setSetting } = useSettingsStore();
+ const { constrainHeight, terminalHeight, staticExtraHeight } = useUIState();
+ const [state, dispatch] = useReducer(footerConfigReducer, undefined, () =>
+ resolveFooterState(settings.merged),
+ );
+
+ const { orderedIds, selectedIds } = state;
+ const [focusKey, setFocusKey] = useState(orderedIds[0]);
+
+ const listItems = useMemo((): Array> => {
+ const items: Array> = orderedIds
+ .map((id: string) => {
+ const item = ALL_ITEMS.find((i) => i.id === id);
+ if (!item) return null;
+ return {
+ key: id,
+ value: {
+ key: id,
+ id,
+ label: item.id,
+ description: item.description as string,
+ type: 'config' as const,
+ },
+ };
+ })
+ .filter((i): i is NonNullable => i !== null);
+
+ items.push({
+ key: 'show-labels',
+ value: {
+ key: 'show-labels',
+ id: 'show-labels',
+ label: 'Show footer labels',
+ type: 'labels-toggle',
+ },
+ });
+
+ items.push({
+ key: 'reset',
+ value: {
+ key: 'reset',
+ id: 'reset',
+ label: 'Reset to default footer',
+ type: 'reset',
+ },
+ });
+
+ return items;
+ }, [orderedIds]);
+
+ const handleSaveAndClose = useCallback(() => {
+ const finalItems = orderedIds.filter((id: string) => selectedIds.has(id));
+ const currentSetting = settings.merged.ui?.footer?.items;
+ // When items haven't been explicitly set yet (legacy mode), compare against
+ // the legacy-derived items to avoid persisting items and silently overriding
+ // legacy boolean settings like hideContextPercentage.
+ const effectiveCurrent =
+ currentSetting ?? deriveItemsFromLegacySettings(settings.merged);
+ if (JSON.stringify(finalItems) !== JSON.stringify(effectiveCurrent)) {
+ setSetting(SettingScope.User, 'ui.footer.items', finalItems);
+ }
+ onClose?.();
+ }, [orderedIds, selectedIds, setSetting, settings.merged, onClose]);
+
+ const handleResetToDefaults = useCallback(() => {
+ setSetting(SettingScope.User, 'ui.footer.items', undefined);
+ const newState = resolveFooterState(settings.merged);
+ dispatch({ type: 'SET_STATE', payload: newState });
+ setFocusKey(newState.orderedIds[0]);
+ }, [setSetting, settings.merged]);
+
+ const handleToggleLabels = useCallback(() => {
+ const current = settings.merged.ui.footer.showLabels !== false;
+ setSetting(SettingScope.User, 'ui.footer.showLabels', !current);
+ }, [setSetting, settings.merged.ui.footer.showLabels]);
+
+ const handleSelect = useCallback(
+ (item: FooterConfigItem) => {
+ if (item.type === 'config') {
+ dispatch({ type: 'TOGGLE_ITEM', id: item.id });
+ } else if (item.type === 'labels-toggle') {
+ handleToggleLabels();
+ } else if (item.type === 'reset') {
+ handleResetToDefaults();
+ }
+ },
+ [handleResetToDefaults, handleToggleLabels],
+ );
+
+ const handleHighlight = useCallback((item: FooterConfigItem) => {
+ setFocusKey(item.key);
+ }, []);
+
+ useKeypress(
+ (key: Key) => {
+ if (keyMatchers[Command.ESCAPE](key)) {
+ handleSaveAndClose();
+ return true;
+ }
+
+ if (keyMatchers[Command.MOVE_LEFT](key)) {
+ if (focusKey && orderedIds.includes(focusKey)) {
+ dispatch({ type: 'MOVE_ITEM', id: focusKey, direction: -1 });
+ return true;
+ }
+ }
+
+ if (keyMatchers[Command.MOVE_RIGHT](key)) {
+ if (focusKey && orderedIds.includes(focusKey)) {
+ dispatch({ type: 'MOVE_ITEM', id: focusKey, direction: 1 });
+ return true;
+ }
+ }
+
+ return false;
+ },
+ { isActive: true, priority: true },
+ );
+
+ const showLabels = settings.merged.ui.footer.showLabels !== false;
+
+ // Preview logic
+ const previewContent = useMemo(() => {
+ if (focusKey === 'reset') {
+ return (
+
+ Default footer (uses legacy settings)
+
+ );
+ }
+
+ const itemsToPreview = orderedIds.filter((id: string) =>
+ selectedIds.has(id),
+ );
+ if (itemsToPreview.length === 0) return null;
+
+ const itemColor = showLabels ? theme.text.primary : theme.ui.comment;
+
+ const getColor = (id: string, defaultColor?: string) =>
+ defaultColor || itemColor;
+
+ // Mock data for preview (headers come from ALL_ITEMS)
+ const mockData: Record = {
+ workspace: (
+ ~/project/path
+ ),
+ 'git-branch': main,
+ sandbox: docker,
+ 'model-name': (
+ gemini-2.5-pro
+ ),
+ 'context-used': (
+ 85% used
+ ),
+ quota: 42% used,
+ 'memory-usage': (
+ 260 MB
+ ),
+ 'session-id': (
+ 769992f9
+ ),
+ hostname: (
+ dev-machine
+ ),
+ 'code-changes': (
+
+
+ +12
+
+
+ -4
+
+ ),
+ auth: test@example.com,
+ 'token-count': (
+ 1.5k tokens
+ ),
+ };
+
+ const rowItems: FooterRowItem[] = itemsToPreview
+ .filter((id: string) => mockData[id])
+ .map((id: string) => ({
+ key: id,
+ header: ALL_ITEMS.find((i) => i.id === id)?.header ?? id,
+ element: mockData[id],
+ flexGrow: 0,
+ isFocused: id === focusKey,
+ }));
+
+ return (
+
+
+
+ );
+ }, [orderedIds, selectedIds, focusKey, showLabels]);
+
+ const availableTerminalHeight = constrainHeight
+ ? terminalHeight - staticExtraHeight
+ : Number.MAX_SAFE_INTEGER;
+
+ const BORDER_HEIGHT = 2; // Outer round border
+ const STATIC_ELEMENTS = 13; // Text, margins, preview box, dialog footer
+
+ // Default padding adds 2 lines (top and bottom)
+ let includePadding = true;
+ if (availableTerminalHeight < BORDER_HEIGHT + 2 + STATIC_ELEMENTS + 6) {
+ includePadding = false;
+ }
+
+ const effectivePaddingY = includePadding ? 2 : 0;
+ const availableListSpace = Math.max(
+ 0,
+ availableTerminalHeight -
+ BORDER_HEIGHT -
+ effectivePaddingY -
+ STATIC_ELEMENTS,
+ );
+
+ const maxItemsToShow = Math.max(
+ 1,
+ Math.min(listItems.length, Math.floor(availableListSpace / 2)),
+ );
+
+ return (
+
+ Configure Footer{'\n'}
+
+ Select which items to display in the footer.
+
+
+
+
+ items={listItems}
+ onSelect={handleSelect}
+ onHighlight={handleHighlight}
+ focusKey={focusKey}
+ showNumbers={false}
+ maxItemsToShow={maxItemsToShow}
+ showScrollArrows={true}
+ selectedIndicator=">"
+ renderItem={(item, { isSelected, titleColor }) => {
+ const configItem = item.value;
+ const isChecked =
+ configItem.type === 'config'
+ ? selectedIds.has(configItem.id)
+ : configItem.type === 'labels-toggle'
+ ? showLabels
+ : false;
+
+ return (
+
+
+ {configItem.type !== 'reset' && (
+
+ [{isChecked ? '✓' : ' '}]
+
+ )}
+
+ {configItem.type !== 'reset' ? ' ' : ''}
+ {configItem.label}
+
+
+ {configItem.description && (
+
+ {' '}
+ {configItem.description}
+
+ )}
+
+ );
+ }}
+ />
+
+
+
+
+
+ Preview:
+
+ {previewContent}
+
+
+
+ );
+};
diff --git a/packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx b/packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e725ca3714b20a55e38e67b299233756ba228946
--- /dev/null
+++ b/packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx
@@ -0,0 +1,82 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { useStreamingContext } from '../contexts/StreamingContext.js';
+import { Text, useIsScreenReaderEnabled } from 'ink';
+import { StreamingState } from '../types.js';
+import {
+ SCREEN_READER_LOADING,
+ SCREEN_READER_RESPONDING,
+} from '../textConstants.js';
+
+vi.mock('../contexts/StreamingContext.js');
+vi.mock('ink', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useIsScreenReaderEnabled: vi.fn(),
+ };
+});
+
+vi.mock('./GeminiSpinner.js', () => ({
+ GeminiSpinner: ({ altText }: { altText?: string }) => (
+ GeminiSpinner {altText}
+ ),
+}));
+
+describe('GeminiRespondingSpinner', () => {
+ const mockUseStreamingContext = vi.mocked(useStreamingContext);
+ const mockUseIsScreenReaderEnabled = vi.mocked(useIsScreenReaderEnabled);
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseIsScreenReaderEnabled.mockReturnValue(false);
+ });
+
+ it('renders spinner when responding', async () => {
+ mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
+ const { lastFrame, unmount } = await render();
+ expect(lastFrame()).toContain('GeminiSpinner');
+ unmount();
+ });
+
+ it('renders screen reader text when responding and screen reader enabled', async () => {
+ mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
+ mockUseIsScreenReaderEnabled.mockReturnValue(true);
+ const { lastFrame, unmount } = await render();
+ expect(lastFrame()).toContain(SCREEN_READER_RESPONDING);
+ unmount();
+ });
+
+ it('renders nothing when not responding and no non-responding display', async () => {
+ mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
+ const { lastFrame, unmount } = await render();
+ expect(lastFrame({ allowEmpty: true })).toBe('');
+ unmount();
+ });
+
+ it('renders non-responding display when provided', async () => {
+ mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
+ const { lastFrame, unmount } = await render(
+ ,
+ );
+ expect(lastFrame()).toContain('Waiting...');
+ unmount();
+ });
+
+ it('renders screen reader loading text when non-responding display provided and screen reader enabled', async () => {
+ mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
+ mockUseIsScreenReaderEnabled.mockReturnValue(true);
+ const { lastFrame, unmount } = await render(
+ ,
+ );
+ expect(lastFrame()).toContain(SCREEN_READER_LOADING);
+ unmount();
+ });
+});
diff --git a/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx b/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..316438d7376ca1d013ad70f435cfce6d3bc67f63
--- /dev/null
+++ b/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx
@@ -0,0 +1,65 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Text, useIsScreenReaderEnabled } from 'ink';
+import type { SpinnerName } from 'cli-spinners';
+import { useStreamingContext } from '../contexts/StreamingContext.js';
+import { StreamingState } from '../types.js';
+import {
+ SCREEN_READER_LOADING,
+ SCREEN_READER_RESPONDING,
+} from '../textConstants.js';
+import { theme } from '../semantic-colors.js';
+import { GeminiSpinner } from './GeminiSpinner.js';
+
+interface GeminiRespondingSpinnerProps {
+ /**
+ * Optional string to display when not in Responding state.
+ * If not provided and not Responding, renders null.
+ */
+ nonRespondingDisplay?: string;
+ spinnerType?: SpinnerName;
+ /**
+ * If true, we prioritize showing the nonRespondingDisplay (hook icon)
+ * even if the state is Responding.
+ */
+ isHookActive?: boolean;
+ color?: string;
+}
+
+export const GeminiRespondingSpinner: React.FC<
+ GeminiRespondingSpinnerProps
+> = ({
+ nonRespondingDisplay,
+ spinnerType = 'dots',
+ isHookActive = false,
+ color,
+}) => {
+ const streamingState = useStreamingContext();
+ const isScreenReaderEnabled = useIsScreenReaderEnabled();
+
+ // If a hook is active, we want to show the hook icon (nonRespondingDisplay)
+ // to be consistent, instead of the rainbow spinner which means "Gemini is talking".
+ if (streamingState === StreamingState.Responding && !isHookActive) {
+ return (
+
+ );
+ }
+
+ if (nonRespondingDisplay) {
+ return isScreenReaderEnabled ? (
+ {SCREEN_READER_LOADING}
+ ) : (
+ {nonRespondingDisplay}
+ );
+ }
+
+ return null;
+};
diff --git a/packages/cli/src/ui/components/GeminiSpinner.tsx b/packages/cli/src/ui/components/GeminiSpinner.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..37d1930625bedd3a9f2bd11fb241b01cf056123e
--- /dev/null
+++ b/packages/cli/src/ui/components/GeminiSpinner.tsx
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { useState, useEffect, useMemo } from 'react';
+import { Text, useIsScreenReaderEnabled } from 'ink';
+import { CliSpinner } from './CliSpinner.js';
+import type { SpinnerName } from 'cli-spinners';
+import { Colors } from '../colors.js';
+import tinygradient from 'tinygradient';
+
+const COLOR_CYCLE_DURATION_MS = 4000;
+
+interface GeminiSpinnerProps {
+ spinnerType?: SpinnerName;
+ altText?: string;
+}
+
+export const GeminiSpinner: React.FC = ({
+ spinnerType = 'dots',
+ altText,
+}) => {
+ const isScreenReaderEnabled = useIsScreenReaderEnabled();
+ const [time, setTime] = useState(0);
+
+ const googleGradient = useMemo(() => {
+ const brandColors = [
+ Colors.AccentPurple,
+ Colors.AccentBlue,
+ Colors.AccentCyan,
+ Colors.AccentGreen,
+ Colors.AccentYellow,
+ Colors.AccentRed,
+ ];
+ return tinygradient([...brandColors, brandColors[0]]);
+ }, []);
+
+ useEffect(() => {
+ if (isScreenReaderEnabled) {
+ return;
+ }
+
+ const interval = setInterval(() => {
+ setTime((prevTime) => prevTime + 30);
+ }, 30); // ~33fps for smooth color transitions
+
+ return () => clearInterval(interval);
+ }, [isScreenReaderEnabled]);
+
+ const progress = (time % COLOR_CYCLE_DURATION_MS) / COLOR_CYCLE_DURATION_MS;
+ const currentColor = googleGradient.rgbAt(progress).toHexString();
+
+ return isScreenReaderEnabled ? (
+ {altText}
+ ) : (
+
+
+
+ );
+};
diff --git a/packages/cli/src/ui/components/GradientRegression.test.tsx b/packages/cli/src/ui/components/GradientRegression.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..75ecac6f9a9d88d9e77fc4618ad40c5cce1e171a
--- /dev/null
+++ b/packages/cli/src/ui/components/GradientRegression.test.tsx
@@ -0,0 +1,137 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { renderWithProviders } from '../../test-utils/render.js';
+import * as SessionContext from '../contexts/SessionContext.js';
+import { type SessionStatsState } from '../contexts/SessionContext.js';
+import { Banner } from './Banner.js';
+import { Footer } from './Footer.js';
+import { AppHeader } from './AppHeader.js';
+import { ModelDialog } from './ModelDialog.js';
+import { StatsDisplay } from './StatsDisplay.js';
+
+// Mock the theme module
+vi.mock('../semantic-colors.js', async (importOriginal) => {
+ const original =
+ await importOriginal();
+ return {
+ ...original,
+ theme: {
+ ...original.theme,
+ background: {
+ ...original.theme.background,
+ focus: '#004000',
+ },
+ ui: {
+ ...original.theme.ui,
+ focus: '#00ff00',
+ gradient: [], // Empty array to potentially trigger the crash
+ },
+ },
+ };
+});
+
+// Mock the context to provide controlled data for testing
+vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useSessionStats: vi.fn(),
+ };
+});
+
+const mockSessionStats: SessionStatsState = {
+ sessionId: 'test-session',
+ sessionStartTime: new Date(),
+ lastPromptTokenCount: 0,
+ promptCount: 0,
+ metrics: {
+ models: {},
+ tools: {
+ totalCalls: 0,
+ totalSuccess: 0,
+ totalFail: 0,
+ totalDurationMs: 0,
+ totalDecisions: { accept: 0, reject: 0, modify: 0, auto_accept: 0 },
+ byName: {},
+ },
+ files: { totalLinesAdded: 0, totalLinesRemoved: 0 },
+ },
+};
+
+const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
+useSessionStatsMock.mockReturnValue({
+ stats: mockSessionStats,
+ getPromptCount: () => 0,
+ startNewPrompt: vi.fn(),
+});
+
+describe('Gradient Crash Regression Tests', () => {
+ it(' should not crash when theme.ui.gradient is empty', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ width: 120,
+ },
+ );
+ expect(lastFrame()).toBeDefined();
+ unmount();
+ });
+
+ it(' should not crash when theme.ui.gradient is empty', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ {}} />,
+ {
+ width: 120,
+ },
+ );
+ expect(lastFrame()).toBeDefined();
+ unmount();
+ });
+
+ it(' should not crash when theme.ui.gradient is empty', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ width: 120,
+ },
+ );
+ expect(lastFrame()).toBeDefined();
+ unmount();
+ });
+
+ it(' should not crash when theme.ui.gradient has only one color (or empty) and nightly is true', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(, {
+ width: 120,
+ uiState: {
+ nightly: true, // Enable nightly to trigger Gradient usage logic
+ sessionStats: mockSessionStats,
+ },
+ });
+ // If it crashes, this line won't be reached or lastFrame() will throw
+ expect(lastFrame()).toBeDefined();
+ // It should fall back to rendering text without gradient
+ expect(lastFrame()).not.toContain('Gradient');
+ unmount();
+ });
+
+ it(' should not crash when theme.ui.gradient is empty', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ width: 120,
+ uiState: {
+ sessionStats: mockSessionStats,
+ },
+ },
+ );
+ expect(lastFrame()).toBeDefined();
+ // Ensure title is rendered
+ expect(lastFrame()).toContain('My Stats');
+ unmount();
+ });
+});
diff --git a/packages/cli/src/ui/components/Header.test.tsx b/packages/cli/src/ui/components/Header.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..dabd5ccb0ba4fb9756a7341ed325dee63f55bca9
--- /dev/null
+++ b/packages/cli/src/ui/components/Header.test.tsx
@@ -0,0 +1,155 @@
+/**
+ * @license
+ * Copyright 2025 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 { Header } from './Header.js';
+import * as useTerminalSize from '../hooks/useTerminalSize.js';
+import { longAsciiLogo } from './AsciiArt.js';
+import * as semanticColors from '../semantic-colors.js';
+import { Text } from 'ink';
+import type React from 'react';
+
+vi.mock('../hooks/useTerminalSize.js');
+vi.mock('../hooks/useSnowfall.js', () => ({
+ useSnowfall: vi.fn((art) => art),
+}));
+vi.mock('ink-gradient', () => {
+ const MockGradient = ({ children }: { children: React.ReactNode }) => (
+ <>{children}>
+ );
+ return {
+ default: vi.fn(MockGradient),
+ };
+});
+vi.mock('../semantic-colors.js');
+vi.mock('ink', async () => {
+ const originalInk = await vi.importActual('ink');
+ return {
+ ...originalInk,
+ Text: vi.fn(originalInk.Text),
+ };
+});
+
+describe('', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders the long logo on a wide terminal', async () => {
+ vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
+ columns: 120,
+ rows: 20,
+ });
+ await render();
+ expect(Text).toHaveBeenCalledWith(
+ expect.objectContaining({
+ children: longAsciiLogo,
+ }),
+ undefined,
+ );
+ });
+
+ it('renders custom ASCII art when provided', async () => {
+ const customArt = 'CUSTOM ART';
+ await render(
+ ,
+ );
+ expect(Text).toHaveBeenCalledWith(
+ expect.objectContaining({
+ children: customArt,
+ }),
+ undefined,
+ );
+ });
+
+ it('displays the version number when nightly is true', async () => {
+ await render();
+ const textCalls = (Text as Mock).mock.calls;
+ const versionText = Array.isArray(textCalls[1][0].children)
+ ? textCalls[1][0].children.join('')
+ : textCalls[1][0].children;
+ expect(versionText).toBe('v1.0.0');
+ });
+
+ it('does not display the version number when nightly is false', async () => {
+ await render();
+ expect(Text).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ children: 'v1.0.0',
+ }),
+ undefined,
+ );
+ });
+
+ it('renders with no gradient when theme.ui.gradient is undefined', async () => {
+ vi.spyOn(semanticColors, 'theme', 'get').mockReturnValue({
+ text: {
+ primary: '',
+ secondary: '',
+ link: '',
+ accent: '#123456',
+ response: '',
+ },
+ background: {
+ primary: '',
+ message: '',
+ input: '',
+ focus: '',
+ diff: { added: '', removed: '' },
+ },
+ border: {
+ default: '',
+ },
+ ui: {
+ comment: '',
+ symbol: '',
+ active: '',
+ dark: '',
+ focus: '',
+ gradient: undefined,
+ },
+ status: {
+ error: '',
+ success: '',
+ warning: '',
+ },
+ });
+ const Gradient = await import('ink-gradient');
+ await render();
+ expect(Gradient.default).not.toHaveBeenCalled();
+ const textCalls = (Text as Mock).mock.calls;
+ expect(textCalls[0][0]).toHaveProperty('color', '#123456');
+ });
+
+ it('renders with a single color when theme.ui.gradient has one color', async () => {
+ const singleColor = '#FF0000';
+ vi.spyOn(semanticColors, 'theme', 'get').mockReturnValue({
+ ui: { gradient: [singleColor] },
+ } as typeof semanticColors.theme);
+ const Gradient = await import('ink-gradient');
+ await render();
+ expect(Gradient.default).not.toHaveBeenCalled();
+ const textCalls = (Text as Mock).mock.calls;
+ expect(textCalls.length).toBe(1);
+ expect(textCalls[0][0]).toHaveProperty('color', singleColor);
+ });
+
+ it('renders with a gradient when theme.ui.gradient has two or more colors', async () => {
+ const gradientColors = ['#FF0000', '#00FF00'];
+ vi.spyOn(semanticColors, 'theme', 'get').mockReturnValue({
+ ui: { gradient: gradientColors },
+ } as typeof semanticColors.theme);
+ const Gradient = await import('ink-gradient');
+ await render();
+ expect(Gradient.default).toHaveBeenCalledWith(
+ expect.objectContaining({
+ colors: gradientColors,
+ }),
+ undefined,
+ );
+ });
+});
diff --git a/packages/cli/src/ui/components/Header.tsx b/packages/cli/src/ui/components/Header.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2bf260148e5a3cb9da60b6242d71c7affc02ffd5
--- /dev/null
+++ b/packages/cli/src/ui/components/Header.tsx
@@ -0,0 +1,59 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Box } from 'ink';
+import { ThemedGradient } from './ThemedGradient.js';
+import { shortAsciiLogo, longAsciiLogo, tinyAsciiLogo } from './AsciiArt.js';
+import { getAsciiArtWidth } from '../utils/textUtils.js';
+import { useTerminalSize } from '../hooks/useTerminalSize.js';
+import { useSnowfall } from '../hooks/useSnowfall.js';
+
+interface HeaderProps {
+ customAsciiArt?: string; // For user-defined ASCII art
+ version: string;
+ nightly: boolean;
+}
+
+export const Header: React.FC = ({
+ customAsciiArt,
+ version,
+ nightly,
+}) => {
+ const { columns: terminalWidth } = useTerminalSize();
+ let displayTitle;
+ const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
+ const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo);
+
+ if (customAsciiArt) {
+ displayTitle = customAsciiArt;
+ } else if (terminalWidth >= widthOfLongLogo) {
+ displayTitle = longAsciiLogo;
+ } else if (terminalWidth >= widthOfShortLogo) {
+ displayTitle = shortAsciiLogo;
+ } else {
+ displayTitle = tinyAsciiLogo;
+ }
+
+ const artWidth = getAsciiArtWidth(displayTitle);
+ const title = useSnowfall(displayTitle);
+
+ return (
+
+ {title}
+ {nightly && (
+
+ v{version}
+
+ )}
+
+ );
+};
diff --git a/packages/cli/src/ui/components/Help.test.tsx b/packages/cli/src/ui/components/Help.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..058fb0db5527b2089c0ccfb973a4d6e28b04b282
--- /dev/null
+++ b/packages/cli/src/ui/components/Help.test.tsx
@@ -0,0 +1,79 @@
+/**
+ * @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 { Help } from './Help.js';
+import { CommandKind, type SlashCommand } from '../commands/types.js';
+
+const mockCommands: readonly SlashCommand[] = [
+ {
+ name: 'test',
+ description: 'A test command',
+ kind: CommandKind.BUILT_IN,
+ },
+ {
+ name: 'hidden',
+ description: 'A hidden command',
+ hidden: true,
+ kind: CommandKind.BUILT_IN,
+ },
+ {
+ name: 'parent',
+ description: 'A parent command',
+ kind: CommandKind.BUILT_IN,
+ subCommands: [
+ {
+ name: 'visible-child',
+ description: 'A visible child command',
+ kind: CommandKind.BUILT_IN,
+ },
+ {
+ name: 'hidden-child',
+ description: 'A hidden child command',
+ hidden: true,
+ kind: CommandKind.BUILT_IN,
+ },
+ ],
+ },
+];
+
+describe('Help Component', () => {
+ it('should not render hidden commands', async () => {
+ const { lastFrame, unmount } = await render(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toContain('/test');
+ expect(output).not.toContain('/hidden');
+ unmount();
+ });
+
+ it('should not render hidden subcommands', async () => {
+ const { lastFrame, unmount } = await render(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toContain('visible-child');
+ expect(output).not.toContain('hidden-child');
+ unmount();
+ });
+
+ it('should render keyboard shortcuts', async () => {
+ const { lastFrame, unmount } = await render(
+ ,
+ );
+ const output = lastFrame();
+
+ expect(output).toContain('Keyboard Shortcuts:');
+ expect(output).toContain('Ctrl+C');
+ expect(output).toContain('Shift+Tab');
+ expect(output).toContain('Page Up/Page Down');
+ unmount();
+ });
+});
diff --git a/packages/cli/src/ui/components/Help.tsx b/packages/cli/src/ui/components/Help.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2569623c8013dd60b5ca1372ee9cacb7099727cf
--- /dev/null
+++ b/packages/cli/src/ui/components/Help.tsx
@@ -0,0 +1,201 @@
+/**
+ * @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 { type SlashCommand, CommandKind } from '../commands/types.js';
+import { KEYBOARD_SHORTCUTS_URL } from '../constants.js';
+import { sanitizeForDisplay } from '../utils/textUtils.js';
+import { formatCommand } from '../key/keybindingUtils.js';
+import { Command } from '../key/keyBindings.js';
+
+interface Help {
+ commands: readonly SlashCommand[];
+}
+
+export const Help: React.FC = ({ commands }) => (
+
+ {/* Basics */}
+
+ Basics:
+
+
+
+ Add context
+
+ : Use{' '}
+
+ @
+ {' '}
+ to specify files for context (e.g.,{' '}
+
+ @src/myFile.ts
+
+ ) to target specific files or folders.
+
+
+
+ Shell mode
+
+ : Execute shell commands via{' '}
+
+ !
+ {' '}
+ (e.g.,{' '}
+
+ !npm run start
+
+ ) or use natural language (e.g.{' '}
+
+ start server
+
+ ).
+
+
+
+
+ {/* Commands */}
+
+ Commands:
+
+ {commands
+ .filter((command) => command.description && !command.hidden)
+ .map((command: SlashCommand) => (
+
+
+
+ {' '}
+ /{command.name}
+
+ {command.kind === CommandKind.MCP_PROMPT && (
+ [MCP]
+ )}
+ {command.description &&
+ ' - ' + sanitizeForDisplay(command.description, 100)}
+
+ {command.subCommands &&
+ command.subCommands
+ .filter((subCommand) => !subCommand.hidden)
+ .map((subCommand) => (
+
+
+ {' '}
+ {subCommand.name}
+
+ {subCommand.description &&
+ ' - ' + sanitizeForDisplay(subCommand.description, 100)}
+
+ ))}
+
+ ))}
+
+
+ {' '}
+ !{' '}
+
+ - shell command
+
+
+ [MCP] - Model Context Protocol
+ command (from external servers)
+
+
+
+
+ {/* Shortcuts */}
+
+ Keyboard Shortcuts:
+
+
+
+ {formatCommand(Command.MOVE_WORD_LEFT)}/
+ {formatCommand(Command.MOVE_WORD_RIGHT)}
+ {' '}
+ - Jump through words in the input
+
+
+
+ {formatCommand(Command.QUIT)}
+ {' '}
+ - Quit application
+
+
+
+ {formatCommand(Command.NEWLINE)}
+ {' '}
+ - New line
+
+
+
+ {formatCommand(Command.CLEAR_SCREEN)}
+ {' '}
+ - Clear the screen
+
+
+
+ {formatCommand(Command.TOGGLE_COPY_MODE)}
+ {' '}
+ - Enter selection mode to copy text
+
+
+
+ {formatCommand(Command.OPEN_EXTERNAL_EDITOR)}
+ {' '}
+ - Open input in external editor
+
+
+
+ {formatCommand(Command.TOGGLE_YOLO)}
+ {' '}
+ - Toggle YOLO mode
+
+
+
+ {formatCommand(Command.SUBMIT)}
+ {' '}
+ - Send message
+
+
+
+ {formatCommand(Command.ESCAPE)}
+ {' '}
+ - Cancel operation / Clear input (double press)
+
+
+
+ {formatCommand(Command.PAGE_UP)}/{formatCommand(Command.PAGE_DOWN)}
+ {' '}
+ - Scroll page up/down
+
+
+
+ {formatCommand(Command.CYCLE_APPROVAL_MODE)}
+ {' '}
+ - Toggle auto-accepting edits
+
+
+
+ {formatCommand(Command.HISTORY_UP)}/
+ {formatCommand(Command.HISTORY_DOWN)}
+ {' '}
+ - Cycle through your prompt history
+
+
+
+ For a full list of shortcuts, see{' '}
+
+ {KEYBOARD_SHORTCUTS_URL}
+
+
+
+);
diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..aeebf478f34c4894f7a95ca8734766b9c4078cd3
--- /dev/null
+++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
@@ -0,0 +1,436 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as path from 'node:path';
+import { describe, it, expect, vi } from 'vitest';
+import { HistoryItemDisplay } from './HistoryItemDisplay.js';
+import { MessageType, type HistoryItem } from '../types.js';
+import { SessionStatsProvider } from '../contexts/SessionContext.js';
+import {
+ CoreToolCallStatus,
+ type Config,
+ type ToolExecuteConfirmationDetails,
+} from '@google/gemini-cli-core';
+import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
+import { renderWithProviders } from '../../test-utils/render.js';
+import { createMockSettings } from '../../test-utils/settings.js';
+import { makeFakeConfig } from '@google/gemini-cli-core';
+
+// Mock child components
+vi.mock('./messages/ToolGroupMessage.js', () => ({
+ ToolGroupMessage: vi.fn(() => ),
+}));
+
+describe('', () => {
+ const mockConfig = {} as unknown as Config;
+ const baseItem = {
+ id: 1,
+ timestamp: 12345,
+ isPending: false,
+ terminalWidth: 80,
+ config: mockConfig,
+ };
+
+ it('renders UserMessage for "user" type', async () => {
+ const item: HistoryItem = {
+ ...baseItem,
+ type: MessageType.USER,
+ text: 'Hello',
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ expect(lastFrame()).toContain('Hello');
+ unmount();
+ });
+
+ it('renders HintMessage for "hint" type', async () => {
+ const item: HistoryItem = {
+ ...baseItem,
+ type: 'hint',
+ text: 'Try using ripgrep first',
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ expect(lastFrame()).toContain('Try using ripgrep first');
+ unmount();
+ });
+
+ it('renders UserMessage for "user" type with slash command', async () => {
+ const item: HistoryItem = {
+ ...baseItem,
+ type: MessageType.USER,
+ text: '/theme',
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ expect(lastFrame()).toContain('/theme');
+ unmount();
+ });
+
+ it.each([true, false])(
+ 'renders InfoMessage for "info" type with multi-line text (alternateBuffer=%s)',
+ async (useAlternateBuffer) => {
+ const item: HistoryItem = {
+ ...baseItem,
+ type: MessageType.INFO,
+ text: '⚡ Line 1\n⚡ Line 2\n⚡ Line 3',
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ config: makeFakeConfig({ useAlternateBuffer }),
+ settings: createMockSettings({ ui: { useAlternateBuffer } }),
+ },
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ },
+ );
+
+ it('renders AgentsStatus for "agents_list" type', async () => {
+ const item: HistoryItem = {
+ ...baseItem,
+ type: MessageType.AGENTS_LIST,
+ agents: [
+ {
+ name: 'local_agent',
+ displayName: 'Local Agent',
+ description: ' Local agent description.\n Second line.',
+ kind: 'local',
+ },
+ {
+ name: 'remote_agent',
+ description: 'Remote agent description.',
+ kind: 'remote',
+ },
+ ],
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
+ it('renders StatsDisplay for "stats" type', async () => {
+ const item: HistoryItem = {
+ ...baseItem,
+ type: MessageType.STATS,
+ duration: '1s',
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+
+
+ ,
+ );
+ expect(lastFrame()).toContain('Stats');
+ unmount();
+ });
+
+ it('renders AboutBox for "about" type', async () => {
+ const item: HistoryItem = {
+ ...baseItem,
+ type: MessageType.ABOUT,
+ cliVersion: '1.0.0',
+ osVersion: 'test-os',
+ sandboxEnv: 'test-env',
+ modelVersion: 'test-model',
+ selectedAuthType: 'test-auth',
+ gcpProject: 'test-project',
+ ideClient: 'test-ide',
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ expect(lastFrame()).toContain('About Gemini CLI');
+ unmount();
+ });
+
+ it('renders ModelStatsDisplay for "model_stats" type', async () => {
+ const item: HistoryItem = {
+ ...baseItem,
+ type: 'model_stats',
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+
+
+ ,
+ );
+ expect(lastFrame()).toContain(
+ 'No API calls have been made in this session.',
+ );
+ unmount();
+ });
+
+ it('renders ToolStatsDisplay for "tool_stats" type', async () => {
+ const item: HistoryItem = {
+ ...baseItem,
+ type: 'tool_stats',
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+
+
+ ,
+ );
+ expect(lastFrame()).toContain(
+ 'No tool calls have been made in this session.',
+ );
+ unmount();
+ });
+
+ it('renders SessionSummaryDisplay for "quit" type', async () => {
+ const item: HistoryItem = {
+ ...baseItem,
+ type: 'quit',
+ duration: '1s',
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+
+
+ ,
+ );
+ expect(lastFrame()).toContain('Agent powering down. Goodbye!');
+ unmount();
+ });
+
+ it('renders ExportSessionMessage for "export_session" type', async () => {
+ const testPath = path.join(path.sep, 'test', 'path.json');
+ const item: HistoryItem = {
+ ...baseItem,
+ type: 'export_session',
+ exportSession: {
+ isPending: false,
+ targetPath: testPath,
+ },
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ expect(lastFrame()).toContain(
+ `Successfully exported session to ${testPath}`,
+ );
+ unmount();
+ });
+
+ it('should escape ANSI codes in text content', async () => {
+ const historyItem: HistoryItem = {
+ id: 1,
+ type: 'user',
+ text: 'Hello, \u001b[31mred\u001b[0m world!',
+ };
+
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+
+ // The ANSI codes should be escaped for display.
+ expect(lastFrame()).toContain('Hello, \\u001b[31mred\\u001b[0m world!');
+ // The raw ANSI codes should not be present.
+ expect(lastFrame()).not.toContain('Hello, \u001b[31mred\u001b[0m world!');
+ unmount();
+ });
+
+ it('should escape ANSI codes in tool confirmation details', async () => {
+ const historyItem: HistoryItem = {
+ id: 1,
+ type: 'tool_group',
+ tools: [
+ {
+ callId: '123',
+ name: 'run_shell_command',
+ description: 'Run a shell command',
+ resultDisplay: 'blank',
+ status: CoreToolCallStatus.AwaitingApproval,
+ confirmationDetails: {
+ type: 'exec',
+ title: 'Run Shell Command',
+ command: 'echo "\u001b[31mhello\u001b[0m"',
+ rootCommand: 'echo',
+ rootCommands: ['echo'],
+ },
+ },
+ ],
+ };
+
+ const { unmount } = await renderWithProviders(
+ ,
+ );
+
+ const passedProps = vi.mocked(ToolGroupMessage).mock.calls[0][0];
+ const confirmationDetails = passedProps.toolCalls[0]
+ .confirmationDetails as ToolExecuteConfirmationDetails;
+
+ expect(confirmationDetails.command).toBe(
+ 'echo "\\u001b[31mhello\\u001b[0m"',
+ );
+ unmount();
+ });
+
+ describe('thinking items', () => {
+ it('renders thinking item when enabled', async () => {
+ const item: HistoryItem = {
+ ...baseItem,
+ type: 'thinking',
+ thought: { subject: 'Thinking', description: 'test' },
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ settings: createMockSettings({ ui: { inlineThinkingMode: 'full' } }),
+ },
+ );
+
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
+ it('renders "Thinking..." header when isFirstThinking is true', async () => {
+ const item: HistoryItem = {
+ ...baseItem,
+ type: 'thinking',
+ thought: { subject: 'Thinking', description: 'test' },
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ settings: createMockSettings({ ui: { inlineThinkingMode: 'full' } }),
+ },
+ );
+
+ expect(lastFrame()).toContain(' Thinking...');
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+ it('does not render thinking item when disabled', async () => {
+ const item: HistoryItem = {
+ ...baseItem,
+ type: 'thinking',
+ thought: { subject: 'Thinking', description: 'test' },
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ settings: createMockSettings({ ui: { inlineThinkingMode: 'off' } }),
+ },
+ );
+
+ expect(lastFrame({ allowEmpty: true })).toBe('');
+ unmount();
+ });
+ });
+
+ describe.each([true, false])(
+ 'gemini items (alternateBuffer=%s)',
+ (useAlternateBuffer) => {
+ const longCode =
+ '# Example code block:\n' +
+ '```python\n' +
+ Array.from({ length: 50 }, (_, i) => `Line ${i + 1}`).join('\n') +
+ '\n```';
+
+ it('should render a truncated gemini item', async () => {
+ const item: HistoryItem = {
+ id: 1,
+ type: 'gemini',
+ text: longCode,
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ config: makeFakeConfig({ useAlternateBuffer }),
+ settings: createMockSettings({ ui: { useAlternateBuffer } }),
+ },
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
+ it('should render a full gemini item when using availableTerminalHeightGemini', async () => {
+ const item: HistoryItem = {
+ id: 1,
+ type: 'gemini',
+ text: longCode,
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ config: makeFakeConfig({ useAlternateBuffer }),
+ settings: createMockSettings({ ui: { useAlternateBuffer } }),
+ },
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
+ it('should render a truncated gemini_content item', async () => {
+ const item: HistoryItem = {
+ id: 1,
+ type: 'gemini_content',
+ text: longCode,
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ config: makeFakeConfig({ useAlternateBuffer }),
+ settings: createMockSettings({ ui: { useAlternateBuffer } }),
+ },
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
+ it('should render a full gemini_content item when using availableTerminalHeightGemini', async () => {
+ const item: HistoryItem = {
+ id: 1,
+ type: 'gemini_content',
+ text: longCode,
+ };
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ {
+ config: makeFakeConfig({ useAlternateBuffer }),
+ settings: createMockSettings({ ui: { useAlternateBuffer } }),
+ },
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+ },
+ );
+});
diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0594f2ed3a18e6ec26f36d59a5ce353edff43835
--- /dev/null
+++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx
@@ -0,0 +1,251 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { useMemo } from 'react';
+import { escapeAnsiCtrlCodes } from '../utils/textUtils.js';
+import type { HistoryItem } from '../types.js';
+import { UserMessage } from './messages/UserMessage.js';
+import { UserShellMessage } from './messages/UserShellMessage.js';
+import { GeminiMessage } from './messages/GeminiMessage.js';
+import { InfoMessage } from './messages/InfoMessage.js';
+import { ErrorMessage } from './messages/ErrorMessage.js';
+import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
+import { ToolGroupDisplay } from './messages/ToolGroupDisplay.js';
+import { GeminiMessageContent } from './messages/GeminiMessageContent.js';
+import { CompressionMessage } from './messages/CompressionMessage.js';
+import { ExportSessionMessage } from './messages/ExportSessionMessage.js';
+import { WarningMessage } from './messages/WarningMessage.js';
+import { SubagentHistoryMessage } from './messages/SubagentHistoryMessage.js';
+import { Box } from 'ink';
+import { AboutBox } from './AboutBox.js';
+import { StatsDisplay } from './StatsDisplay.js';
+import { ModelStatsDisplay } from './ModelStatsDisplay.js';
+import { ToolStatsDisplay } from './ToolStatsDisplay.js';
+import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
+import { Help } from './Help.js';
+import type { SlashCommand } from '../commands/types.js';
+import { ExtensionsList } from './views/ExtensionsList.js';
+import { getMCPServerStatus } from '@google/gemini-cli-core';
+import { ToolsList } from './views/ToolsList.js';
+import { SkillsList } from './views/SkillsList.js';
+import { AgentsStatus } from './views/AgentsStatus.js';
+import { McpStatus } from './views/McpStatus.js';
+import { GemmaStatus } from './views/GemmaStatus.js';
+import { ChatList } from './views/ChatList.js';
+import { ModelMessage } from './messages/ModelMessage.js';
+import { ThinkingMessage } from './messages/ThinkingMessage.js';
+import { HintMessage } from './messages/HintMessage.js';
+import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
+import { useSettings } from '../contexts/SettingsContext.js';
+
+interface HistoryItemDisplayProps {
+ item: HistoryItem;
+ availableTerminalHeight?: number;
+ terminalWidth: number;
+ isPending: boolean;
+ commands?: readonly SlashCommand[];
+ availableTerminalHeightGemini?: number;
+ isExpandable?: boolean;
+ isFirstThinking?: boolean;
+ isFirstAfterThinking?: boolean;
+ isToolGroupBoundary?: boolean;
+}
+
+export const HistoryItemDisplay: React.FC = ({
+ item,
+ availableTerminalHeight,
+ terminalWidth,
+ isPending,
+ commands,
+ availableTerminalHeightGemini,
+ isExpandable,
+ isFirstThinking = false,
+ isFirstAfterThinking = false,
+ isToolGroupBoundary = false,
+}) => {
+ const settings = useSettings();
+ const inlineThinkingMode = getInlineThinkingMode(settings);
+ const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
+
+ const needTopMargin = !!(
+ (isFirstAfterThinking && inlineThinkingMode !== 'off') ||
+ isToolGroupBoundary
+ );
+
+ return (
+
+ {/* Render standard message types */}
+ {itemForDisplay.type === 'thinking' && inlineThinkingMode !== 'off' && (
+
+ )}
+ {itemForDisplay.type === 'hint' && (
+
+ )}
+ {itemForDisplay.type === 'user' && (
+
+ )}
+ {itemForDisplay.type === 'user_shell' && (
+
+ )}
+ {itemForDisplay.type === 'gemini' && (
+
+ )}
+ {itemForDisplay.type === 'gemini_content' && (
+
+ )}
+ {itemForDisplay.type === 'info' && (
+
+ )}
+ {itemForDisplay.type === 'warning' && (
+
+ )}
+ {itemForDisplay.type === 'error' && (
+
+ )}
+ {itemForDisplay.type === 'about' && (
+
+ )}
+ {itemForDisplay.type === 'help' && commands && (
+
+ )}
+ {itemForDisplay.type === 'stats' && (
+
+ )}
+ {itemForDisplay.type === 'model_stats' && (
+
+ )}
+ {itemForDisplay.type === 'tool_stats' && }
+ {itemForDisplay.type === 'model' && (
+
+ )}
+ {itemForDisplay.type === 'quit' && (
+
+ )}
+ {itemForDisplay.type === 'tool_group' && (
+
+ )}
+ {itemForDisplay.type === 'tool_display_group' && (
+
+ )}
+ {itemForDisplay.type === 'subagent' && (
+
+ )}
+ {itemForDisplay.type === 'compression' && (
+
+ )}
+ {itemForDisplay.type === 'export_session' && (
+
+ )}
+ {itemForDisplay.type === 'extensions_list' && (
+
+ )}
+ {itemForDisplay.type === 'tools_list' && (
+
+ )}
+ {itemForDisplay.type === 'skills_list' && (
+
+ )}
+ {itemForDisplay.type === 'agents_list' && (
+
+ )}
+ {itemForDisplay.type === 'mcp_status' && (
+
+ )}
+ {itemForDisplay.type === 'gemma_status' && (
+
+ )}
+ {itemForDisplay.type === 'chat_list' && (
+
+ )}
+
+ );
+};
diff --git a/packages/cli/src/ui/components/HookStatusDisplay.test.tsx b/packages/cli/src/ui/components/HookStatusDisplay.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9603e6b31ac012dea640ebe85b0b18bcc8076542
--- /dev/null
+++ b/packages/cli/src/ui/components/HookStatusDisplay.test.tsx
@@ -0,0 +1,93 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { HookStatusDisplay } from './HookStatusDisplay.js';
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+});
+
+describe('', () => {
+ it('should render a single executing hook', async () => {
+ const props = {
+ activeHooks: [{ name: 'test-hook', eventName: 'BeforeAgent' }],
+ };
+ const { lastFrame, unmount, waitUntilReady } = await render(
+ ,
+ );
+ await waitUntilReady();
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
+ it('should render multiple executing hooks', async () => {
+ const props = {
+ activeHooks: [
+ { name: 'h1', eventName: 'BeforeAgent' },
+ { name: 'h2', eventName: 'BeforeAgent' },
+ ],
+ };
+ const { lastFrame, unmount, waitUntilReady } = await render(
+ ,
+ );
+ await waitUntilReady();
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
+ it('should render sequential hook progress', async () => {
+ const props = {
+ activeHooks: [
+ { name: 'step', eventName: 'BeforeAgent', index: 1, total: 3 },
+ ],
+ };
+ const { lastFrame, unmount, waitUntilReady } = await render(
+ ,
+ );
+ await waitUntilReady();
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
+ it('should return empty string if no active hooks', async () => {
+ const props = { activeHooks: [] };
+ const { lastFrame, unmount, waitUntilReady } = await render(
+ ,
+ );
+ await waitUntilReady();
+ expect(lastFrame({ allowEmpty: true })).toBe('');
+ unmount();
+ });
+
+ it('should show generic message when only system hooks are active', async () => {
+ const props = {
+ activeHooks: [
+ { name: 'sys-hook', eventName: 'BeforeAgent', source: 'system' },
+ ],
+ };
+ const { lastFrame, unmount, waitUntilReady } = await render(
+ ,
+ );
+ await waitUntilReady();
+ expect(lastFrame()).toContain('Working...');
+ unmount();
+ });
+
+ it('matches SVG snapshot for single hook', async () => {
+ const props = {
+ activeHooks: [
+ { name: 'test-hook', eventName: 'BeforeAgent', source: 'user' },
+ ],
+ };
+ const result = await render();
+ await result.waitUntilReady();
+ await expect(result).toMatchSvgSnapshot();
+ result.unmount();
+ });
+});
diff --git a/packages/cli/src/ui/components/HookStatusDisplay.tsx b/packages/cli/src/ui/components/HookStatusDisplay.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a4551937064a123e3391bcb0a17cc6d341610365
--- /dev/null
+++ b/packages/cli/src/ui/components/HookStatusDisplay.tsx
@@ -0,0 +1,51 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Text } from 'ink';
+import { type ActiveHook } from '../types.js';
+import { isUserVisibleHook } from '@google/gemini-cli-core';
+import { GENERIC_WORKING_LABEL } from '../textConstants.js';
+import { theme } from '../semantic-colors.js';
+
+interface HookStatusDisplayProps {
+ activeHooks: ActiveHook[];
+}
+
+export const HookStatusDisplay: React.FC = ({
+ activeHooks,
+}) => {
+ if (activeHooks.length === 0) {
+ return null;
+ }
+
+ const userHooks = activeHooks.filter((h) => isUserVisibleHook(h.source));
+
+ if (userHooks.length > 0) {
+ const label = userHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
+ const displayNames = userHooks.map((hook) => {
+ let name = hook.name;
+ if (hook.index && hook.total && hook.total > 1) {
+ name += ` (${hook.index}/${hook.total})`;
+ }
+ return name;
+ });
+
+ const text = `${label}: ${displayNames.join(', ')}`;
+ return (
+
+ {text}
+
+ );
+ }
+
+ // If only system/extension hooks are running, show a generic message.
+ return (
+
+ {GENERIC_WORKING_LABEL}
+
+ );
+};
diff --git a/packages/cli/src/ui/components/HooksDialog.test.tsx b/packages/cli/src/ui/components/HooksDialog.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..94b221892f5e0f9aea69a61a3c28992e46886bd8
--- /dev/null
+++ b/packages/cli/src/ui/components/HooksDialog.test.tsx
@@ -0,0 +1,240 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { renderWithProviders } from '../../test-utils/render.js';
+import { act } from 'react';
+import { vi, describe, it, expect, beforeEach } from 'vitest';
+import { HooksDialog, type HookEntry } from './HooksDialog.js';
+
+describe('HooksDialog', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ const createMockHook = (
+ name: string,
+ eventName: string,
+ enabled: boolean,
+ options?: Partial,
+ ): HookEntry => ({
+ config: {
+ name,
+ command: `run-${name}`,
+ type: 'command',
+ description: `Test hook: ${name}`,
+ ...options?.config,
+ },
+ source: options?.source ?? '/mock/path/GEMINI.md',
+ eventName,
+ enabled,
+ ...options,
+ });
+
+ describe('snapshots', () => {
+ it('renders empty hooks dialog', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
+ it('renders single hook with security warning, source, and tips', async () => {
+ const hooks = [createMockHook('test-hook', 'before-tool', true)];
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
+ it('renders hooks grouped by event name with enabled and disabled status', async () => {
+ const hooks = [
+ createMockHook('hook1', 'before-tool', true),
+ createMockHook('hook2', 'before-tool', false),
+ createMockHook('hook3', 'after-agent', true),
+ ];
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
+ it('renders hook with all metadata (matcher, sequential, timeout)', async () => {
+ const hooks = [
+ createMockHook('my-hook', 'before-tool', true, {
+ matcher: 'shell_exec',
+ sequential: true,
+ config: {
+ name: 'my-hook',
+ type: 'command',
+ description: 'A hook with all metadata fields',
+ timeout: 30,
+ },
+ }),
+ ];
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
+ it('renders hook using command as name when name is not provided', async () => {
+ const hooks: HookEntry[] = [
+ {
+ config: {
+ command: 'echo hello',
+ type: 'command',
+ },
+ source: '/mock/path',
+ eventName: 'before-tool',
+ enabled: true,
+ },
+ ];
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+ });
+
+ describe('keyboard interaction', () => {
+ it('should call onClose when escape key is pressed', async () => {
+ const onClose = vi.fn();
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ act(() => {
+ stdin.write('\u001b[27u');
+ });
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ unmount();
+ });
+ });
+
+ describe('scrolling behavior', () => {
+ const createManyHooks = (count: number): HookEntry[] =>
+ Array.from({ length: count }, (_, i) =>
+ createMockHook(`hook-${i + 1}`, `event-${(i % 3) + 1}`, i % 2 === 0),
+ );
+
+ it('should not show scroll indicators when hooks fit within maxVisibleHooks', async () => {
+ const hooks = [
+ createMockHook('hook1', 'before-tool', true),
+ createMockHook('hook2', 'after-tool', false),
+ ];
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+
+ expect(lastFrame()).not.toContain('▲');
+ expect(lastFrame()).not.toContain('▼');
+ unmount();
+ });
+
+ it('should show scroll down indicator when there are more hooks than maxVisibleHooks', async () => {
+ const hooks = createManyHooks(15);
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+
+ expect(lastFrame()).toContain('▼');
+ unmount();
+ });
+
+ it('should scroll down when down arrow is pressed', async () => {
+ const hooks = createManyHooks(15);
+ const { lastFrame, waitUntilReady, stdin, unmount } =
+ await renderWithProviders(
+ ,
+ );
+
+ // Initially should not show up indicator
+ expect(lastFrame()).not.toContain('▲');
+
+ act(() => {
+ stdin.write('\u001b[B');
+ });
+ await waitUntilReady();
+
+ // Should now show up indicator after scrolling down
+ expect(lastFrame()).toContain('▲');
+ unmount();
+ });
+
+ it('should scroll up when up arrow is pressed after scrolling down', async () => {
+ const hooks = createManyHooks(15);
+ const { lastFrame, waitUntilReady, stdin, unmount } =
+ await renderWithProviders(
+ ,
+ );
+
+ // Scroll down twice
+ act(() => {
+ stdin.write('\u001b[B');
+ stdin.write('\u001b[B');
+ });
+ await waitUntilReady();
+
+ expect(lastFrame()).toContain('▲');
+
+ // Scroll up once
+ act(() => {
+ stdin.write('\u001b[A');
+ });
+ await waitUntilReady();
+
+ // Should still show up indicator (scrolled down once)
+ expect(lastFrame()).toContain('▲');
+ unmount();
+ });
+
+ it('should not scroll beyond the end', async () => {
+ const hooks = createManyHooks(10);
+ const { lastFrame, waitUntilReady, stdin, unmount } =
+ await renderWithProviders(
+ ,
+ );
+
+ // Scroll down many times past the end
+ act(() => {
+ for (let i = 0; i < 20; i++) {
+ stdin.write('\u001b[B');
+ }
+ });
+ await waitUntilReady();
+
+ const frame = lastFrame();
+ expect(frame).toContain('▲');
+ // At the end, down indicator should be hidden
+ expect(frame).not.toContain('▼');
+ unmount();
+ });
+
+ it('should not scroll above the beginning', async () => {
+ const hooks = createManyHooks(10);
+ const { lastFrame, waitUntilReady, stdin, unmount } =
+ await renderWithProviders(
+ ,
+ );
+
+ // Try to scroll up when already at top
+ act(() => {
+ stdin.write('\u001b[A');
+ });
+ await waitUntilReady();
+
+ expect(lastFrame()).not.toContain('▲');
+ expect(lastFrame()).toContain('▼');
+ unmount();
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/HooksDialog.tsx b/packages/cli/src/ui/components/HooksDialog.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6a60a10af6df725dfd9d25a8ec5f6f09f136b310
--- /dev/null
+++ b/packages/cli/src/ui/components/HooksDialog.tsx
@@ -0,0 +1,254 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { useState, useMemo } from 'react';
+import { Box, Text } from 'ink';
+import { theme } from '../semantic-colors.js';
+import { useKeypress } from '../hooks/useKeypress.js';
+import { Command } from '../key/keyMatchers.js';
+import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
+
+/**
+ * Hook entry type matching HookRegistryEntry from core
+ */
+export interface HookEntry {
+ config: {
+ command?: string;
+ type: string;
+ name?: string;
+ description?: string;
+ timeout?: number;
+ };
+ source: string;
+ eventName: string;
+ matcher?: string;
+ sequential?: boolean;
+ enabled: boolean;
+}
+
+interface HooksDialogProps {
+ hooks: readonly HookEntry[];
+ onClose: () => void;
+ /** Maximum number of hooks to display at once before scrolling. Default: 8 */
+ maxVisibleHooks?: number;
+}
+
+/** Maximum hooks to show at once before scrolling is needed */
+const DEFAULT_MAX_VISIBLE_HOOKS = 8;
+
+/**
+ * Dialog component for displaying hooks in a styled box.
+ * Replaces inline chat history display with a modal-style dialog.
+ * Supports scrolling with up/down arrow keys when there are many hooks.
+ */
+export const HooksDialog: React.FC = ({
+ hooks,
+ onClose,
+ maxVisibleHooks = DEFAULT_MAX_VISIBLE_HOOKS,
+}) => {
+ const keyMatchers = useKeyMatchers();
+ const [scrollOffset, setScrollOffset] = useState(0);
+
+ // Flatten hooks with their event names for easier scrolling
+ const flattenedHooks = useMemo(() => {
+ const result: Array<{
+ type: 'header' | 'hook';
+ eventName: string;
+ hook?: HookEntry;
+ }> = [];
+
+ // Group hooks by event name
+ const hooksByEvent = hooks.reduce(
+ (acc, hook) => {
+ if (!acc[hook.eventName]) {
+ acc[hook.eventName] = [];
+ }
+ acc[hook.eventName].push(hook);
+ return acc;
+ },
+ {} as Record,
+ );
+
+ // Flatten into displayable items
+ Object.entries(hooksByEvent).forEach(([eventName, eventHooks]) => {
+ result.push({ type: 'header', eventName });
+ eventHooks.forEach((hook) => {
+ result.push({ type: 'hook', eventName, hook });
+ });
+ });
+
+ return result;
+ }, [hooks]);
+
+ const totalItems = flattenedHooks.length;
+ const needsScrolling = totalItems > maxVisibleHooks;
+ const maxScrollOffset = Math.max(0, totalItems - maxVisibleHooks);
+
+ // Handle keyboard navigation
+ useKeypress(
+ (key) => {
+ if (keyMatchers[Command.ESCAPE](key)) {
+ onClose();
+ return true;
+ }
+
+ // Scroll navigation
+ if (needsScrolling) {
+ if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
+ setScrollOffset((prev) => Math.max(0, prev - 1));
+ return true;
+ }
+ if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
+ setScrollOffset((prev) => Math.min(maxScrollOffset, prev + 1));
+ return true;
+ }
+ }
+
+ return false;
+ },
+ { isActive: true },
+ );
+
+ // Get visible items based on scroll offset
+ const visibleItems = needsScrolling
+ ? flattenedHooks.slice(scrollOffset, scrollOffset + maxVisibleHooks)
+ : flattenedHooks;
+
+ const showScrollUp = needsScrolling && scrollOffset > 0;
+ const showScrollDown = needsScrolling && scrollOffset < maxScrollOffset;
+
+ return (
+
+ {hooks.length === 0 ? (
+ <>
+ No hooks configured.
+ >
+ ) : (
+ <>
+ {/* Security Warning */}
+
+
+ Security Warning:
+
+
+ Hooks can execute arbitrary commands on your system. Only use
+ hooks from sources you trust. Review hook scripts carefully.
+
+
+
+ {/* Learn more link */}
+
+
+ Learn more:{' '}
+
+ https://geminicli.com/docs/hooks
+
+
+
+
+ {/* Configured Hooks heading */}
+
+
+ Configured Hooks
+
+
+
+ {/* Scroll up indicator */}
+ {showScrollUp && (
+
+ ▲
+
+ )}
+
+ {/* Visible hooks */}
+
+ {visibleItems.map((item, index) => {
+ if (item.type === 'header') {
+ return (
+
+
+ {item.eventName}
+
+
+ );
+ }
+
+ const hook = item.hook!;
+ const hookName =
+ hook.config.name || hook.config.command || 'unknown';
+ const hookKey = `${item.eventName}:${hook.source}:${hook.config.name ?? ''}:${hook.config.command ?? ''}`;
+ const statusColor = hook.enabled
+ ? theme.status.success
+ : theme.text.secondary;
+ const statusText = hook.enabled ? 'enabled' : 'disabled';
+
+ return (
+
+
+
+ {hookName}
+
+ {` [${statusText}]`}
+
+
+ {hook.config.description && (
+
+ {hook.config.description}
+
+ )}
+
+ Source: {hook.source}
+ {hook.config.name &&
+ hook.config.command &&
+ ` | Command: ${hook.config.command}`}
+ {hook.matcher && ` | Matcher: ${hook.matcher}`}
+ {hook.sequential && ` | Sequential`}
+ {hook.config.timeout &&
+ ` | Timeout: ${hook.config.timeout}s`}
+
+
+
+ );
+ })}
+
+
+ {/* Scroll down indicator */}
+ {showScrollDown && (
+
+ ▼
+
+ )}
+
+ {/* Tips */}
+
+
+ Tip: Use /hooks enable {''} or{' '}
+ /hooks disable {''} to toggle
+ individual hooks. Use /hooks enable-all or{' '}
+ /hooks disable-all to toggle all hooks at once.
+
+
+ >
+ )}
+
+
+ (Press Esc to close)
+
+
+
+ );
+};
diff --git a/packages/cli/src/ui/components/IdeTrustChangeDialog.test.tsx b/packages/cli/src/ui/components/IdeTrustChangeDialog.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d02675f9f07c774bc8f56b9e7712c3979d91f9d4
--- /dev/null
+++ b/packages/cli/src/ui/components/IdeTrustChangeDialog.test.tsx
@@ -0,0 +1,111 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { vi, describe, it, expect, beforeEach } from 'vitest';
+import { act } from 'react';
+import * as processUtils from '../../utils/processUtils.js';
+import { renderWithProviders } from '../../test-utils/render.js';
+import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
+import { debugLogger } from '@google/gemini-cli-core';
+
+describe('IdeTrustChangeDialog', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders the correct message for CONNECTION_CHANGE', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+
+ const frameText = lastFrame();
+ expect(frameText).toContain(
+ 'Workspace trust has changed due to a change in the IDE connection.',
+ );
+ expect(frameText).toContain("Press 'r' to restart Gemini");
+ unmount();
+ });
+
+ it('renders the correct message for TRUST_CHANGE', async () => {
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+
+ const frameText = lastFrame();
+ expect(frameText).toContain(
+ 'Workspace trust has changed due to a change in the IDE trust.',
+ );
+ expect(frameText).toContain("Press 'r' to restart Gemini");
+ unmount();
+ });
+
+ it('renders a generic message and logs an error for NONE reason', async () => {
+ const debugLoggerWarnSpy = vi
+ .spyOn(debugLogger, 'warn')
+ .mockImplementation(() => {});
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ );
+
+ const frameText = lastFrame();
+ expect(frameText).toContain('Workspace trust has changed.');
+ expect(debugLoggerWarnSpy).toHaveBeenCalledWith(
+ 'IdeTrustChangeDialog rendered with unexpected reason "NONE"',
+ );
+ unmount();
+ });
+
+ it('calls relaunchApp when "r" is pressed', async () => {
+ const relaunchAppSpy = vi
+ .spyOn(processUtils, 'relaunchApp')
+ .mockResolvedValue(undefined);
+ const { stdin, waitUntilReady, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('r');
+ });
+ await waitUntilReady();
+
+ expect(relaunchAppSpy).toHaveBeenCalledTimes(1);
+ unmount();
+ });
+
+ it('calls relaunchApp when "R" is pressed', async () => {
+ const relaunchAppSpy = vi
+ .spyOn(processUtils, 'relaunchApp')
+ .mockResolvedValue(undefined);
+ const { stdin, waitUntilReady, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('R');
+ });
+ await waitUntilReady();
+
+ expect(relaunchAppSpy).toHaveBeenCalledTimes(1);
+ unmount();
+ });
+
+ it('does not call relaunchApp when another key is pressed', async () => {
+ const relaunchAppSpy = vi
+ .spyOn(processUtils, 'relaunchApp')
+ .mockResolvedValue(undefined);
+ const { stdin, waitUntilReady, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('a');
+ });
+ await waitUntilReady();
+
+ expect(relaunchAppSpy).not.toHaveBeenCalled();
+ unmount();
+ });
+});
diff --git a/packages/cli/src/ui/components/IdeTrustChangeDialog.tsx b/packages/cli/src/ui/components/IdeTrustChangeDialog.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..32e451a542e12526c681eae69a49b956bf105963
--- /dev/null
+++ b/packages/cli/src/ui/components/IdeTrustChangeDialog.tsx
@@ -0,0 +1,51 @@
+/**
+ * @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 { relaunchApp } from '../../utils/processUtils.js';
+import { type RestartReason } from '../hooks/useIdeTrustListener.js';
+import { debugLogger } from '@google/gemini-cli-core';
+
+interface IdeTrustChangeDialogProps {
+ reason: RestartReason;
+}
+
+export const IdeTrustChangeDialog = ({ reason }: IdeTrustChangeDialogProps) => {
+ useKeypress(
+ (key) => {
+ if (key.name === 'r' || key.name === 'R') {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ relaunchApp();
+ return true;
+ }
+ return false;
+ },
+ { isActive: true },
+ );
+
+ let message = 'Workspace trust has changed.';
+ if (reason === 'NONE') {
+ // This should not happen, but provides a fallback and a debug log.
+ debugLogger.warn(
+ 'IdeTrustChangeDialog rendered with unexpected reason "NONE"',
+ );
+ } else if (reason === 'CONNECTION_CHANGE') {
+ message =
+ 'Workspace trust has changed due to a change in the IDE connection.';
+ } else if (reason === 'TRUST_CHANGE') {
+ message = 'Workspace trust has changed due to a change in the IDE trust.';
+ }
+
+ return (
+
+
+ {message} Press 'r' to restart Gemini to apply the changes.
+
+
+ );
+};
diff --git a/packages/cli/src/ui/components/InboxDialog.test.tsx b/packages/cli/src/ui/components/InboxDialog.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..969b7e9ff48f5e4ffe57122c975a9f62ffbd04d5
--- /dev/null
+++ b/packages/cli/src/ui/components/InboxDialog.test.tsx
@@ -0,0 +1,1171 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { act } from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type {
+ Config,
+ InboxSkill,
+ InboxPatch,
+ InboxMemoryPatch,
+} from '@google/gemini-cli-core';
+import {
+ dismissInboxSkill,
+ dismissInboxMemoryPatch,
+ listInboxSkills,
+ listInboxPatches,
+ listInboxMemoryPatches,
+ moveInboxSkill,
+ applyInboxPatch,
+ dismissInboxPatch,
+ applyInboxMemoryPatch,
+ isProjectSkillPatchTarget,
+} from '@google/gemini-cli-core';
+import { waitFor } from '../../test-utils/async.js';
+import { renderWithProviders } from '../../test-utils/render.js';
+import { createMockSettings } from '../../test-utils/settings.js';
+import { InboxDialog } from './InboxDialog.js';
+
+const altBufferSettings = createMockSettings({
+ ui: { useAlternateBuffer: true },
+});
+
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const original =
+ await importOriginal();
+
+ return {
+ ...original,
+ dismissInboxSkill: vi.fn(),
+ dismissInboxMemoryPatch: vi.fn(),
+ listInboxSkills: vi.fn(),
+ listInboxPatches: vi.fn(),
+ listInboxMemoryPatches: vi.fn(),
+ moveInboxSkill: vi.fn(),
+ applyInboxPatch: vi.fn(),
+ dismissInboxPatch: vi.fn(),
+ applyInboxMemoryPatch: vi.fn(),
+ isProjectSkillPatchTarget: vi.fn(),
+ getErrorMessage: vi.fn((error: unknown) =>
+ error instanceof Error ? error.message : String(error),
+ ),
+ };
+});
+
+const mockListInboxSkills = vi.mocked(listInboxSkills);
+const mockListInboxPatches = vi.mocked(listInboxPatches);
+const mockListInboxMemoryPatches = vi.mocked(listInboxMemoryPatches);
+const mockMoveInboxSkill = vi.mocked(moveInboxSkill);
+const mockDismissInboxSkill = vi.mocked(dismissInboxSkill);
+const mockApplyInboxPatch = vi.mocked(applyInboxPatch);
+const mockDismissInboxPatch = vi.mocked(dismissInboxPatch);
+const mockApplyInboxMemoryPatch = vi.mocked(applyInboxMemoryPatch);
+const mockDismissInboxMemoryPatch = vi.mocked(dismissInboxMemoryPatch);
+const mockIsProjectSkillPatchTarget = vi.mocked(isProjectSkillPatchTarget);
+
+const inboxSkill: InboxSkill = {
+ dirName: 'inbox-skill',
+ name: 'Inbox Skill',
+ description: 'A test skill',
+ content:
+ '---\nname: Inbox Skill\ndescription: A test skill\n---\n\n## Procedure\n1. Do the thing\n',
+ extractedAt: '2025-01-15T10:00:00Z',
+};
+
+const inboxPatch: InboxPatch = {
+ fileName: 'update-docs.patch',
+ name: 'update-docs',
+ entries: [
+ {
+ targetPath: '/home/user/.gemini/skills/docs-writer/SKILL.md',
+ diffContent: [
+ '--- /home/user/.gemini/skills/docs-writer/SKILL.md',
+ '+++ /home/user/.gemini/skills/docs-writer/SKILL.md',
+ '@@ -1,3 +1,4 @@',
+ ' line1',
+ ' line2',
+ '+line2.5',
+ ' line3',
+ ].join('\n'),
+ },
+ ],
+ extractedAt: '2025-01-20T14:00:00Z',
+};
+
+const inboxMemoryPatch: InboxMemoryPatch = {
+ kind: 'private',
+ relativePath: 'private',
+ name: 'Private memory',
+ sourceFiles: ['update-memory.patch'],
+ entries: [
+ {
+ targetPath: '/home/user/.gemini/tmp/project/memory/MEMORY.md',
+ isNewFile: false,
+ diffContent: [
+ '--- /home/user/.gemini/tmp/project/memory/MEMORY.md',
+ '+++ /home/user/.gemini/tmp/project/memory/MEMORY.md',
+ '@@ -1,1 +1,1 @@',
+ '-old',
+ '+use focused tests',
+ ].join('\n'),
+ },
+ ],
+ extractedAt: '2025-01-21T10:00:00Z',
+};
+
+const workspacePatch: InboxPatch = {
+ fileName: 'workspace-update.patch',
+ name: 'workspace-update',
+ entries: [
+ {
+ targetPath: '/repo/.gemini/skills/docs-writer/SKILL.md',
+ diffContent: [
+ '--- /repo/.gemini/skills/docs-writer/SKILL.md',
+ '+++ /repo/.gemini/skills/docs-writer/SKILL.md',
+ '@@ -1,1 +1,2 @@',
+ ' line1',
+ '+line2',
+ ].join('\n'),
+ },
+ ],
+};
+
+const multiSectionPatch: InboxPatch = {
+ fileName: 'multi-section.patch',
+ name: 'multi-section',
+ entries: [
+ {
+ targetPath: '/home/user/.gemini/skills/docs-writer/SKILL.md',
+ diffContent: [
+ '--- /home/user/.gemini/skills/docs-writer/SKILL.md',
+ '+++ /home/user/.gemini/skills/docs-writer/SKILL.md',
+ '@@ -1,1 +1,2 @@',
+ ' line1',
+ '+line2',
+ ].join('\n'),
+ },
+ {
+ targetPath: '/home/user/.gemini/skills/docs-writer/SKILL.md',
+ diffContent: [
+ '--- /home/user/.gemini/skills/docs-writer/SKILL.md',
+ '+++ /home/user/.gemini/skills/docs-writer/SKILL.md',
+ '@@ -3,1 +4,2 @@',
+ ' line3',
+ '+line4',
+ ].join('\n'),
+ },
+ ],
+};
+
+const windowsGlobalPatch: InboxPatch = {
+ fileName: 'windows-update.patch',
+ name: 'windows-update',
+ entries: [
+ {
+ targetPath: 'C:\\Users\\sandy\\.gemini\\skills\\docs-writer\\SKILL.md',
+ diffContent: [
+ '--- C:\\Users\\sandy\\.gemini\\skills\\docs-writer\\SKILL.md',
+ '+++ C:\\Users\\sandy\\.gemini\\skills\\docs-writer\\SKILL.md',
+ '@@ -1,1 +1,2 @@',
+ ' line1',
+ '+line2',
+ ].join('\n'),
+ },
+ ],
+};
+
+describe('InboxDialog', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockListInboxSkills.mockResolvedValue([inboxSkill]);
+ mockListInboxPatches.mockResolvedValue([]);
+ mockListInboxMemoryPatches.mockResolvedValue([]);
+ mockMoveInboxSkill.mockResolvedValue({
+ success: true,
+ message: 'Moved "inbox-skill" to ~/.gemini/skills.',
+ });
+ mockDismissInboxSkill.mockResolvedValue({
+ success: true,
+ message: 'Dismissed "inbox-skill" from inbox.',
+ });
+ mockApplyInboxPatch.mockResolvedValue({
+ success: true,
+ message: 'Applied patch to 1 file.',
+ });
+ mockDismissInboxPatch.mockResolvedValue({
+ success: true,
+ message: 'Dismissed "update-docs.patch" from inbox.',
+ });
+ mockApplyInboxMemoryPatch.mockResolvedValue({
+ success: true,
+ message: 'Applied memory patch to 1 file.',
+ });
+ mockDismissInboxMemoryPatch.mockResolvedValue({
+ success: true,
+ message: 'Dismissed 1 private memory patch from inbox.',
+ });
+ mockIsProjectSkillPatchTarget.mockImplementation(
+ async (targetPath: string, config: Config) => {
+ const projectSkillsDir = config.storage
+ ?.getProjectSkillsDir?.()
+ ?.replaceAll('\\', '/')
+ ?.replace(/\/+$/, '');
+
+ return projectSkillsDir
+ ? targetPath.replaceAll('\\', '/').startsWith(projectSkillsDir)
+ : false;
+ },
+ );
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('reviews and applies memory patches', async () => {
+ mockListInboxSkills.mockResolvedValue([]);
+ mockListInboxMemoryPatches.mockResolvedValue([inboxMemoryPatch]);
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ } as unknown as Config;
+ const onReloadMemory = vi.fn().mockResolvedValue(undefined);
+ const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
+ renderWithProviders(
+ ,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('Private memory');
+ });
+
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ const frame = lastFrame() ?? '';
+ expect(frame).toContain('Review');
+ expect(frame).toMatch(/source patch/);
+ });
+
+ // Memory patches default to Dismiss as the highlighted action so a stray
+ // Enter cannot apply durable changes. Arrow-down to reach Apply, then
+ // press Enter to confirm.
+ await act(async () => {
+ stdin.write('\u001B[B'); // arrow down → Apply
+ await waitUntilReady();
+ });
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ // Aggregate apply: relativePath equals the kind name.
+ expect(mockApplyInboxMemoryPatch).toHaveBeenCalledWith(
+ config,
+ 'private',
+ 'private',
+ );
+ expect(onReloadMemory).toHaveBeenCalled();
+ });
+
+ unmount();
+ });
+
+ it('disables the project destination when the workspace is untrusted', async () => {
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(false),
+ } as unknown as Config;
+ const onReloadSkills = vi.fn().mockResolvedValue(undefined);
+ const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
+ renderWithProviders(
+ ,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('Inbox Skill');
+ });
+
+ // Select skill → lands on preview
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('Review new skill');
+ });
+
+ // Select "Move" → lands on destination chooser
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ const frame = lastFrame();
+ expect(frame).toContain('Project');
+ expect(frame).toContain('unavailable until this workspace is trusted');
+ });
+
+ unmount();
+ });
+
+ it('shows inline feedback when moving a skill throws', async () => {
+ mockMoveInboxSkill.mockRejectedValue(new Error('permission denied'));
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ } as unknown as Config;
+ const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
+ renderWithProviders(
+ ,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('Inbox Skill');
+ });
+
+ // Select skill → preview
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ // Select "Move" → destination chooser
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ // Select "Global" → triggers move
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ const frame = lastFrame();
+ expect(frame).toContain('Move "Inbox Skill"');
+ expect(frame).toContain('Failed to install skill: permission denied');
+ });
+
+ unmount();
+ });
+
+ it('shows inline feedback when reloading skills fails after a move', async () => {
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ } as unknown as Config;
+ const onReloadSkills = vi
+ .fn()
+ .mockRejectedValue(new Error('reload hook failed'));
+ const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
+ renderWithProviders(
+ ,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('Inbox Skill');
+ });
+
+ // Select skill → preview
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ // Select "Move" → destination chooser
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ // Select "Global" → triggers move
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain(
+ 'Moved "inbox-skill" to ~/.gemini/skills. Failed to reload skills: reload hook failed',
+ );
+ });
+ expect(onReloadSkills).toHaveBeenCalledTimes(1);
+
+ unmount();
+ });
+
+ it('preserves the highlighted row after Esc-ing back from a sub-phase', async () => {
+ // Reproduces the bug where pressing Esc from the apply dialog re-rendered
+ // the list with focus jumped back to row 0 instead of staying on the row
+ // the user was on.
+ const secondSkill: InboxSkill = {
+ ...inboxSkill,
+ dirName: 'second-skill',
+ name: 'Second Skill',
+ };
+ mockListInboxSkills.mockResolvedValue([inboxSkill, secondSkill]);
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ } as unknown as Config;
+ const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
+ renderWithProviders(
+ ,
+ ),
+ );
+
+ await waitFor(() => {
+ const frame = lastFrame();
+ expect(frame).toContain('Inbox Skill');
+ expect(frame).toContain('Second Skill');
+ });
+
+ // Arrow down to the second row.
+ await act(async () => {
+ stdin.write('\x1b[B');
+ await waitUntilReady();
+ });
+
+ // Enter the second row's preview.
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ const frame = lastFrame();
+ expect(frame).toContain('Review new skill');
+ expect(frame).toContain('Second Skill');
+ });
+
+ // Esc back to list.
+ await act(async () => {
+ stdin.write('\x1b');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ const frame = lastFrame();
+ expect(frame).toContain('Inbox Skill');
+ expect(frame).toContain('Second Skill');
+ });
+
+ // Re-enter (no arrow keys this time). The active row must still be the
+ // SECOND skill, not the first — which is what the bug reproduced before.
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ const frame = lastFrame();
+ expect(frame).toContain('Review new skill');
+ // The preview header echoes the highlighted skill's name.
+ expect(frame).toContain('Second Skill');
+ });
+
+ unmount();
+ });
+
+ describe('patch support', () => {
+ it('shows patches alongside skills with section headers', async () => {
+ mockListInboxPatches.mockResolvedValue([inboxPatch]);
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ storage: {
+ getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
+ },
+ } as unknown as Config;
+ const { lastFrame, unmount } = await act(async () =>
+ renderWithProviders(
+ ,
+ ),
+ );
+
+ await waitFor(() => {
+ const frame = lastFrame();
+ expect(frame).toContain('New Skills');
+ expect(frame).toContain('Inbox Skill');
+ expect(frame).toContain('Skill Updates');
+ expect(frame).toContain('update-docs');
+ });
+
+ unmount();
+ });
+
+ it('shows diff preview when a patch is selected', async () => {
+ mockListInboxSkills.mockResolvedValue([]);
+ mockListInboxPatches.mockResolvedValue([inboxPatch]);
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ storage: {
+ getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
+ },
+ } as unknown as Config;
+ const { lastFrame, stdin, unmount, waitUntilReady } = await act(
+ async () =>
+ renderWithProviders(
+ ,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('update-docs');
+ });
+
+ // Select the patch
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ const frame = lastFrame();
+ expect(frame).toContain('Review changes before applying');
+ expect(frame).toContain('Apply');
+ expect(frame).toContain('Dismiss');
+ });
+
+ unmount();
+ });
+
+ it('applies a patch when Apply is selected', async () => {
+ mockListInboxSkills.mockResolvedValue([]);
+ mockListInboxPatches.mockResolvedValue([inboxPatch]);
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ storage: {
+ getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
+ },
+ } as unknown as Config;
+ const onReloadSkills = vi.fn().mockResolvedValue(undefined);
+ const { stdin, unmount, waitUntilReady } = await act(async () =>
+ renderWithProviders(
+ ,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(mockListInboxPatches).toHaveBeenCalled();
+ });
+
+ // Select the patch
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ // Select "Apply"
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ expect(mockApplyInboxPatch).toHaveBeenCalledWith(
+ config,
+ 'update-docs.patch',
+ );
+ });
+ expect(onReloadSkills).toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('disables Apply for workspace patches in an untrusted workspace', async () => {
+ mockListInboxSkills.mockResolvedValue([]);
+ mockListInboxPatches.mockResolvedValue([workspacePatch]);
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(false),
+ storage: {
+ getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
+ },
+ } as unknown as Config;
+ const { lastFrame, stdin, unmount, waitUntilReady } = await act(
+ async () =>
+ renderWithProviders(
+ ,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('workspace-update');
+ });
+
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ const frame = lastFrame();
+ expect(frame).toContain('Apply');
+ expect(frame).toContain(
+ '.gemini/skills — unavailable until this workspace is trusted',
+ );
+ });
+ expect(mockApplyInboxPatch).not.toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('uses canonical project-scope checks before enabling Apply', async () => {
+ mockListInboxSkills.mockResolvedValue([]);
+ mockListInboxPatches.mockResolvedValue([workspacePatch]);
+ mockIsProjectSkillPatchTarget.mockResolvedValue(true);
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(false),
+ storage: {
+ getProjectSkillsDir: vi
+ .fn()
+ .mockReturnValue('/symlinked/workspace/.gemini/skills'),
+ },
+ } as unknown as Config;
+ const { lastFrame, stdin, unmount, waitUntilReady } = await act(
+ async () =>
+ renderWithProviders(
+ ,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('workspace-update');
+ });
+
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain(
+ '.gemini/skills — unavailable until this workspace is trusted',
+ );
+ });
+ expect(mockIsProjectSkillPatchTarget).toHaveBeenCalledWith(
+ '/repo/.gemini/skills/docs-writer/SKILL.md',
+ config,
+ );
+ expect(mockApplyInboxPatch).not.toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('dismisses a patch when Dismiss is selected', async () => {
+ mockListInboxSkills.mockResolvedValue([]);
+ mockListInboxPatches.mockResolvedValue([inboxPatch]);
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ storage: {
+ getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
+ },
+ } as unknown as Config;
+ const onReloadSkills = vi.fn().mockResolvedValue(undefined);
+ const { stdin, unmount, waitUntilReady } = await act(async () =>
+ renderWithProviders(
+ ,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(mockListInboxPatches).toHaveBeenCalled();
+ });
+
+ // Select the patch
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ // Move down to "Dismiss" and select
+ await act(async () => {
+ stdin.write('\x1b[B');
+ await waitUntilReady();
+ });
+
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ expect(mockDismissInboxPatch).toHaveBeenCalledWith(
+ config,
+ 'update-docs.patch',
+ );
+ });
+ expect(onReloadSkills).not.toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('shows Windows patch entries with a basename and origin tag', async () => {
+ vi.stubEnv('USERPROFILE', 'C:\\Users\\sandy');
+ mockListInboxSkills.mockResolvedValue([]);
+ mockListInboxPatches.mockResolvedValue([windowsGlobalPatch]);
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ storage: {
+ getProjectSkillsDir: vi
+ .fn()
+ .mockReturnValue('C:\\repo\\.gemini\\skills'),
+ },
+ } as unknown as Config;
+ const { lastFrame, unmount } = await act(async () =>
+ renderWithProviders(
+ ,
+ ),
+ );
+
+ await waitFor(() => {
+ const frame = lastFrame();
+ expect(frame).toContain('[Global]');
+ expect(frame).toContain('SKILL.md');
+ expect(frame).not.toContain('C:\\Users\\sandy\\.gemini\\skills');
+ });
+
+ unmount();
+ });
+
+ it('renders multi-section patches without duplicate React keys', async () => {
+ mockListInboxSkills.mockResolvedValue([]);
+ mockListInboxPatches.mockResolvedValue([multiSectionPatch]);
+
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ storage: {
+ getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
+ },
+ } as unknown as Config;
+ const { lastFrame, stdin, unmount, waitUntilReady } = await act(
+ async () =>
+ renderWithProviders(
+ ,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('multi-section');
+ });
+
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('Review changes before applying');
+ });
+
+ expect(consoleErrorSpy).not.toHaveBeenCalledWith(
+ expect.stringContaining('Encountered two children with the same key'),
+ );
+
+ consoleErrorSpy.mockRestore();
+ unmount();
+ });
+
+ const tallPatch: InboxPatch = {
+ fileName: 'tall.patch',
+ name: 'tall-patch',
+ entries: [
+ {
+ targetPath: '/repo/.gemini/skills/docs-writer/SKILL.md',
+ diffContent: [
+ '--- /repo/.gemini/skills/docs-writer/SKILL.md',
+ '+++ /repo/.gemini/skills/docs-writer/SKILL.md',
+ '@@ -1,4 +1,8 @@',
+ ' line1',
+ ' line2',
+ '+added-1',
+ '+added-2',
+ '+added-3',
+ '+added-4',
+ ' line3',
+ ' line4',
+ ].join('\n'),
+ },
+ ],
+ };
+
+ it('alt-buffer: renders a bounded ScrollableList viewport for tall patches', async () => {
+ // Alt-buffer mode has no terminal scrollback, so the dialog must
+ // scroll inside itself. ScrollableList renders a `█` thumb when
+ // content exceeds viewport height — the regression signal that the
+ // diff is bounded and off-screen content is reachable via PgUp/PgDn.
+ mockListInboxSkills.mockResolvedValue([]);
+ mockListInboxPatches.mockResolvedValue([tallPatch]);
+ mockListInboxMemoryPatches.mockResolvedValue([]);
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ storage: {
+ getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
+ },
+ } as unknown as Config;
+
+ const { lastFrame, stdin, unmount, waitUntilReady } = await act(
+ async () =>
+ renderWithProviders(
+ ,
+ {
+ settings: altBufferSettings,
+ uiState: { terminalHeight: 18 },
+ },
+ ),
+ );
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('tall-patch');
+ });
+
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ const frame = lastFrame() ?? '';
+ expect(frame).toContain('Apply');
+ expect(frame).toContain('Dismiss');
+ expect(frame).toContain('█');
+ });
+
+ unmount();
+ });
+
+ it('alt-buffer: surfaces PgUp/PgDn in the patch-preview footer', async () => {
+ mockListInboxSkills.mockResolvedValue([]);
+ mockListInboxPatches.mockResolvedValue([inboxPatch]);
+ mockListInboxMemoryPatches.mockResolvedValue([]);
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ storage: {
+ getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
+ },
+ } as unknown as Config;
+
+ const { lastFrame, stdin, unmount, waitUntilReady } = await act(
+ async () =>
+ renderWithProviders(
+ ,
+ { settings: altBufferSettings },
+ ),
+ );
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('update-docs');
+ });
+
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('PgUp/PgDn to scroll');
+ });
+
+ unmount();
+ });
+
+ it('non-alt-buffer: clips the diff via DiffRenderer with a "lines hidden" hint', async () => {
+ // Non-alt-buffer mode uses the codebase's standard bounded
+ // DiffRenderer + ShowMoreLines + Ctrl+O pattern (matches
+ // FolderTrustDialog/ThemeDialog). MaxSizedBox emits a
+ // "... first/last N line(s) hidden ..." hint when it clips, which
+ // is the regression signal that the diff is bounded.
+ mockListInboxSkills.mockResolvedValue([]);
+ mockListInboxPatches.mockResolvedValue([tallPatch]);
+ mockListInboxMemoryPatches.mockResolvedValue([]);
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ storage: {
+ getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
+ },
+ } as unknown as Config;
+
+ const { lastFrame, stdin, unmount, waitUntilReady } = await act(
+ async () =>
+ renderWithProviders(
+ ,
+ { uiState: { terminalHeight: 18, constrainHeight: true } },
+ ),
+ );
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('tall-patch');
+ });
+
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ expect(lastFrame() ?? '').toMatch(/lines? hidden/);
+ });
+
+ unmount();
+ });
+
+ it('non-alt-buffer: surfaces Ctrl+O inline (not in the footer) when the diff overflows', async () => {
+ // In non-alt-buffer mode the Ctrl+O affordance is rendered inline
+ // by ShowMoreLines above the footer when the diff is clipped. The
+ // footer itself stays clean (no PgUp/PgDn or Ctrl+O text) since
+ // duplicating the hint there would be noisy.
+ mockListInboxSkills.mockResolvedValue([]);
+ mockListInboxPatches.mockResolvedValue([tallPatch]);
+ mockListInboxMemoryPatches.mockResolvedValue([]);
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ storage: {
+ getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
+ },
+ } as unknown as Config;
+
+ const { lastFrame, stdin, unmount, waitUntilReady } = await act(
+ async () =>
+ renderWithProviders(
+ ,
+ { uiState: { terminalHeight: 18, constrainHeight: true } },
+ ),
+ );
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('tall-patch');
+ });
+
+ await act(async () => {
+ stdin.write('\r');
+ await waitUntilReady();
+ });
+
+ await waitFor(() => {
+ const frame = lastFrame() ?? '';
+ expect(frame).toContain('Ctrl+O');
+ expect(frame).not.toContain('PgUp/PgDn to scroll');
+ });
+
+ unmount();
+ });
+ });
+
+ it('renders each list row as exactly two lines even with long descriptions', async () => {
+ // Reproduces the production bug: with the previous renderer, long
+ // descriptions wrapped onto multiple lines (and the date sibling was
+ // interleaved into the wrap), making each item 3-5 rows tall and
+ // breaking the listMaxItemsToShow budget. The fix uses height={2}
+ // and wrap="truncate-end" on every list row.
+ const longDescription =
+ 'This is an extremely long description that would absolutely wrap to ' +
+ 'multiple lines if rendered without truncation, which used to push the ' +
+ 'list-phase footer off the bottom of the alternate buffer in production.';
+ mockListInboxSkills.mockResolvedValue([
+ {
+ dirName: 'long-skill',
+ name: 'long-skill',
+ description: longDescription,
+ content: '---\nname: x\ndescription: y\n---\n',
+ },
+ ]);
+ mockListInboxPatches.mockResolvedValue([]);
+ mockListInboxMemoryPatches.mockResolvedValue([]);
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ } as unknown as Config;
+
+ const { lastFrame, unmount } = await act(async () =>
+ renderWithProviders(
+ ,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('long-skill');
+ });
+
+ const frame = lastFrame() ?? '';
+ expect(frame).not.toContain('production');
+ expect(frame).toContain('extremely long description');
+
+ unmount();
+ });
+
+ it('keeps the list-phase footer on screen with many long-description skills', async () => {
+ const longDesc =
+ 'A very long description that would wrap across multiple lines if not ' +
+ 'truncated, which was causing the dialog body to overflow the bottom ' +
+ 'of the alternate buffer';
+ const manySkills: InboxSkill[] = Array.from({ length: 8 }, (_, i) => ({
+ dirName: `skill-${i}`,
+ name: `skill-${i}`,
+ description: `${longDesc} (#${i})`,
+ content: '---\nname: x\ndescription: y\n---\n',
+ }));
+ mockListInboxSkills.mockResolvedValue(manySkills);
+ mockListInboxPatches.mockResolvedValue([]);
+ mockListInboxMemoryPatches.mockResolvedValue([]);
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ } as unknown as Config;
+
+ const { lastFrame, unmount } = await act(async () =>
+ renderWithProviders(
+ ,
+ { uiState: { terminalHeight: 28 } },
+ ),
+ );
+
+ await waitFor(() => {
+ const frame = lastFrame() ?? '';
+ expect(frame).toContain('Memory Inbox');
+ expect(frame).toContain('Esc to close');
+ });
+
+ unmount();
+ });
+
+ it('keeps the list-phase footer on screen on short terminals', async () => {
+ const manySkills: InboxSkill[] = Array.from({ length: 12 }, (_, i) => ({
+ dirName: `skill-${i}`,
+ name: `Skill ${i}`,
+ description: `Description ${i}`,
+ content: '---\nname: Skill\ndescription: Skill\n---\n',
+ }));
+ mockListInboxSkills.mockResolvedValue(manySkills);
+ mockListInboxPatches.mockResolvedValue([inboxPatch]);
+ mockListInboxMemoryPatches.mockResolvedValue([]);
+
+ const config = {
+ isTrustedFolder: vi.fn().mockReturnValue(true),
+ storage: {
+ getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
+ },
+ } as unknown as Config;
+
+ const { lastFrame, unmount } = await act(async () =>
+ renderWithProviders(
+ ,
+ { uiState: { terminalHeight: 18 } },
+ ),
+ );
+
+ await waitFor(() => {
+ const frame = lastFrame() ?? '';
+ expect(frame).toContain('Memory Inbox');
+ expect(frame).toContain('Esc to close');
+ });
+
+ unmount();
+ });
+});
diff --git a/packages/cli/src/ui/components/InboxDialog.tsx b/packages/cli/src/ui/components/InboxDialog.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3da004266c9354bac5b1e4d02e218f7820fa1d31
--- /dev/null
+++ b/packages/cli/src/ui/components/InboxDialog.tsx
@@ -0,0 +1,1446 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as path from 'node:path';
+import type React from 'react';
+import { Fragment, useState, useMemo, useCallback, useEffect } from 'react';
+import { Box, Text } from 'ink';
+import { theme } from '../semantic-colors.js';
+import { useUIState } from '../contexts/UIStateContext.js';
+import { useKeypress } from '../hooks/useKeypress.js';
+import { Command } from '../key/keyMatchers.js';
+import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
+import { BaseSelectionList } from './shared/BaseSelectionList.js';
+import type { SelectionListItem } from '../hooks/useSelectionList.js';
+import { DialogFooter } from './shared/DialogFooter.js';
+import {
+ DiffRenderer,
+ parseDiffWithLineNumbers,
+ renderDiffLines,
+ type DiffLine,
+} from './messages/DiffRenderer.js';
+import { ScrollableList } from './shared/ScrollableList.js';
+import { ShowMoreLines } from './ShowMoreLines.js';
+import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
+import { OverflowProvider } from '../contexts/OverflowContext.js';
+import {
+ type Config,
+ type InboxSkill,
+ type InboxPatch,
+ type InboxMemoryPatch,
+ type InboxSkillDestination,
+ getErrorMessage,
+ listInboxSkills,
+ listInboxPatches,
+ listInboxMemoryPatches,
+ moveInboxSkill,
+ dismissInboxSkill,
+ applyInboxPatch,
+ dismissInboxPatch,
+ applyInboxMemoryPatch,
+ dismissInboxMemoryPatch,
+ isProjectSkillPatchTarget,
+} from '@google/gemini-cli-core';
+
+type Phase =
+ | 'list'
+ | 'skill-preview'
+ | 'skill-action'
+ | 'patch-preview'
+ | 'memory-preview';
+
+type InboxItem =
+ | { type: 'skill'; skill: InboxSkill }
+ | { type: 'patch'; patch: InboxPatch; targetsProjectSkills: boolean }
+ | { type: 'memory-patch'; memoryPatch: InboxMemoryPatch }
+ | { type: 'header'; label: string };
+
+interface DestinationChoice {
+ destination: InboxSkillDestination;
+ label: string;
+ description: string;
+}
+
+interface PatchAction {
+ action: 'apply' | 'dismiss';
+ label: string;
+ description: string;
+}
+
+interface MemoryPatchAction {
+ action: 'apply' | 'dismiss';
+ label: string;
+ description: string;
+}
+
+const SKILL_DESTINATION_CHOICES: DestinationChoice[] = [
+ {
+ destination: 'global',
+ label: 'Global',
+ description: '~/.gemini/skills — available in all projects',
+ },
+ {
+ destination: 'project',
+ label: 'Project',
+ description: '.gemini/skills — available in this workspace',
+ },
+];
+
+interface SkillPreviewAction {
+ action: 'move' | 'dismiss';
+ label: string;
+ description: string;
+}
+
+const SKILL_PREVIEW_CHOICES: SkillPreviewAction[] = [
+ {
+ action: 'move',
+ label: 'Move',
+ description: 'Choose where to install this skill',
+ },
+ {
+ action: 'dismiss',
+ label: 'Dismiss',
+ description: 'Delete from inbox',
+ },
+];
+
+const PATCH_ACTION_CHOICES: PatchAction[] = [
+ {
+ action: 'apply',
+ label: 'Apply',
+ description: 'Apply patch and delete from inbox',
+ },
+ {
+ action: 'dismiss',
+ label: 'Dismiss',
+ description: 'Delete from inbox without applying',
+ },
+];
+
+// Dismiss-first: memory patches modify durable on-disk state outside the
+// project (private MEMORY.md and sibling files, plus ~/.gemini/GEMINI.md),
+// so a stray Enter on a freshly-opened memory-patch preview must NOT apply.
+// The lower-stakes skill-patch list (PATCH_ACTION_CHOICES) keeps Apply as
+// the default.
+const MEMORY_PATCH_ACTION_CHOICES: MemoryPatchAction[] = [
+ {
+ action: 'dismiss',
+ label: 'Dismiss',
+ description: 'Delete from inbox without applying',
+ },
+ {
+ action: 'apply',
+ label: 'Apply',
+ description: 'Apply patch and delete from inbox',
+ },
+];
+
+function normalizePathForUi(filePath: string): string {
+ return path.posix.normalize(filePath.replaceAll('\\', '/'));
+}
+
+function getPathBasename(filePath: string): string {
+ const normalizedPath = normalizePathForUi(filePath);
+ const basename = path.posix.basename(normalizedPath);
+ return basename === '.' ? filePath : basename;
+}
+
+function formatMemoryPatchSummary(patch: InboxMemoryPatch): string {
+ const hunkCount = patch.entries.length;
+ const sourceCount = patch.sourceFiles.length;
+ const hunkLabel = hunkCount === 1 ? 'hunk' : 'hunks';
+ const sourceLabel = sourceCount === 1 ? 'patch' : 'patches';
+ return `${hunkCount} ${hunkLabel} from ${sourceCount} source ${sourceLabel}`;
+}
+
+async function patchTargetsProjectSkills(
+ patch: InboxPatch,
+ config: Config,
+): Promise {
+ const entryTargetsProjectSkills = await Promise.all(
+ patch.entries.map((entry) =>
+ isProjectSkillPatchTarget(entry.targetPath, config),
+ ),
+ );
+ return entryTargetsProjectSkills.some(Boolean);
+}
+
+/**
+ * Derives a bracketed origin tag from a skill file path,
+ * matching the existing [Built-in] convention in SkillsList.
+ */
+function getSkillOriginTag(filePath: string): string {
+ const normalizedPath = normalizePathForUi(filePath);
+
+ if (normalizedPath.includes('/bundle/')) {
+ return 'Built-in';
+ }
+ if (normalizedPath.includes('/extensions/')) {
+ return 'Extension';
+ }
+ if (normalizedPath.includes('/.gemini/skills/')) {
+ const homeDirs = [process.env['HOME'], process.env['USERPROFILE']]
+ .filter((homeDir): homeDir is string => Boolean(homeDir))
+ .map(normalizePathForUi);
+ if (
+ homeDirs.some((homeDir) =>
+ normalizedPath.startsWith(`${homeDir}/.gemini/skills/`),
+ )
+ ) {
+ return 'Global';
+ }
+ return 'Workspace';
+ }
+ return '';
+}
+
+/**
+ * Creates a unified diff string representing a new file.
+ */
+function newFileDiff(filename: string, content: string): string {
+ const lines = content.split('\n');
+ const hunkLines = lines.map((l) => `+${l}`).join('\n');
+ return [
+ `--- /dev/null`,
+ `+++ ${filename}`,
+ `@@ -0,0 +1,${lines.length} @@`,
+ hunkLines,
+ ].join('\n');
+}
+
+function formatDate(isoString: string): string {
+ try {
+ const date = new Date(isoString);
+ return date.toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ });
+ } catch {
+ return isoString;
+ }
+}
+
+interface DiffSection {
+ /** Stable identifier for the section (e.g. patch entry path + index). */
+ key: string;
+ /** Header rendered above the diff body, e.g. file path or "SKILL.md". */
+ header: string;
+ /** Raw unified-diff string. Parsed via parseDiffWithLineNumbers. */
+ diffContent: string;
+}
+
+interface DiffViewportItem {
+ key: string;
+ /** Pre-rendered React node for this row. */
+ element: React.ReactElement;
+}
+
+/**
+ * A fixed-height, scrollable diff viewer used by the skill, patch, and
+ * memory-patch preview phases. It flattens one or more DiffSections into
+ * individual line items so ScrollableList can virtualize and so
+ * PgUp/PgDn/Shift+arrows move the viewport over arbitrarily long diffs
+ * without overflowing the alternate buffer.
+ *
+ * The visual styling matches DiffRenderer's renderDiffLines path; we share
+ * that helper instead of nesting DiffRenderer (whose own MaxSizedBox
+ * wrapping would interfere with virtualization).
+ */
+const ScrollableDiffViewport: React.FC<{
+ sections: DiffSection[];
+ width: number;
+ height: number;
+ hasFocus: boolean;
+}> = ({ sections, width, height, hasFocus }) => {
+ const items = useMemo(() => {
+ const result: DiffViewportItem[] = [];
+ sections.forEach((section, sectionIndex) => {
+ // Header (with a blank spacer row above for separation between
+ // sections — skipped above the first section).
+ if (sectionIndex > 0) {
+ result.push({
+ key: `${section.key}:spacer`,
+ element: ,
+ });
+ }
+ result.push({
+ key: `${section.key}:header`,
+ element: (
+
+ {section.header}
+
+ ),
+ });
+
+ const parsed: DiffLine[] = parseDiffWithLineNumbers(section.diffContent);
+ const rendered = renderDiffLines({
+ parsedLines: parsed,
+ filename: section.header,
+ terminalWidth: width,
+ });
+ rendered.forEach((node, index) => {
+ result.push({
+ key: `${section.key}:line:${index}`,
+ // renderDiffLines emits ReactNodes with their own keys; wrap each
+ // in a Fragment so ScrollableList sees a single ReactElement per
+ // row regardless of node shape.
+ element: {node},
+ });
+ });
+ });
+ return result;
+ }, [sections, width]);
+
+ const renderItem = useCallback(
+ ({ item }: { item: DiffViewportItem }) => item.element,
+ [],
+ );
+ const keyExtractor = useCallback((item: DiffViewportItem) => item.key, []);
+ // Most diff rows are exactly one line tall; long lines wrap so this is a
+ // lower bound. ScrollableList re-measures via ResizeObserver, so the
+ // estimate only matters for initial sizing.
+ const estimatedItemHeight = useCallback(() => 1, []);
+
+ return (
+
+
+ data={items}
+ renderItem={renderItem}
+ keyExtractor={keyExtractor}
+ estimatedItemHeight={estimatedItemHeight}
+ hasFocus={hasFocus}
+ initialScrollIndex={0}
+ scrollbar={true}
+ />
+
+ );
+};
+
+interface InboxDialogProps {
+ config: Config;
+ onClose: () => void;
+ onReloadSkills: () => Promise;
+ onReloadMemory?: () => Promise;
+}
+
+export const InboxDialog: React.FC = ({
+ config,
+ onClose,
+ onReloadSkills,
+ onReloadMemory,
+}) => {
+ const keyMatchers = useKeyMatchers();
+ const { terminalWidth, terminalHeight, constrainHeight } = useUIState();
+ const isAlternateBuffer = useAlternateBuffer();
+ const isTrustedFolder = config.isTrustedFolder();
+ const [phase, setPhase] = useState('list');
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [selectedItem, setSelectedItem] = useState(null);
+ const [feedback, setFeedback] = useState<{
+ text: string;
+ isError: boolean;
+ } | null>(null);
+ // Tracks the most recent highlighted/selected position in the list so we
+ // can restore focus when the user backs out of a sub-phase (e.g. ESC from
+ // the apply dialog) instead of jumping back to the top of the list.
+ const [lastListIndex, setLastListIndex] = useState(0);
+
+ // Load inbox skills and patches on mount
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ try {
+ const [skills, patches, memoryPatches] = await Promise.all([
+ listInboxSkills(config),
+ listInboxPatches(config),
+ listInboxMemoryPatches(config),
+ ]);
+ const patchItems = await Promise.all(
+ patches.map(async (patch): Promise => {
+ let targetsProjectSkills = false;
+ try {
+ targetsProjectSkills = await patchTargetsProjectSkills(
+ patch,
+ config,
+ );
+ } catch {
+ targetsProjectSkills = false;
+ }
+
+ return {
+ type: 'patch',
+ patch,
+ targetsProjectSkills,
+ };
+ }),
+ );
+ if (!cancelled) {
+ const combined: InboxItem[] = [
+ ...skills.map((skill): InboxItem => ({ type: 'skill', skill })),
+ ...patchItems,
+ ...memoryPatches.map(
+ (memoryPatch): InboxItem => ({
+ type: 'memory-patch',
+ memoryPatch,
+ }),
+ ),
+ ];
+ setItems(combined);
+ setLoading(false);
+ }
+ } catch {
+ if (!cancelled) {
+ setItems([]);
+ setLoading(false);
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [config]);
+
+ const getItemKey = useCallback(
+ (item: InboxItem): string =>
+ item.type === 'skill'
+ ? `skill:${item.skill.dirName}`
+ : item.type === 'patch'
+ ? `patch:${item.patch.fileName}`
+ : item.type === 'memory-patch'
+ ? `memory:${item.memoryPatch.kind}:${item.memoryPatch.relativePath}`
+ : `header:${item.label}`,
+ [],
+ );
+
+ const listItems: Array> = useMemo(() => {
+ const skills = items.filter((i) => i.type === 'skill');
+ const patches = items.filter((i) => i.type === 'patch');
+ const memoryPatches = items.filter((i) => i.type === 'memory-patch');
+ const result: Array> = [];
+
+ const groups: Array<{ label: string; items: InboxItem[] }> = [
+ { label: 'New Skills', items: skills },
+ { label: 'Skill Updates', items: patches },
+ { label: 'Memory Updates', items: memoryPatches },
+ ].filter((group) => group.items.length > 0);
+ const showHeaders = groups.length > 1;
+
+ for (const group of groups) {
+ if (showHeaders) {
+ const header: InboxItem = { type: 'header', label: group.label };
+ result.push({
+ key: `header:${group.label}`,
+ value: header,
+ disabled: true,
+ hideNumber: true,
+ });
+ }
+ for (const item of group.items) {
+ result.push({ key: getItemKey(item), value: item });
+ }
+ }
+
+ return result;
+ }, [items, getItemKey]);
+
+ const destinationItems: Array> = useMemo(
+ () =>
+ SKILL_DESTINATION_CHOICES.map((choice) => {
+ if (choice.destination === 'project' && !isTrustedFolder) {
+ return {
+ key: choice.destination,
+ value: {
+ ...choice,
+ description:
+ '.gemini/skills — unavailable until this workspace is trusted',
+ },
+ disabled: true,
+ };
+ }
+
+ return {
+ key: choice.destination,
+ value: choice,
+ };
+ }),
+ [isTrustedFolder],
+ );
+
+ const selectedPatchTargetsProjectSkills = useMemo(() => {
+ if (!selectedItem || selectedItem.type !== 'patch') {
+ return false;
+ }
+
+ return selectedItem.targetsProjectSkills;
+ }, [selectedItem]);
+
+ const patchActionItems: Array> = useMemo(
+ () =>
+ PATCH_ACTION_CHOICES.map((choice) => {
+ if (
+ choice.action === 'apply' &&
+ selectedPatchTargetsProjectSkills &&
+ !isTrustedFolder
+ ) {
+ return {
+ key: choice.action,
+ value: {
+ ...choice,
+ description:
+ '.gemini/skills — unavailable until this workspace is trusted',
+ },
+ disabled: true,
+ };
+ }
+
+ return {
+ key: choice.action,
+ value: choice,
+ };
+ }),
+ [isTrustedFolder, selectedPatchTargetsProjectSkills],
+ );
+
+ const skillPreviewItems: Array> =
+ useMemo(
+ () =>
+ SKILL_PREVIEW_CHOICES.map((choice) => ({
+ key: choice.action,
+ value: choice,
+ })),
+ [],
+ );
+
+ const memoryPatchActionItems: Array> =
+ useMemo(
+ () =>
+ MEMORY_PATCH_ACTION_CHOICES.map((choice) => ({
+ key: choice.action,
+ value: choice,
+ })),
+ [],
+ );
+
+ const handleSelectItem = useCallback(
+ (item: InboxItem) => {
+ setSelectedItem(item);
+ setFeedback(null);
+ // Remember which list row we navigated away from so ESC restores focus
+ // instead of jumping the cursor back to the top of the list.
+ const idx = listItems.findIndex((i) => i.value === item);
+ if (idx >= 0) {
+ setLastListIndex(idx);
+ }
+ setPhase(
+ item.type === 'skill'
+ ? 'skill-preview'
+ : item.type === 'patch'
+ ? 'patch-preview'
+ : 'memory-preview',
+ );
+ },
+ [listItems],
+ );
+
+ const removeItem = useCallback(
+ (item: InboxItem) => {
+ setItems((prev) =>
+ prev.filter((i) => getItemKey(i) !== getItemKey(item)),
+ );
+ },
+ [getItemKey],
+ );
+
+ const handleSkillPreviewAction = useCallback(
+ (choice: SkillPreviewAction) => {
+ if (!selectedItem || selectedItem.type !== 'skill') return;
+
+ if (choice.action === 'move') {
+ setFeedback(null);
+ setPhase('skill-action');
+ return;
+ }
+
+ // Dismiss
+ setFeedback(null);
+ const skill = selectedItem.skill;
+ void (async () => {
+ try {
+ const result = await dismissInboxSkill(config, skill.dirName);
+ setFeedback({ text: result.message, isError: !result.success });
+ if (result.success) {
+ removeItem(selectedItem);
+ setSelectedItem(null);
+ setPhase('list');
+ }
+ } catch (error) {
+ setFeedback({
+ text: `Failed to dismiss skill: ${getErrorMessage(error)}`,
+ isError: true,
+ });
+ }
+ })();
+ },
+ [config, selectedItem, removeItem],
+ );
+
+ const handleSelectDestination = useCallback(
+ (choice: DestinationChoice) => {
+ if (!selectedItem || selectedItem.type !== 'skill') return;
+ const skill = selectedItem.skill;
+
+ if (choice.destination === 'project' && !config.isTrustedFolder()) {
+ setFeedback({
+ text: 'Project skills are unavailable until this workspace is trusted.',
+ isError: true,
+ });
+ return;
+ }
+
+ setFeedback(null);
+
+ void (async () => {
+ try {
+ const result = await moveInboxSkill(
+ config,
+ skill.dirName,
+ choice.destination,
+ );
+
+ setFeedback({ text: result.message, isError: !result.success });
+
+ if (!result.success) {
+ return;
+ }
+
+ removeItem(selectedItem);
+ setSelectedItem(null);
+ setPhase('list');
+
+ try {
+ await onReloadSkills();
+ } catch (error) {
+ setFeedback({
+ text: `${result.message} Failed to reload skills: ${getErrorMessage(error)}`,
+ isError: true,
+ });
+ }
+ } catch (error) {
+ setFeedback({
+ text: `Failed to install skill: ${getErrorMessage(error)}`,
+ isError: true,
+ });
+ }
+ })();
+ },
+ [config, selectedItem, onReloadSkills, removeItem],
+ );
+
+ const handleSelectPatchAction = useCallback(
+ (choice: PatchAction) => {
+ if (!selectedItem || selectedItem.type !== 'patch') return;
+ const patch = selectedItem.patch;
+
+ if (
+ choice.action === 'apply' &&
+ !config.isTrustedFolder() &&
+ selectedItem.targetsProjectSkills
+ ) {
+ setFeedback({
+ text: 'Project skill patches are unavailable until this workspace is trusted.',
+ isError: true,
+ });
+ return;
+ }
+
+ setFeedback(null);
+
+ void (async () => {
+ try {
+ let result: { success: boolean; message: string };
+ if (choice.action === 'apply') {
+ result = await applyInboxPatch(config, patch.fileName);
+ } else {
+ result = await dismissInboxPatch(config, patch.fileName);
+ }
+
+ setFeedback({ text: result.message, isError: !result.success });
+
+ if (!result.success) {
+ return;
+ }
+
+ removeItem(selectedItem);
+ setSelectedItem(null);
+ setPhase('list');
+
+ if (choice.action === 'apply') {
+ try {
+ await onReloadSkills();
+ } catch (error) {
+ setFeedback({
+ text: `${result.message} Failed to reload skills: ${getErrorMessage(error)}`,
+ isError: true,
+ });
+ }
+ }
+ } catch (error) {
+ const operation =
+ choice.action === 'apply' ? 'apply patch' : 'dismiss patch';
+ setFeedback({
+ text: `Failed to ${operation}: ${getErrorMessage(error)}`,
+ isError: true,
+ });
+ }
+ })();
+ },
+ [config, selectedItem, onReloadSkills, removeItem],
+ );
+
+ const handleSelectMemoryPatchAction = useCallback(
+ (choice: MemoryPatchAction) => {
+ if (!selectedItem || selectedItem.type !== 'memory-patch') return;
+ const memoryPatch = selectedItem.memoryPatch;
+
+ setFeedback(null);
+
+ void (async () => {
+ try {
+ let result: { success: boolean; message: string };
+ if (choice.action === 'apply') {
+ result = await applyInboxMemoryPatch(
+ config,
+ memoryPatch.kind,
+ memoryPatch.relativePath,
+ );
+ } else {
+ result = await dismissInboxMemoryPatch(
+ config,
+ memoryPatch.kind,
+ memoryPatch.relativePath,
+ );
+ }
+
+ setFeedback({ text: result.message, isError: !result.success });
+
+ if (!result.success) {
+ return;
+ }
+
+ removeItem(selectedItem);
+ setSelectedItem(null);
+ setPhase('list');
+
+ if (choice.action === 'apply' && onReloadMemory) {
+ try {
+ await onReloadMemory();
+ } catch (error) {
+ setFeedback({
+ text: `${result.message} Failed to reload memory: ${getErrorMessage(error)}`,
+ isError: true,
+ });
+ }
+ }
+ } catch (error) {
+ const operation =
+ choice.action === 'apply'
+ ? 'apply memory patch'
+ : 'dismiss memory patch';
+ setFeedback({
+ text: `Failed to ${operation}: ${getErrorMessage(error)}`,
+ isError: true,
+ });
+ }
+ })();
+ },
+ [config, selectedItem, onReloadMemory, removeItem],
+ );
+
+ useKeypress(
+ (key) => {
+ if (keyMatchers[Command.ESCAPE](key)) {
+ if (phase === 'skill-action') {
+ setPhase('skill-preview');
+ setFeedback(null);
+ } else if (phase !== 'list') {
+ setPhase('list');
+ setSelectedItem(null);
+ setFeedback(null);
+ } else {
+ onClose();
+ }
+ return true;
+ }
+ return false;
+ },
+ { isActive: true, priority: true },
+ );
+
+ // Hoist the per-phase preview data so the array literals passed to
+ // ScrollableDiffViewport don't change identity on every parent render.
+ // ScrollableDiffViewport memoizes its expensive `parseDiffWithLineNumbers`
+ // + `renderDiffLines` on `sections`, so a new array literal every render
+ // would defeat that and re-colorize the diff each time. Keying on
+ // `selectedItem` captures every input that affects the rendered diffs.
+ // Must live above the early returns below so React sees a consistent
+ // hook order.
+ const previewData = useMemo(() => {
+ if (!selectedItem) {
+ return {
+ skillSections: undefined as DiffSection[] | undefined,
+ patchSections: undefined as DiffSection[] | undefined,
+ memoryGroups: undefined as
+ | Array<[string, { isNewFile: boolean; diffs: string[] }]>
+ | undefined,
+ memorySections: undefined as DiffSection[] | undefined,
+ };
+ }
+
+ if (selectedItem.type === 'skill') {
+ const skill = selectedItem.skill;
+ if (!skill.content) {
+ return {
+ skillSections: undefined,
+ patchSections: undefined,
+ memoryGroups: undefined,
+ memorySections: undefined,
+ };
+ }
+ return {
+ skillSections: [
+ {
+ key: `skill:${skill.dirName}`,
+ header: 'SKILL.md',
+ diffContent: newFileDiff('SKILL.md', skill.content),
+ },
+ ],
+ patchSections: undefined,
+ memoryGroups: undefined,
+ memorySections: undefined,
+ };
+ }
+
+ if (selectedItem.type === 'patch') {
+ const patch = selectedItem.patch;
+ return {
+ skillSections: undefined,
+ patchSections: patch.entries.map((entry, index) => ({
+ key: `${patch.fileName}:${entry.targetPath}:${index}`,
+ header: entry.targetPath,
+ diffContent: entry.diffContent,
+ })),
+ memoryGroups: undefined,
+ memorySections: undefined,
+ };
+ }
+
+ if (selectedItem.type === 'memory-patch') {
+ // Group hunks by target file. Multiple source patches may touch the
+ // same file (e.g. several patches all updating MEMORY.md); showing
+ // the file path once with all its hunks beneath is less noisy than
+ // repeating the path for every hunk.
+ const groups = new Map();
+ for (const entry of selectedItem.memoryPatch.entries) {
+ const existing = groups.get(entry.targetPath);
+ if (existing) {
+ existing.diffs.push(entry.diffContent);
+ if (entry.isNewFile) existing.isNewFile = true;
+ } else {
+ groups.set(entry.targetPath, {
+ isNewFile: entry.isNewFile,
+ diffs: [entry.diffContent],
+ });
+ }
+ }
+ const memoryGroups = Array.from(groups.entries());
+
+ const memorySections: DiffSection[] = [];
+ memoryGroups.forEach(([targetPath, { isNewFile, diffs }], groupIndex) => {
+ const headerAnnotation = `${isNewFile ? ' (new file)' : ''}${
+ diffs.length > 1
+ ? ` · ${diffs.length} changes from different patches`
+ : ''
+ }`;
+ diffs.forEach((diff, hunkIndex) => {
+ memorySections.push({
+ key: `${targetPath}:${groupIndex}:${hunkIndex}`,
+ header:
+ hunkIndex === 0 ? `${targetPath}${headerAnnotation}` : targetPath,
+ diffContent: diff,
+ });
+ });
+ });
+
+ return {
+ skillSections: undefined,
+ patchSections: undefined,
+ memoryGroups,
+ memorySections,
+ };
+ }
+
+ return {
+ skillSections: undefined,
+ patchSections: undefined,
+ memoryGroups: undefined,
+ memorySections: undefined,
+ };
+ }, [selectedItem]);
+
+ if (loading) {
+ return (
+
+ Loading inbox…
+
+ );
+ }
+
+ if (items.length === 0 && !feedback) {
+ return (
+
+ Memory Inbox
+
+ No items in inbox.
+
+
+
+ );
+ }
+
+ // Border + paddingX account for 6 chars of width
+ const contentWidth = terminalWidth - 6;
+
+ // Diff-rendering budgets. Two strategies, picked by `isAlternateBuffer`:
+ //
+ // - Alt-buffer: a fixed-height ScrollableList viewport. There is no
+ // terminal scrollback, so we must scroll inside the dialog itself
+ // via PgUp/PgDn/Shift+arrows.
+ //
+ // - Non-alt-buffer: the codebase's standard pattern of a bounded
+ // DiffRenderer + ShowMoreLines + Ctrl+O (see FolderTrustDialog,
+ // ThemeDialog). Clipped content lands in terminal scrollback when
+ // the user expands via Ctrl+O.
+ //
+ // Chrome accounts for the dialog's borders, padding, title + subtitle,
+ // action list (two `minHeight={2}` rows), the section's `marginTop`,
+ // the dialog footer, and a couple of safety rows. Bumped when inline
+ // feedback is showing.
+ const DIALOG_CHROME_HEIGHT = 16;
+ const feedbackHeight = feedback ? 2 : 0;
+ const diffViewportHeight = Math.max(
+ 3,
+ terminalHeight - DIALOG_CHROME_HEIGHT - feedbackHeight,
+ );
+
+ // For the non-alt-buffer DiffRenderer path, mirror MainContent /
+ // DialogManager and drop the clamp when the user has pressed Ctrl+O.
+ const availableContentHeight = constrainHeight
+ ? diffViewportHeight
+ : undefined;
+ const PATCH_ENTRY_OVERHEAD = 2; // target-path label + marginBottom
+ const patchEntryCount =
+ selectedItem?.type === 'patch'
+ ? selectedItem.patch.entries.length
+ : selectedItem?.type === 'memory-patch'
+ ? selectedItem.memoryPatch.entries.length
+ : 1;
+ const availablePatchEntryHeight =
+ availableContentHeight === undefined
+ ? undefined
+ : Math.max(
+ 3,
+ Math.floor(
+ (availableContentHeight - patchEntryCount * PATCH_ENTRY_OVERHEAD) /
+ Math.max(1, patchEntryCount),
+ ),
+ );
+
+ const previewNavigationHint = isAlternateBuffer
+ ? 'PgUp/PgDn to scroll'
+ : undefined;
+
+ // Budget the list phase so the dialog footer never clips on shorter
+ // terminals. Every visible row — skill items, patch items, memory-patch
+ // items, and the section headers — renders at exactly 2 rows tall
+ // (enforced by `height={2}` on item renders and `marginTop={1}` + 1
+ // text line for headers), so the windowed-slot count maps directly to
+ // terminal rows.
+ //
+ // Chrome rows accounted for:
+ // - round border (2)
+ // - paddingY (2)
+ // - DefaultAppLayout's alt-buffer paddingBottom (1)
+ // - title + subtitle (2)
+ // - marginTop above the list (1)
+ // - dialog footer marginTop + text (2)
+ // - BaseSelectionList ▲ + ▼ scroll arrows (2) — always shown when
+ // items > maxItemsToShow, which is precisely when this budget
+ // matters
+ const LIST_PHASE_CHROME_HEIGHT = 12;
+ const LIST_ROW_HEIGHT = 2;
+ const listMaxItemsToShow = Math.max(
+ 1,
+ Math.min(
+ 8,
+ Math.floor(
+ (terminalHeight - LIST_PHASE_CHROME_HEIGHT - feedbackHeight) /
+ LIST_ROW_HEIGHT,
+ ),
+ ),
+ );
+
+ return (
+
+
+ {phase === 'list' && (
+ <>
+
+ Memory Inbox ({items.length} item{items.length !== 1 ? 's' : ''})
+
+
+ Extracted from past sessions. Select one to review.
+
+
+
+
+ items={listItems}
+ initialIndex={Math.max(
+ 0,
+ Math.min(lastListIndex, listItems.length - 1),
+ )}
+ onSelect={handleSelectItem}
+ isFocused={true}
+ showNumbers={false}
+ showScrollArrows={true}
+ maxItemsToShow={listMaxItemsToShow}
+ renderItem={(item, { titleColor }) => {
+ if (item.value.type === 'header') {
+ return (
+
+
+ {item.value.label}
+
+
+ );
+ }
+ if (item.value.type === 'skill') {
+ const skill = item.value.skill;
+ const subtitle = skill.extractedAt
+ ? `${skill.description} · ${formatDate(skill.extractedAt)}`
+ : skill.description;
+ return (
+
+
+ {skill.name}
+
+
+ {subtitle}
+
+
+ );
+ }
+ if (item.value.type === 'memory-patch') {
+ const memoryPatch = item.value.memoryPatch;
+ const summary = formatMemoryPatchSummary(memoryPatch);
+ const subtitle = memoryPatch.extractedAt
+ ? `${summary} · ${formatDate(memoryPatch.extractedAt)}`
+ : summary;
+ return (
+
+
+ {memoryPatch.name}
+
+
+ {subtitle}
+
+
+ );
+ }
+ const patch = item.value.patch;
+ const fileNames = patch.entries.map((e) =>
+ getPathBasename(e.targetPath),
+ );
+ const origin = getSkillOriginTag(
+ patch.entries[0]?.targetPath ?? '',
+ );
+ const titleLine = origin
+ ? `${patch.name} [${origin}]`
+ : patch.name;
+ const subtitle = patch.extractedAt
+ ? `${fileNames.join(', ')} · ${formatDate(patch.extractedAt)}`
+ : fileNames.join(', ');
+ return (
+
+
+ {titleLine}
+
+
+ {subtitle}
+
+
+ );
+ }}
+ />
+
+
+ {feedback && (
+
+
+ {feedback.isError ? '✗ ' : '✓ '}
+ {feedback.text}
+
+
+ )}
+
+
+ >
+ )}
+
+ {phase === 'skill-preview' && selectedItem?.type === 'skill' && (
+ <>
+ {selectedItem.skill.name}
+
+ Review new skill before installing.
+
+
+ {selectedItem.skill.content &&
+ (isAlternateBuffer ? (
+
+
+
+ ) : (
+
+
+ SKILL.md
+
+
+
+ ))}
+
+
+
+ items={skillPreviewItems}
+ onSelect={handleSkillPreviewAction}
+ isFocused={true}
+ showNumbers={true}
+ renderItem={(item, { titleColor }) => (
+
+
+ {item.value.label}
+
+
+ {item.value.description}
+
+
+ )}
+ />
+
+
+ {feedback && (
+
+
+ {feedback.isError ? '✗ ' : '✓ '}
+ {feedback.text}
+
+
+ )}
+
+ {!isAlternateBuffer && (
+
+ )}
+
+
+ >
+ )}
+
+ {phase === 'skill-action' && selectedItem?.type === 'skill' && (
+ <>
+ Move "{selectedItem.skill.name}"
+
+ Choose where to install this skill.
+
+
+
+
+ items={destinationItems}
+ onSelect={handleSelectDestination}
+ isFocused={true}
+ showNumbers={true}
+ renderItem={(item, { titleColor }) => (
+
+
+ {item.value.label}
+
+
+ {item.value.description}
+
+
+ )}
+ />
+
+
+ {feedback && (
+
+
+ {feedback.isError ? '✗ ' : '✓ '}
+ {feedback.text}
+
+
+ )}
+
+
+ >
+ )}
+
+ {phase === 'patch-preview' && selectedItem?.type === 'patch' && (
+ <>
+ {selectedItem.patch.name}
+
+
+ Review changes before applying.
+
+ {(() => {
+ const origin = getSkillOriginTag(
+ selectedItem.patch.entries[0]?.targetPath ?? '',
+ );
+ return origin ? (
+ {` [${origin}]`}
+ ) : null;
+ })()}
+
+
+
+ {isAlternateBuffer ? (
+
+ ) : (
+ selectedItem.patch.entries.map((entry, index) => (
+
+
+ {entry.targetPath}
+
+
+
+ ))
+ )}
+
+
+
+
+ items={patchActionItems}
+ onSelect={handleSelectPatchAction}
+ isFocused={true}
+ showNumbers={true}
+ renderItem={(item, { titleColor }) => (
+
+
+ {item.value.label}
+
+
+ {item.value.description}
+
+
+ )}
+ />
+
+
+ {feedback && (
+
+
+ {feedback.isError ? '✗ ' : '✓ '}
+ {feedback.text}
+
+
+ )}
+
+ {!isAlternateBuffer && (
+
+ )}
+
+
+ >
+ )}
+
+ {phase === 'memory-preview' &&
+ selectedItem?.type === 'memory-patch' && (
+ <>
+ {selectedItem.memoryPatch.name}
+
+ Review {formatMemoryPatchSummary(selectedItem.memoryPatch)}{' '}
+ before applying. Apply runs each source patch atomically;
+ Dismiss removes them all.
+
+
+ {(() => {
+ // Grouping + section flattening were hoisted into the
+ // `previewData` useMemo so the array identities passed into
+ // ScrollableDiffViewport stay stable across re-renders.
+ const groupEntries = previewData.memoryGroups ?? [];
+
+ if (isAlternateBuffer) {
+ return (
+
+
+
+ );
+ }
+
+ return groupEntries.map(
+ ([targetPath, { isNewFile, diffs }]) => (
+
+
+ {targetPath}
+ {isNewFile ? ' (new file)' : ''}
+ {diffs.length > 1
+ ? ` · ${diffs.length} changes from different patches`
+ : ''}
+
+ {diffs.map((diff, hunkIndex) => (
+
+ ))}
+
+ ),
+ );
+ })()}
+
+
+
+ items={memoryPatchActionItems}
+ onSelect={handleSelectMemoryPatchAction}
+ isFocused={true}
+ showNumbers={true}
+ renderItem={(item, { titleColor }) => (
+
+
+ {item.value.label}
+
+
+ {item.value.description}
+
+
+ )}
+ />
+
+
+ {feedback && (
+
+
+ {feedback.isError ? '✗ ' : '✓ '}
+ {feedback.text}
+
+
+ )}
+
+ {!isAlternateBuffer && (
+
+ )}
+
+
+ >
+ )}
+
+
+ );
+};
diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a2b0788f939c868825e1db78f89bf98617256413
--- /dev/null
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -0,0 +1,5430 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { renderWithProviders, cleanup } 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 { act, useState, useMemo } from 'react';
+import type { EventEmitter } from 'node:events';
+
+const { fakeTranscriptionProvider } = vi.hoisted(() => {
+ // Use require within hoisted block for immediate synchronous access
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, no-restricted-syntax
+ const { EventEmitter } = require('node:events');
+ class FakeTranscriptionProvider extends EventEmitter {
+ connect = vi.fn().mockResolvedValue(undefined);
+ disconnect = vi.fn();
+ sendAudioChunk = vi.fn();
+ getTranscription = vi.fn().mockReturnValue('');
+ }
+ return {
+ fakeTranscriptionProvider: new FakeTranscriptionProvider(),
+ };
+});
+
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const actual = (await importOriginal()) as any;
+ return {
+ ...actual,
+ TranscriptionFactory: {
+ createProvider: vi.fn(() => fakeTranscriptionProvider),
+ },
+ };
+});
+
+import {
+ InputPrompt,
+ tryTogglePasteExpansion,
+ type InputPromptProps,
+} from './InputPrompt.js';
+import { InputContext } from '../contexts/InputContext.js';
+import { type UIState } from '../contexts/UIStateContext.js';
+import {
+ calculateTransformationsForLine,
+ calculateTransformedLine,
+ type TextBuffer,
+} from './shared/text-buffer.js';
+import {
+ ApprovalMode,
+ debugLogger,
+ coreEvents,
+ type Config,
+} from '@google/gemini-cli-core';
+import * as path from 'node:path';
+import {
+ CommandKind,
+ type CommandContext,
+ type SlashCommand,
+} from '../commands/types.js';
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+import { Text } from 'ink';
+import {
+ useShellHistory,
+ type UseShellHistoryReturn,
+} from '../hooks/useShellHistory.js';
+import {
+ useCommandCompletion,
+ CompletionMode,
+ type UseCommandCompletionReturn,
+} from '../hooks/useCommandCompletion.js';
+import {
+ useInputHistory,
+ type UseInputHistoryReturn,
+} from '../hooks/useInputHistory.js';
+import {
+ useReverseSearchCompletion,
+ type UseReverseSearchCompletionReturn,
+} from '../hooks/useReverseSearchCompletion.js';
+import clipboardy from 'clipboardy';
+import * as clipboardUtils from '../utils/clipboardUtils.js';
+import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js';
+import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
+import stripAnsi from 'strip-ansi';
+import { StreamingState } from '../types.js';
+import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
+import { cpLen } from '../utils/textUtils.js';
+import { defaultKeyMatchers, Command } from '../key/keyMatchers.js';
+import { useKeypress, type Key } from '../hooks/useKeypress.js';
+import {
+ appEvents,
+ AppEvent,
+ TransientMessageType,
+} from '../../utils/events.js';
+import '../../test-utils/customMatchers.js';
+
+vi.mock('../hooks/useShellHistory.js');
+vi.mock('../hooks/useCommandCompletion.js');
+vi.mock('../hooks/useInputHistory.js');
+vi.mock('../hooks/useReverseSearchCompletion.js');
+vi.mock('clipboardy');
+vi.mock('../utils/clipboardUtils.js');
+vi.mock('../hooks/useKittyKeyboardProtocol.js');
+vi.mock('./ListeningIndicator.js', () => ({
+ ListeningIndicator: vi.fn(({ color }) => ~~~ ),
+}));
+// Mock ink BEFORE importing components that use it to intercept terminalCursorPosition
+vi.mock('ink', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ Text: vi.fn(({ children, ...props }) => (
+ {children}
+ )),
+ };
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+ cleanup();
+});
+
+const mockSlashCommands: SlashCommand[] = [
+ {
+ name: 'stats',
+ description: 'Check stats',
+ kind: CommandKind.BUILT_IN,
+ isSafeConcurrent: true,
+ },
+ {
+ name: 'clear',
+ kind: CommandKind.BUILT_IN,
+ description: 'Clear screen',
+ action: vi.fn(),
+ },
+ {
+ name: 'memory',
+ kind: CommandKind.BUILT_IN,
+ description: 'Manage memory',
+ subCommands: [
+ {
+ name: 'show',
+ kind: CommandKind.BUILT_IN,
+ description: 'Show memory',
+ action: vi.fn(),
+ },
+ {
+ name: 'add',
+ kind: CommandKind.BUILT_IN,
+ description: 'Add to memory',
+ action: vi.fn(),
+ },
+ {
+ name: 'refresh',
+ kind: CommandKind.BUILT_IN,
+ description: 'Refresh memory',
+ action: vi.fn(),
+ },
+ ],
+ },
+ {
+ name: 'chat',
+ description: 'Manage chats',
+ kind: CommandKind.BUILT_IN,
+ subCommands: [
+ {
+ name: 'resume',
+ description: 'Resume a chat',
+ kind: CommandKind.BUILT_IN,
+ action: vi.fn(),
+ completion: async () => ['fix-foo', 'fix-bar'],
+ },
+ ],
+ },
+ {
+ name: 'resume',
+ description: 'Browse and resume sessions',
+ kind: CommandKind.BUILT_IN,
+ action: vi.fn(),
+ },
+];
+
+export type TestInputPromptProps = InputPromptProps & {
+ buffer: TextBuffer;
+ userMessages: string[];
+ shellModeActive: boolean;
+ copyModeEnabled?: boolean;
+ showEscapePrompt?: boolean;
+ inputWidth: number;
+ suggestionsWidth: number;
+};
+
+const TestInputPrompt = (props: TestInputPromptProps) => {
+ const contextValue = useMemo(
+ () => ({
+ buffer: props.buffer,
+ userMessages: props.userMessages,
+ shellModeActive: props.shellModeActive,
+ copyModeEnabled: props.copyModeEnabled,
+ showEscapePrompt: props.showEscapePrompt || false,
+ inputWidth: props.inputWidth,
+ suggestionsWidth: props.suggestionsWidth,
+ }),
+ [
+ props.buffer,
+ props.userMessages,
+ props.shellModeActive,
+ props.copyModeEnabled,
+ props.showEscapePrompt,
+ props.inputWidth,
+ props.suggestionsWidth,
+ ],
+ );
+
+ return (
+
+
+
+ );
+};
+
+describe('InputPrompt', () => {
+ let props: TestInputPromptProps;
+ let mockShellHistory: UseShellHistoryReturn;
+ let mockCommandCompletion: UseCommandCompletionReturn;
+ let mockInputHistory: UseInputHistoryReturn;
+ let mockReverseSearchCompletion: UseReverseSearchCompletionReturn;
+ let mockBuffer: TextBuffer;
+ let mockCommandContext: CommandContext;
+
+ const GlobalEscapeHandler = ({ onEscape }: { onEscape: () => void }) => {
+ useKeypress(
+ (key) => {
+ if (key.name !== 'escape') return false;
+ onEscape();
+ return true;
+ },
+ { isActive: true, priority: false },
+ );
+ return null;
+ };
+
+ const mockedUseShellHistory = vi.mocked(useShellHistory);
+ const mockedUseCommandCompletion = vi.mocked(useCommandCompletion);
+ const mockedUseInputHistory = vi.mocked(useInputHistory);
+ const mockedUseReverseSearchCompletion = vi.mocked(
+ useReverseSearchCompletion,
+ );
+ const mockedUseKittyKeyboardProtocol = vi.mocked(useKittyKeyboardProtocol);
+ const mockSetEmbeddedShellFocused = vi.fn();
+ const mockSetCleanUiDetailsVisible = vi.fn();
+ const mockToggleCleanUiDetailsVisible = vi.fn();
+ const mockRevealCleanUiDetailsTemporarily = vi.fn();
+ const uiActions = {
+ setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
+ setCleanUiDetailsVisible: mockSetCleanUiDetailsVisible,
+ toggleCleanUiDetailsVisible: mockToggleCleanUiDetailsVisible,
+ revealCleanUiDetailsTemporarily: mockRevealCleanUiDetailsTemporarily,
+ addMessage: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+ coreEvents.removeAllListeners();
+ vi.spyOn(
+ terminalCapabilityManager,
+ 'isKittyProtocolEnabled',
+ ).mockReturnValue(true);
+
+ mockCommandContext = createMockCommandContext();
+
+ mockBuffer = {
+ text: '',
+ cursor: [0, 0],
+ lines: [''],
+ setText: vi.fn(
+ (newText: string, cursorPosition?: 'start' | 'end' | number) => {
+ mockBuffer.text = newText;
+ mockBuffer.lines = newText.split('\n');
+ let col = 0;
+ if (typeof cursorPosition === 'number') {
+ col = cursorPosition;
+ } else if (cursorPosition === 'start') {
+ col = 0;
+ } else {
+ col = newText.length;
+ }
+ mockBuffer.cursor = [0, col];
+ mockBuffer.allVisualLines = newText.split('\n');
+ mockBuffer.viewportVisualLines = newText.split('\n');
+ mockBuffer.visualToLogicalMap = newText
+ .split('\n')
+ .map((_, i) => [i, 0] as [number, number]);
+ mockBuffer.visualCursor = [0, col];
+ mockBuffer.visualScrollRow = 0;
+ mockBuffer.viewportHeight = 10;
+ mockBuffer.visualToTransformedMap = newText
+ .split('\n')
+ .map((_, i) => i);
+ mockBuffer.transformationsByLine = newText.split('\n').map(() => []);
+ },
+ ),
+ replaceRangeByOffset: vi.fn(),
+ viewportVisualLines: [''],
+ allVisualLines: [''],
+ visualCursor: [0, 0],
+ visualScrollRow: 0,
+ viewportHeight: 10,
+ handleInput: vi.fn((key: Key) => {
+ if (defaultKeyMatchers[Command.CLEAR_INPUT](key)) {
+ if (mockBuffer.text.length > 0) {
+ mockBuffer.setText('');
+ return true;
+ }
+ return false;
+ }
+ return false;
+ }),
+ move: vi.fn((dir: string) => {
+ if (dir === 'home') {
+ mockBuffer.visualCursor = [mockBuffer.visualCursor[0], 0];
+ } else if (dir === 'end') {
+ const line =
+ mockBuffer.allVisualLines[mockBuffer.visualCursor[0]] || '';
+ mockBuffer.visualCursor = [mockBuffer.visualCursor[0], cpLen(line)];
+ }
+ }),
+ moveToOffset: vi.fn((offset: number) => {
+ mockBuffer.cursor = [0, offset];
+ }),
+ moveToVisualPosition: vi.fn(),
+ killLineRight: vi.fn(),
+ killLineLeft: vi.fn(),
+ openInExternalEditor: vi.fn(),
+ newline: vi.fn(),
+ undo: vi.fn(),
+ redo: vi.fn(),
+ backspace: vi.fn(),
+ preferredCol: null,
+ selectionAnchor: null,
+ insert: vi.fn(),
+ del: vi.fn(),
+ replaceRange: vi.fn(),
+ deleteWordLeft: vi.fn(),
+ deleteWordRight: vi.fn(),
+ visualToLogicalMap: [[0, 0]],
+ visualToTransformedMap: [0],
+ transformationsByLine: [],
+ getOffset: vi.fn().mockImplementation(() => mockBuffer.cursor[1]),
+ pastedContent: {},
+ } as unknown as TextBuffer;
+
+ mockShellHistory = {
+ history: [],
+ addCommandToHistory: vi.fn(),
+ getPreviousCommand: vi.fn().mockReturnValue(null),
+ getNextCommand: vi.fn().mockReturnValue(null),
+ resetHistoryPosition: vi.fn(),
+ };
+ mockedUseShellHistory.mockReturnValue(mockShellHistory);
+
+ mockCommandCompletion = {
+ suggestions: [],
+ activeSuggestionIndex: -1,
+ isLoadingSuggestions: false,
+ showSuggestions: false,
+ visibleStartIndex: 0,
+ isPerfectMatch: false,
+ navigateUp: vi.fn(),
+ navigateDown: vi.fn(),
+ resetCompletionState: vi.fn(),
+ setActiveSuggestionIndex: vi.fn(),
+ handleAutocomplete: vi.fn(),
+ promptCompletion: {
+ text: '',
+ accept: vi.fn(),
+ clear: vi.fn(),
+ isLoading: false,
+ isActive: false,
+ markSelected: vi.fn(),
+ },
+ getCommandFromSuggestion: vi.fn().mockReturnValue(undefined),
+ slashCompletionRange: {
+ completionStart: -1,
+ completionEnd: -1,
+ getCommandFromSuggestion: vi.fn().mockReturnValue(undefined),
+ isArgumentCompletion: false,
+ leafCommand: null,
+ },
+ getCompletedText: vi.fn().mockReturnValue(null),
+ completionMode: CompletionMode.IDLE,
+ forceShowShellSuggestions: false,
+ setForceShowShellSuggestions: vi.fn(),
+ isShellSuggestionsVisible: true,
+ };
+ mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);
+
+ mockInputHistory = {
+ navigateUp: vi.fn(),
+ navigateDown: vi.fn(),
+ handleSubmit: vi.fn(),
+ };
+ mockedUseInputHistory.mockImplementation(({ onSubmit }) => {
+ mockInputHistory.handleSubmit = vi.fn((val) => onSubmit(val));
+ return mockInputHistory;
+ });
+
+ mockReverseSearchCompletion = {
+ suggestions: [],
+ activeSuggestionIndex: -1,
+ visibleStartIndex: 0,
+ showSuggestions: false,
+ isLoadingSuggestions: false,
+ navigateUp: vi.fn(),
+ navigateDown: vi.fn(),
+ handleAutocomplete: vi.fn(),
+ resetCompletionState: vi.fn(),
+ };
+ mockedUseReverseSearchCompletion.mockReturnValue(
+ mockReverseSearchCompletion,
+ );
+
+ mockedUseKittyKeyboardProtocol.mockReturnValue({
+ enabled: false,
+ checking: false,
+ });
+
+ vi.mocked(clipboardy.read).mockResolvedValue('');
+
+ props = {
+ onQueueMessage: vi.fn(),
+
+ buffer: mockBuffer,
+ onSubmit: vi.fn(),
+ userMessages: [],
+ onClearScreen: vi.fn(),
+ config: {
+ getProjectRoot: () => path.join('test', 'project'),
+ getTargetDir: () => path.join('test', 'project', 'src'),
+ getVimMode: () => false,
+ getUseBackgroundColor: () => true,
+ getUseTerminalBuffer: () => false,
+ getTerminalBackground: () => undefined,
+ getWorkspaceContext: () => ({
+ getDirectories: () => ['/test/project/src'],
+ }),
+ getContentGeneratorConfig: () => ({ apiKey: 'test-api-key' }),
+ } as unknown as Config,
+ slashCommands: mockSlashCommands,
+ commandContext: mockCommandContext,
+ shellModeActive: false,
+ setShellModeActive: vi.fn(),
+ approvalMode: ApprovalMode.DEFAULT,
+ inputWidth: 80,
+ suggestionsWidth: 80,
+ focus: true,
+ setQueueErrorMessage: vi.fn(),
+ streamingState: StreamingState.Idle,
+ setBannerVisible: vi.fn(),
+ };
+ });
+
+ it('should call shellHistory.getPreviousCommand on up arrow in shell mode', async () => {
+ props.shellModeActive = true;
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\u001B[A');
+ });
+ await waitFor(() =>
+ expect(mockShellHistory.getPreviousCommand).toHaveBeenCalled(),
+ );
+ unmount();
+ });
+
+ it('should call shellHistory.getNextCommand on down arrow in shell mode', async () => {
+ props.shellModeActive = true;
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\u001B[B');
+ await waitFor(() =>
+ expect(mockShellHistory.getNextCommand).toHaveBeenCalled(),
+ );
+ });
+ unmount();
+ });
+
+ it('should set the buffer text when a shell history command is retrieved', async () => {
+ props.shellModeActive = true;
+ vi.mocked(mockShellHistory.getPreviousCommand).mockReturnValue(
+ 'previous command',
+ );
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\u001B[A');
+ });
+ await waitFor(() => {
+ expect(mockShellHistory.getPreviousCommand).toHaveBeenCalled();
+ expect(props.buffer.setText).toHaveBeenCalledWith('previous command');
+ });
+ unmount();
+ });
+
+ it('should call shellHistory.addCommandToHistory on submit in shell mode', async () => {
+ props.shellModeActive = true;
+ props.buffer.setText('ls -l');
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\r');
+ });
+ await waitFor(() => {
+ expect(mockShellHistory.addCommandToHistory).toHaveBeenCalledWith(
+ 'ls -l',
+ );
+ expect(props.onSubmit).toHaveBeenCalledWith('ls -l');
+ });
+ unmount();
+ });
+
+ it('should submit command in shell mode when Enter pressed with suggestions visible but no arrow navigation', async () => {
+ props.shellModeActive = true;
+ props.buffer.setText('ls ');
+
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: true,
+ suggestions: [
+ { label: 'dir1', value: 'dir1' },
+ { label: 'dir2', value: 'dir2' },
+ ],
+ activeSuggestionIndex: 0,
+ });
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ // Press Enter without navigating — should dismiss suggestions and fall
+ // through to the main submit handler.
+ await act(async () => {
+ stdin.write('\r');
+ });
+ await waitFor(() => {
+ expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
+ expect(props.onSubmit).toHaveBeenCalledWith('ls'); // Assert fall-through (text is trimmed)
+ });
+ expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should accept suggestion in shell mode when Enter pressed after arrow navigation', async () => {
+ props.shellModeActive = true;
+ props.buffer.setText('ls ');
+
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: true,
+ suggestions: [
+ { label: 'dir1', value: 'dir1' },
+ { label: 'dir2', value: 'dir2' },
+ ],
+ activeSuggestionIndex: 1,
+ });
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ // Press ArrowDown to navigate, then Enter to accept
+ await act(async () => {
+ stdin.write('\u001B[B'); // ArrowDown — sets hasUserNavigatedSuggestions
+ });
+ await waitFor(() =>
+ expect(mockCommandCompletion.navigateDown).toHaveBeenCalled(),
+ );
+
+ await act(async () => {
+ stdin.write('\r'); // Enter — should accept navigated suggestion
+ });
+ await waitFor(() => {
+ expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(1);
+ });
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should NOT call shell history methods when not in shell mode', async () => {
+ props.buffer.setText('some text');
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\u0010'); // Ctrl+P
+ });
+ await waitFor(() => expect(mockInputHistory.navigateUp).toHaveBeenCalled());
+
+ await act(async () => {
+ stdin.write('\u000E'); // Ctrl+N
+ });
+ await waitFor(() =>
+ expect(mockInputHistory.navigateDown).toHaveBeenCalled(),
+ );
+
+ await act(async () => {
+ stdin.write('\r'); // Enter
+ });
+ await waitFor(() =>
+ expect(props.onSubmit).toHaveBeenCalledWith('some text'),
+ );
+
+ expect(mockShellHistory.getPreviousCommand).not.toHaveBeenCalled();
+ expect(mockShellHistory.getNextCommand).not.toHaveBeenCalled();
+ expect(mockShellHistory.addCommandToHistory).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ describe('arrow key navigation', () => {
+ it('should move to start of line on Up arrow if on first line but not at start', async () => {
+ mockBuffer.allVisualLines = ['line 1', 'line 2'];
+ mockBuffer.visualCursor = [0, 5]; // First line, not at start
+ mockBuffer.visualScrollRow = 0;
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\u001B[A'); // Up arrow
+ });
+
+ await waitFor(() => {
+ expect(mockBuffer.move).toHaveBeenCalledWith('home');
+ expect(mockInputHistory.navigateUp).not.toHaveBeenCalled();
+ });
+ unmount();
+ });
+
+ it('should navigate history on Up arrow if on first line and at start', async () => {
+ mockBuffer.allVisualLines = ['line 1', 'line 2'];
+ mockBuffer.visualCursor = [0, 0]; // First line, at start
+ mockBuffer.visualScrollRow = 0;
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\u001B[A'); // Up arrow
+ });
+
+ await waitFor(() => {
+ expect(mockBuffer.move).not.toHaveBeenCalledWith('home');
+ expect(mockInputHistory.navigateUp).toHaveBeenCalled();
+ });
+ unmount();
+ });
+
+ it('should move to end of line on Down arrow if on last line but not at end', async () => {
+ mockBuffer.allVisualLines = ['line 1', 'line 2'];
+ mockBuffer.visualCursor = [1, 0]; // Last line, not at end
+ mockBuffer.visualScrollRow = 0;
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\u001B[B'); // Down arrow
+ });
+
+ await waitFor(() => {
+ expect(mockBuffer.move).toHaveBeenCalledWith('end');
+ expect(mockInputHistory.navigateDown).not.toHaveBeenCalled();
+ });
+ unmount();
+ });
+
+ it('should navigate history on Down arrow if on last line and at end', async () => {
+ mockBuffer.allVisualLines = ['line 1', 'line 2'];
+ mockBuffer.visualCursor = [1, 6]; // Last line, at end ("line 2" is length 6)
+ mockBuffer.visualScrollRow = 0;
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\u001B[B'); // Down arrow
+ });
+
+ await waitFor(() => {
+ expect(mockBuffer.move).not.toHaveBeenCalledWith('end');
+ expect(mockInputHistory.navigateDown).toHaveBeenCalled();
+ });
+ unmount();
+ });
+ });
+
+ it('should call completion.navigateUp for both up arrow and Ctrl+P when suggestions are showing', async () => {
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: true,
+ suggestions: [
+ { label: 'memory', value: 'memory' },
+ { label: 'memcache', value: 'memcache' },
+ ],
+ });
+
+ props.buffer.setText('/mem');
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ // Test up arrow
+ await act(async () => {
+ stdin.write('\u001B[A'); // Up arrow
+ });
+ await waitFor(() =>
+ expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(1),
+ );
+
+ await act(async () => {
+ stdin.write('\u0010'); // Ctrl+P
+ });
+ await waitFor(() =>
+ expect(mockCommandCompletion.navigateUp).toHaveBeenCalledTimes(2),
+ );
+ expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('should call completion.navigateDown for both down arrow and Ctrl+N when suggestions are showing', async () => {
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: true,
+ suggestions: [
+ { label: 'memory', value: 'memory' },
+ { label: 'memcache', value: 'memcache' },
+ ],
+ });
+ props.buffer.setText('/mem');
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ // Test down arrow
+ await act(async () => {
+ stdin.write('\u001B[B'); // Down arrow
+ });
+ await waitFor(() =>
+ expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(1),
+ );
+
+ await act(async () => {
+ stdin.write('\u000E'); // Ctrl+N
+ });
+ await waitFor(() =>
+ expect(mockCommandCompletion.navigateDown).toHaveBeenCalledTimes(2),
+ );
+ expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('should NOT call completion navigation when suggestions are not showing', async () => {
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: false,
+ });
+ props.buffer.setText('some text');
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\u0010'); // Ctrl+P
+ });
+ await waitFor(() => expect(mockInputHistory.navigateUp).toHaveBeenCalled());
+ await act(async () => {
+ stdin.write('\u000E'); // Ctrl+N
+ });
+ await waitFor(() =>
+ expect(mockInputHistory.navigateDown).toHaveBeenCalled(),
+ );
+ await act(async () => {
+ stdin.write('\u0010'); // Ctrl+P
+ });
+ await act(async () => {
+ stdin.write('\u000E'); // Ctrl+N
+ });
+
+ await waitFor(() => {
+ expect(mockCommandCompletion.navigateUp).not.toHaveBeenCalled();
+ expect(mockCommandCompletion.navigateDown).not.toHaveBeenCalled();
+ });
+ unmount();
+ });
+
+ it('should clear the buffer and reset completion on Ctrl+C', async () => {
+ mockBuffer.text = 'some text';
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\u0003'); // Ctrl+C
+ });
+
+ await waitFor(() => {
+ expect(mockBuffer.setText).toHaveBeenCalledWith('');
+ expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
+ });
+ unmount();
+ });
+
+ describe('clipboard image paste', () => {
+ beforeEach(() => {
+ vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
+ vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);
+ vi.mocked(clipboardUtils.cleanupOldClipboardImages).mockResolvedValue(
+ undefined,
+ );
+ });
+
+ it('should handle Ctrl+V when clipboard has an image', async () => {
+ vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
+ vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
+ '/test/.gemini-clipboard/clipboard-123.png',
+ );
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ // Send Ctrl+V
+ await act(async () => {
+ stdin.write('\x16'); // Ctrl+V
+ });
+ await waitFor(() => {
+ expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
+ expect(clipboardUtils.saveClipboardImage).toHaveBeenCalledWith(
+ props.config.getTargetDir(),
+ );
+ expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalledWith(
+ props.config.getTargetDir(),
+ );
+ expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
+ });
+ unmount();
+ });
+
+ it('should not insert anything when clipboard has no image', async () => {
+ vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\x16'); // Ctrl+V
+ });
+ await waitFor(() => {
+ expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
+ });
+ expect(clipboardUtils.saveClipboardImage).not.toHaveBeenCalled();
+ expect(mockBuffer.setText).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should handle image save failure gracefully', async () => {
+ vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
+ vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\x16'); // Ctrl+V
+ });
+ await waitFor(() => {
+ expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
+ });
+ expect(mockBuffer.setText).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should insert image path at cursor position with proper spacing', async () => {
+ const imagePath = path.join(
+ 'test',
+ '.gemini-clipboard',
+ 'clipboard-456.png',
+ );
+ vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
+ vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(imagePath);
+
+ // Set initial text and cursor position
+ mockBuffer.text = 'Hello world';
+ mockBuffer.cursor = [0, 5]; // Cursor after "Hello"
+ vi.mocked(mockBuffer.getOffset).mockReturnValue(5);
+ mockBuffer.lines = ['Hello world'];
+ mockBuffer.replaceRangeByOffset = vi.fn();
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\x16'); // Ctrl+V
+ });
+ await waitFor(() => {
+ // Should insert at cursor position with spaces
+ expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
+ });
+
+ // Get the actual call to see what path was used
+ const actualCall = vi.mocked(mockBuffer.replaceRangeByOffset).mock
+ .calls[0];
+ expect(actualCall[0]).toBe(5); // start offset
+ expect(actualCall[1]).toBe(5); // end offset
+ expect(actualCall[2]).toBe(
+ ' @' + path.relative(path.join('test', 'project', 'src'), imagePath),
+ );
+ unmount();
+ });
+
+ it('should handle errors during clipboard operations', async () => {
+ const debugLoggerErrorSpy = vi
+ .spyOn(debugLogger, 'error')
+ .mockImplementation(() => {});
+ vi.mocked(clipboardUtils.clipboardHasImage).mockRejectedValue(
+ new Error('Clipboard error'),
+ );
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\x16'); // Ctrl+V
+ });
+ await waitFor(() => {
+ expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
+ 'Error handling paste:',
+ expect.any(Error),
+ );
+ });
+ expect(mockBuffer.setText).not.toHaveBeenCalled();
+
+ debugLoggerErrorSpy.mockRestore();
+ unmount();
+ });
+ });
+
+ describe('clipboard text paste', () => {
+ it('should insert text from clipboard on Ctrl+V', async () => {
+ vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
+ vi.mocked(clipboardy.read).mockResolvedValue('pasted text');
+ vi.mocked(mockBuffer.replaceRangeByOffset).mockClear();
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\x16'); // Ctrl+V
+ });
+
+ await waitFor(() => {
+ expect(clipboardy.read).toHaveBeenCalled();
+ expect(mockBuffer.insert).toHaveBeenCalledWith(
+ 'pasted text',
+ expect.objectContaining({ paste: true }),
+ );
+ });
+ unmount();
+ });
+
+ it('should use OSC 52 when useOSC52Paste setting is enabled', async () => {
+ vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
+ const settings = createMockSettings({
+ experimental: { useOSC52Paste: true },
+ });
+
+ const { stdout, stdin, unmount } = await renderWithProviders(
+ ,
+ { settings },
+ );
+
+ const writeSpy = vi.spyOn(stdout, 'write');
+
+ await act(async () => {
+ stdin.write('\x16'); // Ctrl+V
+ });
+
+ await waitFor(() => {
+ expect(writeSpy).toHaveBeenCalledWith('\x1b]52;c;?\x07');
+ });
+ // Should NOT call clipboardy.read()
+ expect(clipboardy.read).not.toHaveBeenCalled();
+ unmount();
+ });
+ });
+
+ it.each([
+ {
+ name: 'should complete a partial parent command',
+ bufferText: '/mem',
+ suggestions: [{ label: 'memory', value: 'memory', description: '...' }],
+ activeIndex: 0,
+ },
+ {
+ name: 'should append a sub-command when parent command is complete',
+ bufferText: '/memory ',
+ suggestions: [
+ { label: 'show', value: 'show' },
+ { label: 'add', value: 'add' },
+ ],
+ activeIndex: 1,
+ },
+ {
+ name: 'should handle the backspace edge case correctly',
+ bufferText: '/memory',
+ suggestions: [
+ { label: 'show', value: 'show' },
+ { label: 'add', value: 'add' },
+ ],
+ activeIndex: 0,
+ },
+ {
+ name: 'should complete a partial argument for a command',
+ bufferText: '/chat resume fi-',
+ suggestions: [{ label: 'fix-foo', value: 'fix-foo' }],
+ activeIndex: 0,
+ },
+ ])('$name', async ({ bufferText, suggestions, activeIndex }) => {
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: true,
+ suggestions,
+ activeSuggestionIndex: activeIndex,
+ });
+ props.buffer.setText(bufferText);
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => stdin.write('\t'));
+ await waitFor(() =>
+ expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(
+ activeIndex,
+ ),
+ );
+ unmount();
+ });
+
+ it('should autocomplete on Enter when suggestions are active, without submitting', async () => {
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: true,
+ suggestions: [{ label: 'memory', value: 'memory' }],
+ activeSuggestionIndex: 0,
+ });
+ props.buffer.setText('/mem');
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\r');
+ });
+ await waitFor(() => {
+ // The app should autocomplete the text, NOT submit.
+ expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
+ });
+
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should complete a command based on its altNames', async () => {
+ props.slashCommands = [
+ {
+ name: 'help',
+ altNames: ['?'],
+ kind: CommandKind.BUILT_IN,
+ description: '...',
+ },
+ ];
+
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: true,
+ suggestions: [{ label: 'help', value: 'help' }],
+ activeSuggestionIndex: 0,
+ });
+ props.buffer.setText('/?');
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\t'); // Press Tab for autocomplete
+ });
+ await waitFor(() =>
+ expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0),
+ );
+ unmount();
+ });
+
+ it('queues a message when Tab is pressed during generation', async () => {
+ props.buffer.setText('A new prompt');
+ props.streamingState = StreamingState.Responding;
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\t');
+ });
+
+ await waitFor(() => {
+ expect(props.onQueueMessage).toHaveBeenCalledWith('A new prompt');
+ expect(props.buffer.text).toBe('');
+ });
+ unmount();
+ });
+
+ it('shows an error when attempting to queue a slash command', async () => {
+ props.buffer.setText('/clear');
+ props.streamingState = StreamingState.Responding;
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\t');
+ });
+
+ await waitFor(() => {
+ expect(props.setQueueErrorMessage).toHaveBeenCalledWith(
+ 'Slash commands cannot be queued',
+ );
+ expect(props.onQueueMessage).not.toHaveBeenCalled();
+ });
+ unmount();
+ });
+
+ it('shows an error when attempting to queue a shell command', async () => {
+ props.shellModeActive = true;
+ props.buffer.setText('ls');
+ props.streamingState = StreamingState.Responding;
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\t');
+ });
+
+ await waitFor(() => {
+ expect(props.setQueueErrorMessage).toHaveBeenCalledWith(
+ 'Shell commands cannot be queued',
+ );
+ expect(props.onQueueMessage).not.toHaveBeenCalled();
+ });
+ unmount();
+ });
+ it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
+ props.buffer.setText(' '); // Set buffer to whitespace
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\r'); // Press Enter
+ });
+
+ await waitFor(() => {
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ });
+ unmount();
+ });
+
+ it('should submit directly on Enter when isPerfectMatch is true', async () => {
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: false,
+ isPerfectMatch: true,
+ });
+ props.buffer.setText('/clear');
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\r');
+ });
+ await waitFor(() => expect(props.onSubmit).toHaveBeenCalledWith('/clear'));
+ unmount();
+ });
+
+ it('should execute perfect match on Enter even if suggestions are showing, if at first suggestion', async () => {
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: true,
+ suggestions: [
+ { label: 'review', value: 'review' }, // Match is now at index 0
+ { label: 'review-frontend', value: 'review-frontend' },
+ ],
+ activeSuggestionIndex: 0,
+ isPerfectMatch: true,
+ });
+ props.buffer.text = '/review';
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\r');
+ });
+
+ await waitFor(() => {
+ expect(props.onSubmit).toHaveBeenCalledWith('/review');
+ });
+ unmount();
+ });
+
+ it('should autocomplete and NOT execute on Enter if a DIFFERENT suggestion is selected even if perfect match', async () => {
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: true,
+ suggestions: [
+ { label: 'review', value: 'review' },
+ { label: 'review-frontend', value: 'review-frontend' },
+ ],
+ activeSuggestionIndex: 1, // review-frontend selected (not the perfect match at 0)
+ isPerfectMatch: true, // /review is a perfect match
+ });
+ props.buffer.text = '/review';
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\r');
+ });
+
+ await waitFor(() => {
+ // Should handle autocomplete for index 1
+ expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(1);
+ // Should NOT submit
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ });
+ unmount();
+ });
+
+ it('should submit directly on Enter when a complete leaf command is typed', async () => {
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: false,
+ isPerfectMatch: false, // Added explicit isPerfectMatch false
+ });
+ props.buffer.setText('/clear');
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\r');
+ });
+ await waitFor(() => expect(props.onSubmit).toHaveBeenCalledWith('/clear'));
+ unmount();
+ });
+
+ it('should submit on Enter when an @-path is a perfect match', async () => {
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: true,
+ suggestions: [{ label: 'file.txt', value: 'file.txt' }],
+ activeSuggestionIndex: 0,
+ isPerfectMatch: true,
+ completionMode: CompletionMode.AT,
+ });
+ props.buffer.text = '@file.txt';
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\r');
+ });
+
+ await waitFor(() => {
+ // Should submit directly
+ expect(props.onSubmit).toHaveBeenCalledWith('@file.txt');
+ });
+ unmount();
+ });
+
+ it('should NOT submit on Shift+Enter even if an @-path is a perfect match', async () => {
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: true,
+ suggestions: [{ label: 'file.txt', value: 'file.txt' }],
+ activeSuggestionIndex: 0,
+ isPerfectMatch: true,
+ completionMode: CompletionMode.AT,
+ });
+ props.buffer.text = '@file.txt';
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ // Simulate Shift+Enter using CSI u sequence
+ stdin.write('\x1b[13;2u');
+ });
+
+ // Should NOT submit, should call newline instead
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ expect(props.buffer.newline).toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should auto-execute commands with autoExecute: true on Enter', async () => {
+ const aboutCommand: SlashCommand = {
+ name: 'about',
+ kind: CommandKind.BUILT_IN,
+ description: 'About command',
+ action: vi.fn(),
+ autoExecute: true,
+ };
+
+ const suggestion = { label: 'about', value: 'about' };
+
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: true,
+ suggestions: [suggestion],
+ activeSuggestionIndex: 0,
+ getCommandFromSuggestion: vi.fn().mockReturnValue(aboutCommand),
+ getCompletedText: vi.fn().mockReturnValue('/about'),
+ slashCompletionRange: {
+ completionStart: 1,
+ completionEnd: 3, // "/ab" -> start at 1, end at 3
+ getCommandFromSuggestion: vi.fn(),
+ isArgumentCompletion: false,
+ leafCommand: null,
+ },
+ });
+
+ // User typed partial command
+ props.buffer.setText('/ab');
+ props.buffer.lines = ['/ab'];
+ props.buffer.cursor = [0, 3];
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\r'); // Enter
+ });
+
+ await waitFor(() => {
+ // Should submit the full command constructed from buffer + suggestion
+ expect(props.onSubmit).toHaveBeenCalledWith('/about');
+ // Should NOT handle autocomplete (which just fills text)
+ expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled();
+ });
+ unmount();
+ });
+
+ it('should autocomplete commands with autoExecute: false on Enter', async () => {
+ const shareCommand: SlashCommand = {
+ name: 'share',
+ kind: CommandKind.BUILT_IN,
+ description: 'Share conversation to file',
+ action: vi.fn(),
+ autoExecute: false, // Explicitly set to false
+ };
+
+ const suggestion = { label: 'share', value: 'share' };
+
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: true,
+ suggestions: [suggestion],
+ activeSuggestionIndex: 0,
+ getCommandFromSuggestion: vi.fn().mockReturnValue(shareCommand),
+ getCompletedText: vi.fn().mockReturnValue('/share'),
+ });
+
+ props.buffer.setText('/sh');
+ props.buffer.lines = ['/sh'];
+ props.buffer.cursor = [0, 3];
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\r'); // Enter
+ });
+
+ await waitFor(() => {
+ // Should autocomplete to allow adding file argument
+ expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ });
+ unmount();
+ });
+
+ it('should autocomplete on Tab, even for executable commands', async () => {
+ const executableCommand: SlashCommand = {
+ name: 'about',
+ kind: CommandKind.BUILT_IN,
+ description: 'About info',
+ action: vi.fn(),
+ autoExecute: true,
+ };
+
+ const suggestion = { label: 'about', value: 'about' };
+
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: true,
+ suggestions: [suggestion],
+ activeSuggestionIndex: 0,
+ getCommandFromSuggestion: vi.fn().mockReturnValue(executableCommand),
+ getCompletedText: vi.fn().mockReturnValue('/about'),
+ });
+
+ props.buffer.setText('/ab');
+ props.buffer.lines = ['/ab'];
+ props.buffer.cursor = [0, 3];
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\t'); // Tab
+ });
+
+ await waitFor(() => {
+ // Tab always autocompletes, never executes
+ expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ });
+ unmount();
+ });
+
+ it('should NOT autocomplete on Shift+Tab', async () => {
+ const suggestion = { label: 'about', value: 'about' };
+
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: true,
+ suggestions: [suggestion],
+ activeSuggestionIndex: 0,
+ getCompletedText: vi.fn().mockReturnValue('/about'),
+ });
+
+ props.buffer.setText('/ab');
+ props.buffer.lines = ['/ab'];
+ props.buffer.cursor = [0, 3];
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\x1b[Z'); // Shift+Tab
+ });
+
+ // We need to wait a bit to ensure handleAutocomplete was NOT called
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should autocomplete custom commands from .toml files on Enter', async () => {
+ const customCommand: SlashCommand = {
+ name: 'find-capital',
+ kind: CommandKind.USER_FILE,
+ description: 'Find capital of a country',
+ action: vi.fn(),
+ // No autoExecute flag - custom commands default to undefined
+ };
+
+ const suggestion = { label: 'find-capital', value: 'find-capital' };
+
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: true,
+ suggestions: [suggestion],
+ activeSuggestionIndex: 0,
+ getCommandFromSuggestion: vi.fn().mockReturnValue(customCommand),
+ getCompletedText: vi.fn().mockReturnValue('/find-capital'),
+ });
+
+ props.buffer.setText('/find');
+ props.buffer.lines = ['/find'];
+ props.buffer.cursor = [0, 5];
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\r'); // Enter
+ });
+
+ await waitFor(() => {
+ // Should autocomplete (not execute) since autoExecute is undefined
+ expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ });
+ unmount();
+ });
+
+ it('should auto-execute argument completion when command has autoExecute: true', async () => {
+ // Simulates: /mcp auth where user selects a server from completions
+ const authCommand: SlashCommand = {
+ name: 'auth',
+ kind: CommandKind.BUILT_IN,
+ description: 'Authenticate with MCP server',
+ action: vi.fn(),
+ autoExecute: true,
+ completion: vi.fn().mockResolvedValue(['server1', 'server2']),
+ };
+
+ const suggestion = { label: 'server1', value: 'server1' };
+
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: true,
+ suggestions: [suggestion],
+ activeSuggestionIndex: 0,
+ getCommandFromSuggestion: vi.fn().mockReturnValue(authCommand),
+ getCompletedText: vi.fn().mockReturnValue('/mcp auth server1'),
+ slashCompletionRange: {
+ completionStart: 10,
+ completionEnd: 10,
+ getCommandFromSuggestion: vi.fn(),
+ isArgumentCompletion: true,
+ leafCommand: authCommand,
+ },
+ });
+
+ props.buffer.setText('/mcp auth ');
+ props.buffer.lines = ['/mcp auth '];
+ props.buffer.cursor = [0, 10];
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\r'); // Enter
+ });
+
+ await waitFor(() => {
+ // Should auto-execute with the completed command
+ expect(props.onSubmit).toHaveBeenCalledWith('/mcp auth server1');
+ expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled();
+ });
+ unmount();
+ });
+
+ it('should autocomplete argument completion when command has autoExecute: false', async () => {
+ // Simulates: /extensions enable where multi-arg completions should NOT auto-execute
+ const enableCommand: SlashCommand = {
+ name: 'enable',
+ kind: CommandKind.BUILT_IN,
+ description: 'Enable an extension',
+ action: vi.fn(),
+ autoExecute: false,
+ completion: vi.fn().mockResolvedValue(['ext1 --scope user']),
+ };
+
+ const suggestion = {
+ label: 'ext1 --scope user',
+ value: 'ext1 --scope user',
+ };
+
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: true,
+ suggestions: [suggestion],
+ activeSuggestionIndex: 0,
+ getCommandFromSuggestion: vi.fn().mockReturnValue(enableCommand),
+ getCompletedText: vi
+ .fn()
+ .mockReturnValue('/extensions enable ext1 --scope user'),
+ slashCompletionRange: {
+ completionStart: 19,
+ completionEnd: 19,
+ getCommandFromSuggestion: vi.fn(),
+ isArgumentCompletion: true,
+ leafCommand: enableCommand,
+ },
+ });
+
+ props.buffer.setText('/extensions enable ');
+ props.buffer.lines = ['/extensions enable '];
+ props.buffer.cursor = [0, 19];
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\r'); // Enter
+ });
+
+ await waitFor(() => {
+ // Should autocomplete (not execute) to allow user to modify
+ expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ });
+ unmount();
+ });
+
+ it('should autocomplete command name even with autoExecute: true if command has completion function', async () => {
+ // Simulates: /chat resu -> should NOT auto-execute, should autocomplete to show arg completions
+ const resumeCommand: SlashCommand = {
+ name: 'resume',
+ kind: CommandKind.BUILT_IN,
+ description: 'Resume a conversation',
+ action: vi.fn(),
+ autoExecute: true,
+ completion: vi.fn().mockResolvedValue(['chat1', 'chat2']),
+ };
+
+ const suggestion = { label: 'resume', value: 'resume' };
+
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: true,
+ suggestions: [suggestion],
+ activeSuggestionIndex: 0,
+ getCommandFromSuggestion: vi.fn().mockReturnValue(resumeCommand),
+ getCompletedText: vi.fn().mockReturnValue('/chat resume'),
+ slashCompletionRange: {
+ completionStart: 6,
+ completionEnd: 10,
+ getCommandFromSuggestion: vi.fn(),
+ isArgumentCompletion: false,
+ leafCommand: null,
+ },
+ });
+
+ props.buffer.setText('/chat resu');
+ props.buffer.lines = ['/chat resu'];
+ props.buffer.cursor = [0, 10];
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\r'); // Enter
+ });
+
+ await waitFor(() => {
+ // Should autocomplete to allow selecting an argument, NOT auto-execute
+ expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ });
+ unmount();
+ });
+
+ it('should autocomplete an @-path on Enter without submitting', async () => {
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: true,
+ suggestions: [{ label: 'index.ts', value: 'index.ts' }],
+ activeSuggestionIndex: 0,
+ });
+ props.buffer.setText('@src/components/');
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\r');
+ });
+ await waitFor(() =>
+ expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0),
+ );
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should add a newline on enter when the line ends with a backslash', async () => {
+ // This test simulates multi-line input, not submission
+ mockBuffer.text = 'first line\\';
+ mockBuffer.cursor = [0, 11];
+ mockBuffer.lines = ['first line\\'];
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\r');
+ });
+ await waitFor(() => {
+ expect(props.buffer.backspace).toHaveBeenCalled();
+ expect(props.buffer.newline).toHaveBeenCalled();
+ });
+
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should clear the buffer on Ctrl+C if it has text', async () => {
+ await act(async () => {
+ props.buffer.setText('some text to clear');
+ });
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\x03'); // Ctrl+C character
+ });
+ await waitFor(() => {
+ expect(props.buffer.setText).toHaveBeenCalledWith('');
+ expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
+ });
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should render correctly in plan mode', async () => {
+ props.approvalMode = ApprovalMode.PLAN;
+ const { stdout, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await waitFor(() => {
+ const frame = stdout.lastFrameRaw();
+ // In plan mode it uses '>' but with success color.
+ // We check that it contains '>' and not '*' or '!'.
+ expect(frame).toContain('>');
+ expect(frame).not.toContain('*');
+ expect(frame).not.toContain('!');
+ });
+ unmount();
+ });
+
+ it('should NOT clear the buffer on Ctrl+C if it is empty', async () => {
+ props.buffer.text = '';
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\x03'); // Ctrl+C character
+ });
+
+ await waitFor(() => {
+ expect(props.buffer.setText).not.toHaveBeenCalled();
+ });
+ unmount();
+ });
+
+ it('should call setBannerVisible(false) when clear screen key is pressed', async () => {
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\x0C'); // Ctrl+L
+ });
+
+ await waitFor(() => {
+ expect(props.setBannerVisible).toHaveBeenCalledWith(false);
+ });
+ unmount();
+ });
+
+ describe('cursor-based completion trigger', () => {
+ it.each([
+ {
+ name: 'should trigger completion when cursor is after @ without spaces',
+ text: '@src/components',
+ cursor: [0, 15],
+ showSuggestions: true,
+ },
+ {
+ name: 'should trigger completion when cursor is after / without spaces',
+ text: '/memory',
+ cursor: [0, 7],
+ showSuggestions: true,
+ },
+ {
+ name: 'should NOT trigger completion when cursor is after space following @',
+ text: '@src/file.ts hello',
+ cursor: [0, 18],
+ showSuggestions: false,
+ },
+ {
+ name: 'should NOT trigger completion when cursor is after space following /',
+ text: '/memory list',
+ cursor: [0, 12],
+ showSuggestions: false,
+ },
+ {
+ name: 'should NOT trigger completion when cursor is not after @ or /',
+ text: 'hello world',
+ cursor: [0, 5],
+ showSuggestions: false,
+ },
+ {
+ name: 'should handle multiline text correctly',
+ text: 'first line\n/memory',
+ cursor: [1, 7],
+ showSuggestions: false,
+ },
+ {
+ name: 'should handle Unicode characters (emojis) correctly in paths',
+ text: '@src/file👍.txt',
+ cursor: [0, 14],
+ showSuggestions: true,
+ },
+ {
+ name: 'should handle Unicode characters with spaces after them',
+ text: '@src/file👍.txt hello',
+ cursor: [0, 20],
+ showSuggestions: false,
+ },
+ {
+ name: 'should handle escaped spaces in paths correctly',
+ text: '@src/my\\ file.txt',
+ cursor: [0, 16],
+ showSuggestions: true,
+ },
+ {
+ name: 'should NOT trigger completion after unescaped space following escaped space',
+ text: '@path/my\\ file.txt hello',
+ cursor: [0, 24],
+ showSuggestions: false,
+ },
+ {
+ name: 'should handle multiple escaped spaces in paths',
+ text: '@docs/my\\ long\\ file\\ name.md',
+ cursor: [0, 29],
+ showSuggestions: true,
+ },
+ {
+ name: 'should handle escaped spaces in slash commands',
+ text: '/memory\\ test',
+ cursor: [0, 13],
+ showSuggestions: true,
+ },
+ {
+ name: 'should handle Unicode characters with escaped spaces',
+ text: `@${path.join('files', 'emoji\\ 👍\\ test.txt')}`,
+ cursor: [0, 25],
+ showSuggestions: true,
+ },
+ ])('$name', async ({ text, cursor, showSuggestions }) => {
+ mockBuffer.text = text;
+ mockBuffer.lines = text.split('\n');
+ mockBuffer.cursor = cursor as [number, number];
+
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions,
+ suggestions: showSuggestions
+ ? [{ label: 'suggestion', value: 'suggestion' }]
+ : [],
+ });
+
+ const { unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await waitFor(() => {
+ expect(mockedUseCommandCompletion).toHaveBeenCalledWith({
+ buffer: mockBuffer,
+ cwd: path.join('test', 'project', 'src'),
+ slashCommands: mockSlashCommands,
+ commandContext: mockCommandContext,
+ reverseSearchActive: false,
+ shellModeActive: false,
+ config: expect.any(Object),
+ active: expect.anything(),
+ });
+ });
+
+ unmount();
+ });
+ });
+
+ describe('vim mode', () => {
+ it.each([
+ {
+ name: 'should not call buffer.handleInput when vim handles input',
+ vimHandled: true,
+ expectBufferHandleInput: false,
+ },
+ {
+ name: 'should call buffer.handleInput when vim does not handle input',
+ vimHandled: false,
+ expectBufferHandleInput: true,
+ },
+ {
+ name: 'should call handleInput when vim mode is disabled',
+ vimHandled: false,
+ expectBufferHandleInput: true,
+ },
+ ])('$name', async ({ vimHandled, expectBufferHandleInput }) => {
+ props.vimHandleInput = vi.fn().mockReturnValue(vimHandled);
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => stdin.write('i'));
+ await waitFor(() => {
+ expect(props.vimHandleInput).toHaveBeenCalled();
+ if (expectBufferHandleInput) {
+ expect(mockBuffer.handleInput).toHaveBeenCalled();
+ } else {
+ expect(mockBuffer.handleInput).not.toHaveBeenCalled();
+ }
+ });
+ unmount();
+ });
+ });
+
+ describe('unfocused paste', () => {
+ it('should handle bracketed paste when not focused', async () => {
+ props.focus = false;
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\x1B[200~pasted text\x1B[201~');
+ });
+ await waitFor(() => {
+ expect(mockBuffer.handleInput).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'paste',
+ sequence: 'pasted text',
+ }),
+ );
+ });
+ unmount();
+ });
+
+ it('should ignore regular keypresses when not focused', async () => {
+ props.focus = false;
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('a');
+ });
+ await waitFor(() => {});
+
+ expect(mockBuffer.handleInput).not.toHaveBeenCalled();
+ unmount();
+ });
+ });
+
+ describe('Highlighting and Cursor Display', () => {
+ describe('single-line scenarios', () => {
+ it.each([
+ {
+ name: 'mid-word',
+ text: 'hello world',
+ visualCursor: [0, 3],
+ },
+ {
+ name: 'at the beginning of the line',
+ text: 'hello',
+ visualCursor: [0, 0],
+ },
+ {
+ name: 'at the end of the line',
+ text: 'hello',
+ visualCursor: [0, 5],
+ },
+ {
+ name: 'on a highlighted token',
+ text: 'run @path/to/file',
+ visualCursor: [0, 9],
+ },
+ {
+ name: 'for multi-byte unicode characters',
+ text: 'hello 👍 world',
+ visualCursor: [0, 6],
+ },
+ {
+ name: 'after multi-byte unicode characters',
+ text: '👍A',
+ visualCursor: [0, 1],
+ },
+ {
+ name: 'at the end of a line with unicode characters',
+ text: 'hello 👍',
+ visualCursor: [0, 8],
+ },
+ {
+ name: 'at the end of a short line with unicode characters',
+ text: '👍',
+ visualCursor: [0, 1],
+ },
+ {
+ name: 'on an empty line',
+ text: '',
+ visualCursor: [0, 0],
+ },
+ {
+ name: 'on a space between words',
+ text: 'hello world',
+ visualCursor: [0, 5],
+ },
+ ])(
+ 'should display cursor correctly $name',
+ async ({ text, visualCursor }) => {
+ mockBuffer.text = text;
+ mockBuffer.lines = [text];
+ mockBuffer.allVisualLines = [text];
+ mockBuffer.viewportVisualLines = [text];
+ mockBuffer.visualCursor = visualCursor as [number, number];
+ props.config.getUseBackgroundColor = () => false;
+
+ const renderResult = await renderWithProviders(
+ ,
+ );
+ await renderResult.waitUntilReady();
+ await expect(renderResult).toMatchSvgSnapshot();
+ renderResult.unmount();
+ },
+ );
+ });
+
+ describe('multi-line scenarios', () => {
+ it.each([
+ {
+ name: 'in the middle of a line',
+ text: 'first line\nsecond line\nthird line',
+ visualCursor: [1, 3],
+ visualToLogicalMap: [
+ [0, 0],
+ [1, 0],
+ [2, 0],
+ ],
+ },
+ {
+ name: 'at the beginning of a line',
+ text: 'first line\nsecond line',
+ visualCursor: [1, 0],
+ visualToLogicalMap: [
+ [0, 0],
+ [1, 0],
+ ],
+ },
+ {
+ name: 'at the end of a line',
+ text: 'first line\nsecond line',
+ visualCursor: [0, 10],
+ visualToLogicalMap: [
+ [0, 0],
+ [1, 0],
+ ],
+ },
+ ])(
+ 'should display cursor correctly $name in a multiline block',
+ async ({ text, visualCursor, visualToLogicalMap }) => {
+ mockBuffer.text = text;
+ mockBuffer.lines = text.split('\n');
+ mockBuffer.allVisualLines = text.split('\n');
+ mockBuffer.viewportVisualLines = text.split('\n');
+ mockBuffer.visualCursor = visualCursor as [number, number];
+ mockBuffer.visualToLogicalMap = visualToLogicalMap as Array<
+ [number, number]
+ >;
+ props.config.getUseBackgroundColor = () => false;
+
+ const renderResult = await renderWithProviders(
+ ,
+ );
+ await renderResult.waitUntilReady();
+ await expect(renderResult).toMatchSvgSnapshot();
+ renderResult.unmount();
+ },
+ );
+
+ it('should display cursor on a blank line in a multiline block', async () => {
+ const text = 'first line\n\nthird line';
+ mockBuffer.text = text;
+ mockBuffer.lines = text.split('\n');
+ mockBuffer.allVisualLines = text.split('\n');
+ mockBuffer.viewportVisualLines = text.split('\n');
+ mockBuffer.visualCursor = [1, 0]; // cursor on the blank line
+ mockBuffer.visualToLogicalMap = [
+ [0, 0],
+ [1, 0],
+ [2, 0],
+ ];
+ props.config.getUseBackgroundColor = () => false;
+
+ const renderResult = await renderWithProviders(
+ ,
+ );
+ await renderResult.waitUntilReady();
+ await expect(renderResult).toMatchSvgSnapshot();
+ renderResult.unmount();
+ });
+ });
+ });
+
+ describe('scrolling large inputs', () => {
+ it('should correctly render scrolling down and up for large inputs', async () => {
+ const lines = Array.from({ length: 50 }).map((_, i) => `testline ${i}`);
+
+ // Since we need to test how the React component tree responds to TextBuffer state changes,
+ // we must provide a fake TextBuffer implementation that triggers re-renders like the real one.
+
+ const TestWrapper = () => {
+ const [bufferState, setBufferState] = useState({
+ text: lines.join('\n'),
+ lines,
+ allVisualLines: lines,
+ viewportVisualLines: lines.slice(0, 10),
+ visualToLogicalMap: lines.map((_, i) => [i, 0]),
+ visualCursor: [0, 0] as [number, number],
+ visualScrollRow: 0,
+ viewportHeight: 10,
+ });
+
+ const fakeBuffer = {
+ ...mockBuffer,
+ ...bufferState,
+ handleInput: vi.fn().mockImplementation((key) => {
+ let newRow = bufferState.visualCursor[0];
+ let newScroll = bufferState.visualScrollRow;
+ if (key.name === 'down') {
+ newRow = Math.min(49, newRow + 1);
+ if (newRow >= newScroll + 10) newScroll++;
+ } else if (key.name === 'up') {
+ newRow = Math.max(0, newRow - 1);
+ if (newRow < newScroll) newScroll--;
+ }
+ setBufferState({
+ ...bufferState,
+ visualCursor: [newRow, 0],
+ visualScrollRow: newScroll,
+ viewportVisualLines: lines.slice(newScroll, newScroll + 10),
+ });
+ return true;
+ }),
+ } as unknown as TextBuffer;
+
+ const inputState = {
+ buffer: fakeBuffer,
+ userMessages: [],
+ shellModeActive: false,
+ showEscapePrompt: false,
+ copyModeEnabled: false,
+ inputWidth: 80,
+ suggestionsWidth: 80,
+ };
+
+ return (
+
+
+
+ );
+ };
+
+ const { stdout, unmount, stdin } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ // Verify initial render
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toContain('testline 0');
+ expect(stdout.lastFrame()).not.toContain('testline 49');
+ });
+
+ // Move cursor to bottom
+ for (let i = 0; i < 49; i++) {
+ act(() => {
+ stdin.write('\x1b[B'); // Arrow Down
+ });
+ }
+
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toContain('testline 49');
+ expect(stdout.lastFrame()).not.toContain('testline 0');
+ });
+
+ // Move cursor back to top
+ for (let i = 0; i < 49; i++) {
+ act(() => {
+ stdin.write('\x1b[A'); // Arrow Up
+ });
+ }
+
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toContain('testline 0');
+ expect(stdout.lastFrame()).not.toContain('testline 49');
+ });
+
+ unmount();
+ });
+ });
+
+ describe('multiline rendering', () => {
+ it('should correctly render multiline input including blank lines', async () => {
+ const text = 'hello\n\nworld';
+ mockBuffer.text = text;
+ mockBuffer.lines = text.split('\n');
+ mockBuffer.allVisualLines = text.split('\n');
+ mockBuffer.viewportVisualLines = text.split('\n');
+ mockBuffer.allVisualLines = text.split('\n');
+ mockBuffer.visualCursor = [2, 5]; // cursor at the end of "world"
+ // Provide a visual-to-logical mapping for each visual line
+ mockBuffer.visualToLogicalMap = [
+ [0, 0],
+ [1, 0],
+ [2, 0],
+ ];
+ props.config.getUseBackgroundColor = () => false;
+
+ const renderResult = await renderWithProviders(
+ ,
+ );
+
+ await renderResult.waitUntilReady();
+ await expect(renderResult).toMatchSvgSnapshot();
+
+ renderResult.unmount();
+ });
+ });
+
+ describe('multiline paste', () => {
+ it.each([
+ {
+ description: 'with \n newlines',
+ pastedText: 'This \n is \n a \n multiline \n paste.',
+ },
+ {
+ description: 'with extra slashes before \n newlines',
+ pastedText: 'This \\\n is \\\n a \\\n multiline \\\n paste.',
+ },
+ {
+ description: 'with \r\n newlines',
+ pastedText: 'This\r\nis\r\na\r\nmultiline\r\npaste.',
+ },
+ ])('should handle multiline paste $description', async ({ pastedText }) => {
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ // Simulate a bracketed paste event from the terminal
+ await act(async () => {
+ stdin.write(`\x1b[200~${pastedText}\x1b[201~`);
+ });
+ await waitFor(() => {
+ // Verify that the buffer's handleInput was called once with the full text
+ expect(props.buffer.handleInput).toHaveBeenCalledTimes(1);
+ expect(props.buffer.handleInput).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'paste',
+ sequence: pastedText,
+ }),
+ );
+ });
+
+ unmount();
+ });
+ });
+
+ describe('large paste placeholder', () => {
+ it('should handle large clipboard paste (lines > 5) by calling buffer.insert', async () => {
+ vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
+ const largeText = '1\n2\n3\n4\n5\n6';
+ vi.mocked(clipboardy.read).mockResolvedValue(largeText);
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\x16'); // Ctrl+V
+ });
+
+ await waitFor(() => {
+ expect(mockBuffer.insert).toHaveBeenCalledWith(
+ largeText,
+ expect.objectContaining({ paste: true }),
+ );
+ });
+
+ unmount();
+ });
+
+ it('should handle large clipboard paste (chars > 500) by calling buffer.insert', async () => {
+ vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
+ const largeText = 'a'.repeat(501);
+ vi.mocked(clipboardy.read).mockResolvedValue(largeText);
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\x16'); // Ctrl+V
+ });
+
+ await waitFor(() => {
+ expect(mockBuffer.insert).toHaveBeenCalledWith(
+ largeText,
+ expect.objectContaining({ paste: true }),
+ );
+ });
+
+ unmount();
+ });
+
+ it('should handle normal clipboard paste by calling buffer.insert', async () => {
+ vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
+ const smallText = 'hello world';
+ vi.mocked(clipboardy.read).mockResolvedValue(smallText);
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\x16'); // Ctrl+V
+ });
+
+ await waitFor(() => {
+ expect(mockBuffer.insert).toHaveBeenCalledWith(
+ smallText,
+ expect.objectContaining({ paste: true }),
+ );
+ });
+
+ unmount();
+ });
+
+ it('should replace placeholder with actual content on submit', async () => {
+ // Setup buffer to have the placeholder
+ const largeText = '1\n2\n3\n4\n5\n6';
+ const id = '[Pasted Text: 6 lines]';
+ mockBuffer.text = `Check this: ${id}`;
+ mockBuffer.pastedContent = { [id]: largeText };
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\r'); // Enter
+ });
+
+ await waitFor(() => {
+ expect(props.onSubmit).toHaveBeenCalledWith(`Check this: ${largeText}`);
+ });
+
+ unmount();
+ });
+ });
+
+ describe('paste auto-submission protection', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ mockedUseKittyKeyboardProtocol.mockReturnValue({
+ enabled: false,
+ checking: false,
+ });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it('should prevent auto-submission immediately after an unsafe paste', async () => {
+ // isTerminalPasteTrusted will be false due to beforeEach setup.
+ props.buffer.text = 'some command';
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ // Simulate a paste operation (this should set the paste protection)
+ await act(async () => {
+ stdin.write(`\x1b[200~pasted content\x1b[201~`);
+ });
+
+ // Simulate an Enter key press immediately after paste
+ await act(async () => {
+ stdin.write('\r');
+ });
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ // Verify that onSubmit was NOT called due to recent paste protection
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ // It should call newline() instead
+ expect(props.buffer.newline).toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should prevent perfect match auto-submission immediately after an unsafe paste', async () => {
+ // isTerminalPasteTrusted will be false due to beforeEach setup.
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ isPerfectMatch: true,
+ completionMode: CompletionMode.AT,
+ });
+ props.buffer.text = '@file.txt';
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ // Simulate an unsafe paste of a perfect match
+ await act(async () => {
+ stdin.write(`\x1b[200~@file.txt\x1b[201~`);
+ });
+
+ // Simulate an Enter key press immediately after paste
+ await act(async () => {
+ stdin.write('\r');
+ });
+
+ // Verify that onSubmit was NOT called due to recent paste protection
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ // It should call newline() instead
+ expect(props.buffer.newline).toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should allow submission after unsafe paste protection timeout', async () => {
+ // isTerminalPasteTrusted will be false due to beforeEach setup.
+ props.buffer.text = 'pasted text';
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ // Simulate a paste operation (this sets the protection)
+ await act(async () => {
+ stdin.write('\x1b[200~pasted text\x1b[201~');
+ });
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ // Advance timers past the protection timeout
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(50);
+ });
+
+ // Now Enter should work normally
+ await act(async () => {
+ stdin.write('\r');
+ });
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ expect(props.onSubmit).toHaveBeenCalledWith('pasted text');
+ expect(props.buffer.newline).not.toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it.each([
+ {
+ name: 'kitty',
+ setup: () =>
+ mockedUseKittyKeyboardProtocol.mockReturnValue({
+ enabled: true,
+ checking: false,
+ }),
+ },
+ ])(
+ 'should allow immediate submission for a trusted paste ($name)',
+ async ({ setup }) => {
+ setup();
+ props.buffer.text = 'pasted command';
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ // Simulate a paste operation
+ await act(async () => {
+ stdin.write('\x1b[200~some pasted stuff\x1b[201~');
+ });
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ // Simulate an Enter key press immediately after paste
+ await act(async () => {
+ stdin.write('\r');
+ });
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ // Verify that onSubmit was called
+ expect(props.onSubmit).toHaveBeenCalledWith('pasted command');
+ unmount();
+ },
+ );
+
+ it('should not interfere with normal Enter key submission when no recent paste', async () => {
+ // Set up buffer with text before rendering to ensure submission works
+ props.buffer.text = 'normal command';
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ // Press Enter without any recent paste
+ await act(async () => {
+ stdin.write('\r');
+ });
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ // Verify that onSubmit was called normally
+ expect(props.onSubmit).toHaveBeenCalledWith('normal command');
+
+ unmount();
+ });
+ });
+
+ describe('enhanced input UX - keyboard shortcuts', () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => vi.useRealTimers());
+
+ it('should clear buffer on Ctrl-C', async () => {
+ const onEscapePromptChange = vi.fn();
+ props.onEscapePromptChange = onEscapePromptChange;
+ props.buffer.setText('text to clear');
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\x03');
+ vi.advanceTimersByTime(100);
+
+ expect(props.buffer.setText).toHaveBeenCalledWith('');
+ expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
+ });
+ unmount();
+ });
+
+ it('should submit /rewind on double ESC when buffer is empty', async () => {
+ const onEscapePromptChange = vi.fn();
+ props.onEscapePromptChange = onEscapePromptChange;
+ props.buffer.setText('');
+ vi.mocked(props.buffer.setText).mockClear();
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiState: {
+ history: [{ id: 1, type: 'user', text: 'test' }],
+ },
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\x1B\x1B');
+ vi.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(props.onSubmit).toHaveBeenCalledWith('/rewind');
+ });
+ unmount();
+ });
+
+ it('should clear the buffer on esc esc if it has text', async () => {
+ const onEscapePromptChange = vi.fn();
+ props.onEscapePromptChange = onEscapePromptChange;
+ props.buffer.setText('some text');
+ vi.mocked(props.buffer.setText).mockClear();
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\x1B\x1B');
+ vi.advanceTimersByTime(100);
+
+ expect(props.buffer.setText).toHaveBeenCalledWith('');
+ expect(props.onSubmit).not.toHaveBeenCalledWith('/rewind');
+ });
+ unmount();
+ });
+
+ it('should reset escape state on any non-ESC key', async () => {
+ const onEscapePromptChange = vi.fn();
+ props.onEscapePromptChange = onEscapePromptChange;
+ props.buffer.setText('some text');
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\x1B');
+ await waitFor(() => {
+ expect(onEscapePromptChange).toHaveBeenCalledWith(false);
+ });
+ });
+
+ await act(async () => {
+ stdin.write('a');
+ await waitFor(() => {
+ expect(onEscapePromptChange).toHaveBeenCalledWith(false);
+ });
+ });
+ unmount();
+ });
+
+ it('should handle ESC in shell mode by disabling shell mode', async () => {
+ props.shellModeActive = true;
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\x1B');
+ vi.advanceTimersByTime(100);
+
+ expect(props.setShellModeActive).toHaveBeenCalledWith(false);
+ });
+ unmount();
+ });
+
+ it('should not propagate ESC to global cancellation handler when shell mode is active (responding)', async () => {
+ props.shellModeActive = true;
+ props.streamingState = StreamingState.Responding;
+ const onGlobalEscape = vi.fn();
+
+ const { stdin, unmount } = await renderWithProviders(
+ <>
+
+
+ >,
+ );
+
+ await act(async () => {
+ stdin.write('\x1B');
+ vi.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(props.setShellModeActive).toHaveBeenCalledWith(false);
+ });
+ expect(onGlobalEscape).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should allow ESC to reach global cancellation handler when responding and no overlay is active', async () => {
+ props.shellModeActive = false;
+ props.streamingState = StreamingState.Responding;
+ const onGlobalEscape = vi.fn();
+
+ const { stdin, unmount } = await renderWithProviders(
+ <>
+
+
+ >,
+ );
+
+ await act(async () => {
+ stdin.write('\x1B');
+ vi.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(onGlobalEscape).toHaveBeenCalledTimes(1);
+ });
+ expect(props.setShellModeActive).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should handle ESC when completion suggestions are showing', async () => {
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: true,
+ suggestions: [{ label: 'suggestion', value: 'suggestion' }],
+ });
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\x1B');
+
+ vi.advanceTimersByTime(100);
+ expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
+ });
+ unmount();
+ });
+
+ it('should not call onEscapePromptChange when not provided', async () => {
+ props.onEscapePromptChange = undefined;
+ props.buffer.setText('some text');
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ await act(async () => {
+ stdin.write('\x1B');
+ });
+ await act(async () => {
+ await vi.runAllTimersAsync();
+ });
+
+ unmount();
+ });
+
+ it('should not interfere with existing keyboard shortcuts', async () => {
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\x0C');
+ });
+ await waitFor(() => expect(props.onClearScreen).toHaveBeenCalled());
+
+ await act(async () => {
+ stdin.write('\x01');
+ });
+ await waitFor(() =>
+ expect(props.buffer.move).toHaveBeenCalledWith('home'),
+ );
+ unmount();
+ });
+ });
+
+ describe('reverse search', () => {
+ beforeEach(async () => {
+ props.shellModeActive = true;
+
+ vi.mocked(useShellHistory).mockReturnValue({
+ history: ['echo hello', 'echo world', 'ls'],
+ getPreviousCommand: vi.fn(),
+ getNextCommand: vi.fn(),
+ addCommandToHistory: vi.fn(),
+ resetHistoryPosition: vi.fn(),
+ });
+ });
+
+ it('invokes reverse search on Ctrl+R', async () => {
+ // Mock the reverse search completion to return suggestions
+ mockedUseReverseSearchCompletion.mockReturnValue({
+ ...mockReverseSearchCompletion,
+ suggestions: [
+ { label: 'echo hello', value: 'echo hello' },
+ { label: 'echo world', value: 'echo world' },
+ { label: 'ls', value: 'ls' },
+ ],
+ showSuggestions: true,
+ activeSuggestionIndex: 0,
+ });
+
+ const { stdin, stdout, unmount } = await renderWithProviders(
+ ,
+ );
+
+ // Trigger reverse search with Ctrl+R
+ await act(async () => {
+ stdin.write('\x12');
+ });
+
+ await waitFor(() => {
+ const frame = stdout.lastFrameRaw();
+ expect(frame).toContain('(r:)');
+ expect(frame).toContain('echo hello');
+ expect(frame).toContain('echo world');
+ expect(frame).toContain('ls');
+ });
+
+ unmount();
+ });
+
+ it.each([
+ { name: 'standard', escapeSequence: '\x1B' },
+ { name: 'kitty', escapeSequence: '\u001b[27u' },
+ ])(
+ 'resets reverse search state on Escape ($name)',
+ async ({ escapeSequence }) => {
+ const { stdin, stdout, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\x12');
+ });
+
+ // Wait for reverse search to be active
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toContain('(r:)');
+ });
+
+ await act(async () => {
+ stdin.write(escapeSequence);
+ });
+
+ await waitFor(() => {
+ expect(stdout.lastFrame()).not.toContain('(r:)');
+ expect(stdout.lastFrame()).not.toContain('echo hello');
+ });
+
+ unmount();
+ },
+ );
+
+ it('completes the highlighted entry on Tab and exits reverse-search', async () => {
+ // Mock the reverse search completion
+ const mockHandleAutocomplete = vi.fn(() => {
+ props.buffer.setText('echo hello');
+ });
+
+ mockedUseReverseSearchCompletion.mockImplementation(
+ (buffer, shellHistory, reverseSearchActive) => ({
+ ...mockReverseSearchCompletion,
+ suggestions: reverseSearchActive
+ ? [
+ { label: 'echo hello', value: 'echo hello' },
+ { label: 'echo world', value: 'echo world' },
+ { label: 'ls', value: 'ls' },
+ ]
+ : [],
+ showSuggestions: reverseSearchActive,
+ activeSuggestionIndex: reverseSearchActive ? 0 : -1,
+ handleAutocomplete: mockHandleAutocomplete,
+ }),
+ );
+
+ const { stdin, stdout, unmount } = await renderWithProviders(
+ ,
+ );
+
+ // Enter reverse search mode with Ctrl+R
+ await act(async () => {
+ stdin.write('\x12');
+ });
+
+ // Verify reverse search is active
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toContain('(r:)');
+ });
+
+ // Press Tab to complete the highlighted entry
+ await act(async () => {
+ stdin.write('\t');
+ });
+ await waitFor(() => {
+ expect(mockHandleAutocomplete).toHaveBeenCalledWith(0);
+ expect(props.buffer.setText).toHaveBeenCalledWith('echo hello');
+ });
+ unmount();
+ }, 15000);
+
+ it('should NOT autocomplete on Shift+Tab in reverse search', async () => {
+ const mockHandleAutocomplete = vi.fn();
+
+ mockedUseReverseSearchCompletion.mockReturnValue({
+ ...mockReverseSearchCompletion,
+ suggestions: [{ label: 'echo hello', value: 'echo hello' }],
+ showSuggestions: true,
+ activeSuggestionIndex: 0,
+ handleAutocomplete: mockHandleAutocomplete,
+ });
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\x12'); // Ctrl+R
+ });
+
+ await act(async () => {
+ stdin.write('\x1b[Z'); // Shift+Tab
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ expect(mockHandleAutocomplete).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('submits the highlighted entry on Enter and exits reverse-search', async () => {
+ // Mock the reverse search completion to return suggestions
+ mockedUseReverseSearchCompletion.mockReturnValue({
+ ...mockReverseSearchCompletion,
+ suggestions: [
+ { label: 'echo hello', value: 'echo hello' },
+ { label: 'echo world', value: 'echo world' },
+ { label: 'ls', value: 'ls' },
+ ],
+ showSuggestions: true,
+ activeSuggestionIndex: 0,
+ });
+
+ const { stdin, stdout, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\x12');
+ });
+
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toContain('(r:)');
+ });
+
+ await act(async () => {
+ stdin.write('\r');
+ });
+
+ await waitFor(() => {
+ expect(stdout.lastFrame()).not.toContain('(r:)');
+ });
+
+ expect(props.onSubmit).toHaveBeenCalledWith('echo hello');
+ unmount();
+ });
+
+ it('should restore text and cursor position after reverse search"', async () => {
+ const initialText = 'initial text';
+ const initialCursor: [number, number] = [0, 3];
+
+ props.buffer.setText(initialText);
+ props.buffer.cursor = initialCursor;
+
+ // Mock the reverse search completion to be active and then reset
+ mockedUseReverseSearchCompletion.mockImplementation(
+ (buffer, shellHistory, reverseSearchActiveFromInputPrompt) => ({
+ ...mockReverseSearchCompletion,
+ suggestions: reverseSearchActiveFromInputPrompt
+ ? [{ label: 'history item', value: 'history item' }]
+ : [],
+ showSuggestions: reverseSearchActiveFromInputPrompt,
+ }),
+ );
+
+ const { stdin, stdout, unmount } = await renderWithProviders(
+ ,
+ );
+
+ // reverse search with Ctrl+R
+ await act(async () => {
+ stdin.write('\x12');
+ });
+
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toContain('(r:)');
+ });
+
+ // Press kitty escape key
+ await act(async () => {
+ stdin.write('\u001b[27u');
+ });
+
+ await waitFor(() => {
+ expect(stdout.lastFrame()).not.toContain('(r:)');
+ expect(props.buffer.text).toBe(initialText);
+ expect(props.buffer.cursor).toEqual(initialCursor);
+ });
+
+ unmount();
+ });
+ });
+
+ describe('Ctrl+E keyboard shortcut', () => {
+ it('should move cursor to end of current line in multiline input', async () => {
+ props.buffer.text = 'line 1\nline 2\nline 3';
+ props.buffer.cursor = [1, 2];
+ props.buffer.lines = ['line 1', 'line 2', 'line 3'];
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\x05'); // Ctrl+E
+ });
+ await waitFor(() => {
+ expect(props.buffer.move).toHaveBeenCalledWith('end');
+ });
+ expect(props.buffer.moveToOffset).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should move cursor to end of current line for single line input', async () => {
+ props.buffer.text = 'single line text';
+ props.buffer.cursor = [0, 5];
+ props.buffer.lines = ['single line text'];
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\x05'); // Ctrl+E
+ });
+ await waitFor(() => {
+ expect(props.buffer.move).toHaveBeenCalledWith('end');
+ });
+ expect(props.buffer.moveToOffset).not.toHaveBeenCalled();
+ unmount();
+ });
+ });
+
+ describe('command search (Ctrl+R when not in shell)', () => {
+ it('enters command search on Ctrl+R and shows suggestions', async () => {
+ props.shellModeActive = false;
+
+ vi.mocked(useReverseSearchCompletion).mockImplementation(
+ (buffer, data, isActive) => ({
+ ...mockReverseSearchCompletion,
+ suggestions: isActive
+ ? [
+ { label: 'git commit -m "msg"', value: 'git commit -m "msg"' },
+ { label: 'git push', value: 'git push' },
+ ]
+ : [],
+ showSuggestions: !!isActive,
+ activeSuggestionIndex: isActive ? 0 : -1,
+ }),
+ );
+
+ const { stdin, stdout, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\x12'); // Ctrl+R
+ });
+
+ await waitFor(() => {
+ const frame = stdout.lastFrameRaw() ?? '';
+ expect(frame).toContain('(r:)');
+ expect(frame).toContain('git commit');
+ expect(frame).toContain('git push');
+ });
+ unmount();
+ });
+
+ it('expands and collapses long suggestion via Right/Left arrows', async () => {
+ props.shellModeActive = false;
+ const longValue = 'l'.repeat(200);
+
+ vi.mocked(useReverseSearchCompletion).mockReturnValue({
+ ...mockReverseSearchCompletion,
+ suggestions: [{ label: longValue, value: longValue, matchedIndex: 0 }],
+ showSuggestions: true,
+ activeSuggestionIndex: 0,
+ visibleStartIndex: 0,
+ isLoadingSuggestions: false,
+ });
+
+ const { stdin, stdout, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\x12');
+ });
+ await waitFor(() => {
+ expect(clean(stdout.lastFrame())).toContain('→');
+ });
+
+ await act(async () => {
+ stdin.write('\u001B[C');
+ });
+ await waitFor(() => {
+ expect(clean(stdout.lastFrame())).toContain('←');
+ });
+ expect(stdout.lastFrame()).toMatchSnapshot(
+ 'command-search-render-expanded-match',
+ );
+
+ await act(async () => {
+ stdin.write('\u001B[D');
+ });
+ await waitFor(() => {
+ expect(clean(stdout.lastFrame())).toContain('→');
+ });
+ expect(stdout.lastFrame()).toMatchSnapshot(
+ 'command-search-render-collapsed-match',
+ );
+ unmount();
+ });
+
+ it('renders match window and expanded view (snapshots)', async () => {
+ props.shellModeActive = false;
+ props.buffer.setText('commit');
+
+ const label = 'git commit -m "feat: add search" in src/app';
+ const matchedIndex = label.indexOf('commit');
+
+ vi.mocked(useReverseSearchCompletion).mockReturnValue({
+ ...mockReverseSearchCompletion,
+ suggestions: [{ label, value: label, matchedIndex }],
+ showSuggestions: true,
+ activeSuggestionIndex: 0,
+ visibleStartIndex: 0,
+ isLoadingSuggestions: false,
+ });
+
+ const { stdin, stdout, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\x12');
+ });
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toContain('(r:)');
+ });
+ expect(stdout.lastFrame()).toMatchSnapshot(
+ 'command-search-render-collapsed-match',
+ );
+
+ await act(async () => {
+ stdin.write('\u001B[C');
+ });
+ await waitFor(() => {
+ // Just wait for any update to ensure it is stable.
+ // We could also wait for specific text if we knew it.
+ expect(stdout.lastFrame()).toContain('(r:)');
+ });
+ expect(stdout.lastFrame()).toMatchSnapshot(
+ 'command-search-render-expanded-match',
+ );
+ unmount();
+ });
+
+ it('does not show expand/collapse indicator for short suggestions', async () => {
+ props.shellModeActive = false;
+ const shortValue = 'echo hello';
+
+ vi.mocked(useReverseSearchCompletion).mockReturnValue({
+ ...mockReverseSearchCompletion,
+ suggestions: [{ label: shortValue, value: shortValue }],
+ showSuggestions: true,
+ activeSuggestionIndex: 0,
+ visibleStartIndex: 0,
+ isLoadingSuggestions: false,
+ });
+
+ const { stdin, stdout, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\x12');
+ });
+ await waitFor(() => {
+ const frame = clean(stdout.lastFrame());
+ // Ensure it rendered the search mode
+ expect(frame).toContain('(r:)');
+ expect(frame).not.toContain('→');
+ expect(frame).not.toContain('←');
+ });
+ unmount();
+ });
+
+ it('ensures Ctrl+R search results are prioritized newest-to-oldest by reversing userMessages', async () => {
+ props.shellModeActive = false;
+ props.userMessages = ['oldest', 'middle', 'newest'];
+
+ await renderWithProviders();
+
+ const calls = vi.mocked(useReverseSearchCompletion).mock.calls;
+ const commandSearchCall = calls.find(
+ (call) =>
+ call[1] === props.userMessages ||
+ (Array.isArray(call[1]) && call[1][0] === 'newest'),
+ );
+
+ expect(commandSearchCall).toBeDefined();
+ expect(commandSearchCall![1]).toEqual(['newest', 'middle', 'oldest']);
+ });
+ });
+
+ describe('Tab clean UI toggle', () => {
+ it.each([
+ {
+ name: 'should toggle clean UI details on double-Tab when no suggestions or ghost text',
+ showSuggestions: false,
+ ghostText: '',
+ suggestions: [],
+ expectedUiToggle: true,
+ },
+ {
+ name: 'should accept ghost text and NOT toggle clean UI details on Tab',
+ showSuggestions: false,
+ ghostText: 'ghost text',
+ suggestions: [],
+ expectedUiToggle: false,
+ expectedAcceptCall: true,
+ },
+ {
+ name: 'should NOT toggle clean UI details on Tab when suggestions are present',
+ showSuggestions: true,
+ ghostText: '',
+ suggestions: [{ label: 'test', value: 'test' }],
+ expectedUiToggle: false,
+ },
+ ])(
+ '$name',
+ async ({
+ showSuggestions,
+ ghostText,
+ suggestions,
+ expectedUiToggle,
+ expectedAcceptCall,
+ }) => {
+ const mockAccept = vi.fn();
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions,
+ suggestions,
+ promptCompletion: {
+ text: ghostText,
+ accept: mockAccept,
+ clear: vi.fn(),
+ isLoading: false,
+ isActive: ghostText !== '',
+ markSelected: vi.fn(),
+ },
+ });
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ uiState: {},
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\t');
+ if (expectedUiToggle) {
+ stdin.write('\t');
+ }
+ });
+
+ await waitFor(() => {
+ if (expectedUiToggle) {
+ expect(uiActions.toggleCleanUiDetailsVisible).toHaveBeenCalled();
+ } else {
+ expect(
+ uiActions.toggleCleanUiDetailsVisible,
+ ).not.toHaveBeenCalled();
+ }
+
+ if (expectedAcceptCall) {
+ expect(mockAccept).toHaveBeenCalled();
+ }
+ });
+ unmount();
+ },
+ );
+
+ it('should NOT accept ghost text on Shift+Tab', async () => {
+ const mockAccept = vi.fn();
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: false,
+ suggestions: [],
+ promptCompletion: {
+ text: 'ghost text',
+ accept: mockAccept,
+ clear: vi.fn(),
+ isLoading: false,
+ isActive: true,
+ markSelected: vi.fn(),
+ },
+ });
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\x1b[Z'); // Shift+Tab
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ expect(mockAccept).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should not reveal clean UI details on Shift+Tab when hidden', async () => {
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: false,
+ suggestions: [],
+ promptCompletion: {
+ text: '',
+ accept: vi.fn(),
+ clear: vi.fn(),
+ isLoading: false,
+ isActive: false,
+ markSelected: vi.fn(),
+ },
+ });
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ uiState: { activePtyId: 1, cleanUiDetailsVisible: false },
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\x1b[Z');
+ });
+
+ await waitFor(() => {
+ expect(
+ uiActions.revealCleanUiDetailsTemporarily,
+ ).not.toHaveBeenCalled();
+ });
+ unmount();
+ });
+
+ it('should toggle clean UI details on double-Tab by default', async () => {
+ mockedUseCommandCompletion.mockReturnValue({
+ ...mockCommandCompletion,
+ showSuggestions: false,
+ suggestions: [],
+ promptCompletion: {
+ text: '',
+ accept: vi.fn(),
+ clear: vi.fn(),
+ isLoading: false,
+ isActive: false,
+ markSelected: vi.fn(),
+ },
+ });
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ uiState: {},
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\t');
+ stdin.write('\t');
+ });
+
+ await waitFor(() => {
+ expect(uiActions.toggleCleanUiDetailsVisible).toHaveBeenCalled();
+ });
+ unmount();
+ });
+ });
+
+ describe('mouse interaction', () => {
+ it.each([
+ {
+ name: 'first line, first char',
+ relX: 0,
+ relY: 0,
+ mouseCol: 4,
+ mouseRow: 2,
+ },
+ {
+ name: 'first line, middle char',
+ relX: 6,
+ relY: 0,
+ mouseCol: 10,
+ mouseRow: 2,
+ },
+ {
+ name: 'second line, first char',
+ relX: 0,
+ relY: 1,
+ mouseCol: 4,
+ mouseRow: 3,
+ },
+ {
+ name: 'second line, end char',
+ relX: 5,
+ relY: 1,
+ mouseCol: 9,
+ mouseRow: 3,
+ },
+ ])(
+ 'should move cursor on mouse click - $name',
+ async ({ relX, relY, mouseCol, mouseRow }) => {
+ props.buffer.text = 'hello world\nsecond line';
+ props.buffer.lines = ['hello world', 'second line'];
+ props.buffer.allVisualLines = ['hello world', 'second line'];
+ props.buffer.viewportVisualLines = ['hello world', 'second line'];
+ props.buffer.viewportHeight = 10;
+ props.buffer.visualToLogicalMap = [
+ [0, 0],
+ [1, 0],
+ ];
+ props.buffer.visualCursor = [0, 11];
+ props.buffer.visualScrollRow = 0;
+
+ const { stdin, stdout, unmount } = await renderWithProviders(
+ ,
+ { mouseEventsEnabled: true, uiActions },
+ );
+
+ // Wait for initial render
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toContain('hello world');
+ });
+
+ // Simulate left mouse press at calculated coordinates.
+ // Without left border: inner box is at x=3, y=1 based on padding(1)+prompt(2) and border-top(1).
+ await act(async () => {
+ stdin.write(`\x1b[<0;${mouseCol};${mouseRow}M`);
+ });
+
+ await waitFor(() => {
+ expect(props.buffer.moveToVisualPosition).toHaveBeenCalledWith(
+ relY,
+ relX,
+ );
+ });
+
+ unmount();
+ },
+ );
+
+ it('should unfocus embedded shell on click', async () => {
+ props.buffer.setText('hello');
+ props.isEmbeddedShellFocused = true;
+
+ const { stdin, stdout, unmount } = await renderWithProviders(
+ ,
+ { mouseEventsEnabled: true, uiActions },
+ );
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toContain('hello');
+ });
+
+ await act(async () => {
+ // Click somewhere in the prompt
+ stdin.write(`\x1b[<0;5;2M`);
+ });
+
+ await waitFor(() => {
+ expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false);
+ });
+
+ unmount();
+ });
+
+ it('should toggle paste expansion on double-click', async () => {
+ vi.spyOn(Date, 'now').mockReturnValue(1000);
+
+ const id = '[Pasted Text: 10 lines]';
+ const largeText =
+ 'line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10';
+ const togglePasteExpansion = vi.fn();
+
+ const baseProps = props;
+ const TestWrapper = () => {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const currentLines = isExpanded ? largeText.split('\n') : [id];
+ const currentText = isExpanded ? largeText : id;
+
+ const buffer = {
+ ...baseProps.buffer,
+ text: currentText,
+ lines: currentLines,
+ viewportVisualLines: currentLines,
+ allVisualLines: currentLines,
+ viewportHeight: 10,
+ pastedContent: { [id]: largeText },
+ transformationsByLine: isExpanded
+ ? currentLines.map(() => [])
+ : [
+ [
+ {
+ logStart: 0,
+ logEnd: id.length,
+ logicalText: id,
+ collapsedText: id,
+ type: 'paste',
+ id,
+ },
+ ],
+ ],
+ visualScrollRow: 0,
+ visualToLogicalMap: currentLines.map(
+ (_, i) => [i, 0] as [number, number],
+ ),
+ visualToTransformedMap: currentLines.map(() => 0),
+ getLogicalPositionFromVisual: vi.fn().mockReturnValue({
+ row: 0,
+ col: 2,
+ }),
+ togglePasteExpansion: vi.fn().mockImplementation((...args) => {
+ togglePasteExpansion(...args);
+ setIsExpanded((expanded) => !expanded);
+ }),
+ getExpandedPasteAtLine: vi
+ .fn()
+ .mockReturnValue(isExpanded ? id : null),
+ };
+
+ return ;
+ };
+
+ const { stdout, unmount, simulateClick } = await renderWithProviders(
+ ,
+ {
+ mouseEventsEnabled: true,
+ config: makeFakeConfig({ useAlternateBuffer: true }),
+ settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
+ uiActions,
+ },
+ );
+
+ // 1. Verify initial placeholder
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toMatchSnapshot();
+ });
+
+ // Simulate double-click to expand
+ await simulateClick(5, 2);
+ await simulateClick(5, 2);
+
+ // 2. Verify expanded content is visible
+ await waitFor(() => {
+ expect(togglePasteExpansion).toHaveBeenCalledWith(id, 0, 2);
+ expect(stdout.lastFrame()).toContain('line10');
+ });
+
+ // Simulate double-click to collapse
+ await simulateClick(5, 2);
+ await simulateClick(5, 2);
+
+ // 3. Verify placeholder is restored
+ await waitFor(() => {
+ expect(togglePasteExpansion).toHaveBeenCalledTimes(2);
+ expect(stdout.lastFrame()).toContain(id);
+ expect(stdout.lastFrame()).toMatchSnapshot();
+ });
+
+ unmount();
+ });
+
+ it('should collapse expanded paste on double-click after the end of the line', async () => {
+ const id = '[Pasted Text: 10 lines]';
+ const largeText =
+ 'line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10';
+
+ const baseProps = props;
+ const TestWrapper = () => {
+ const [isExpanded, setIsExpanded] = useState(true); // Start expanded
+ const currentLines = isExpanded ? largeText.split('\n') : [id];
+ const currentText = isExpanded ? largeText : id;
+
+ const buffer = {
+ ...baseProps.buffer,
+ text: currentText,
+ lines: currentLines,
+ viewportVisualLines: currentLines,
+ allVisualLines: currentLines,
+ viewportHeight: 10,
+ pastedContent: { [id]: largeText },
+ transformationsByLine: isExpanded
+ ? currentLines.map(() => [])
+ : [
+ [
+ {
+ logStart: 0,
+ logEnd: id.length,
+ logicalText: id,
+ collapsedText: id,
+ type: 'paste',
+ id,
+ },
+ ],
+ ],
+ visualScrollRow: 0,
+ visualToLogicalMap: currentLines.map(
+ (_, i) => [i, 0] as [number, number],
+ ),
+ visualToTransformedMap: currentLines.map(() => 0),
+ getLogicalPositionFromVisual: vi.fn().mockImplementation(
+ (_vRow, _vCol) =>
+ // Simulate that we are past the end of the line by returning something
+ // that getTransformUnderCursor won't match, or having the caller handle it.
+ null,
+ ),
+ togglePasteExpansion: vi.fn().mockImplementation(() => {
+ setIsExpanded(!isExpanded);
+ }),
+ getExpandedPasteAtLine: vi
+ .fn()
+ .mockImplementation((row) =>
+ isExpanded && row >= 0 && row < 10 ? id : null,
+ ),
+ };
+
+ return ;
+ };
+
+ const { stdout, unmount, simulateClick } = await renderWithProviders(
+ ,
+ {
+ mouseEventsEnabled: true,
+ config: makeFakeConfig({ useAlternateBuffer: true }),
+ settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
+ uiActions,
+ },
+ );
+
+ // Verify initially expanded
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toContain('line1');
+ });
+
+ // Simulate double-click WAY to the right on the first line
+ await simulateClick(90, 2);
+ await simulateClick(90, 2);
+
+ // Verify it is NOW collapsed
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toContain(id);
+ expect(stdout.lastFrame()).not.toContain('line1');
+ });
+
+ unmount();
+ });
+
+ it('should move cursor on mouse click with plain borders', async () => {
+ props.config.getUseBackgroundColor = () => false;
+ props.buffer.text = 'hello world';
+ props.buffer.lines = ['hello world'];
+ props.buffer.allVisualLines = ['hello world'];
+ props.buffer.viewportVisualLines = ['hello world'];
+ props.buffer.viewportHeight = 10;
+ props.buffer.visualToLogicalMap = [[0, 0]];
+ props.buffer.visualCursor = [0, 11];
+ props.buffer.visualScrollRow = 0;
+
+ const { stdin, stdout, unmount } = await renderWithProviders(
+ ,
+ { mouseEventsEnabled: true, uiActions },
+ );
+
+ // Wait for initial render
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toContain('hello world');
+ });
+
+ // With plain borders offset
+ await act(async () => {
+ stdin.write(`\x1b[<0;4;2M`); // Click at col 4, row 2
+ });
+
+ await waitFor(() => {
+ expect(props.buffer.moveToVisualPosition).toHaveBeenCalledWith(0, 0);
+ });
+
+ unmount();
+ });
+ });
+
+ describe('queued message editing', () => {
+ it('should load all queued messages when up arrow is pressed with empty input', async () => {
+ const mockPopAllMessages = vi.fn();
+ mockPopAllMessages.mockReturnValue('Message 1\n\nMessage 2\n\nMessage 3');
+ props.popAllMessages = mockPopAllMessages;
+ props.buffer.text = '';
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\u001B[A');
+ });
+ await waitFor(() => expect(mockPopAllMessages).toHaveBeenCalled());
+
+ expect(props.buffer.setText).toHaveBeenCalledWith(
+ 'Message 1\n\nMessage 2\n\nMessage 3',
+ );
+ unmount();
+ });
+
+ it('should not load queued messages when input is not empty', async () => {
+ const mockPopAllMessages = vi.fn();
+ props.popAllMessages = mockPopAllMessages;
+ props.buffer.text = 'some text';
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\u001B[A');
+ });
+ await waitFor(() =>
+ expect(mockInputHistory.navigateUp).toHaveBeenCalled(),
+ );
+ expect(mockPopAllMessages).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should handle undefined messages from popAllMessages', async () => {
+ const mockPopAllMessages = vi.fn();
+ mockPopAllMessages.mockReturnValue(undefined);
+ props.popAllMessages = mockPopAllMessages;
+ props.buffer.text = '';
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\u001B[A');
+ });
+ await waitFor(() => expect(mockPopAllMessages).toHaveBeenCalled());
+
+ expect(props.buffer.setText).not.toHaveBeenCalled();
+ expect(mockInputHistory.navigateUp).toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should work with NAVIGATION_UP key as well', async () => {
+ const mockPopAllMessages = vi.fn();
+ props.popAllMessages = mockPopAllMessages;
+ props.buffer.text = '';
+ props.buffer.allVisualLines = [''];
+ props.buffer.visualCursor = [0, 0];
+ props.buffer.visualScrollRow = 0;
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\u001B[A');
+ });
+ await waitFor(() => expect(mockPopAllMessages).toHaveBeenCalled());
+ unmount();
+ });
+
+ it('should handle single queued message', async () => {
+ const mockPopAllMessages = vi.fn();
+ mockPopAllMessages.mockReturnValue('Single message');
+ props.popAllMessages = mockPopAllMessages;
+ props.buffer.text = '';
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\u001B[A');
+ });
+ await waitFor(() => expect(mockPopAllMessages).toHaveBeenCalled());
+
+ expect(props.buffer.setText).toHaveBeenCalledWith('Single message');
+ unmount();
+ });
+
+ it('should only check for queued messages when buffer text is trimmed empty', async () => {
+ const mockPopAllMessages = vi.fn();
+ props.popAllMessages = mockPopAllMessages;
+ props.buffer.text = ' '; // Whitespace only
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\u001B[A');
+ });
+ await waitFor(() => expect(mockPopAllMessages).toHaveBeenCalled());
+ unmount();
+ });
+
+ it('should not call popAllMessages if it is not provided', async () => {
+ props.popAllMessages = undefined;
+ props.buffer.text = '';
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\u001B[A');
+ });
+ await waitFor(() =>
+ expect(mockInputHistory.navigateUp).toHaveBeenCalled(),
+ );
+ unmount();
+ });
+
+ it('should navigate input history on fresh start when no queued messages exist', async () => {
+ const mockPopAllMessages = vi.fn();
+ mockPopAllMessages.mockReturnValue(undefined);
+ props.popAllMessages = mockPopAllMessages;
+ props.buffer.text = '';
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ stdin.write('\u001B[A');
+ });
+ await waitFor(() => expect(mockPopAllMessages).toHaveBeenCalled());
+
+ expect(mockInputHistory.navigateUp).toHaveBeenCalled();
+ expect(props.buffer.setText).not.toHaveBeenCalled();
+
+ unmount();
+ });
+ });
+
+ describe('snapshots', () => {
+ it('should render correctly in shell mode', async () => {
+ props.shellModeActive = true;
+ const { stdout, unmount } = await renderWithProviders(
+ ,
+ );
+ await waitFor(() => expect(stdout.lastFrame()).toContain('!'));
+ expect(stdout.lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
+ it('should render correctly when accepting edits', async () => {
+ props.approvalMode = ApprovalMode.AUTO_EDIT;
+ const { stdout, unmount } = await renderWithProviders(
+ ,
+ );
+ await waitFor(() => expect(stdout.lastFrame()).toContain('>'));
+ expect(stdout.lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
+ it('should render correctly in yolo mode', async () => {
+ props.approvalMode = ApprovalMode.YOLO;
+ const { stdout, unmount } = await renderWithProviders(
+ ,
+ );
+ await waitFor(() => expect(stdout.lastFrame()).toContain('*'));
+ expect(stdout.lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+ it('should not show inverted cursor when shell is focused', async () => {
+ props.isEmbeddedShellFocused = true;
+ props.focus = false;
+ const renderResult = await renderWithProviders(
+ ,
+ );
+ await renderResult.waitUntilReady();
+ await expect(renderResult).toMatchSvgSnapshot();
+ renderResult.unmount();
+ });
+ });
+
+ it('should still allow input when shell is not focused', async () => {
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ shellFocus: false,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('a');
+ });
+ await waitFor(() => expect(mockBuffer.handleInput).toHaveBeenCalled());
+ unmount();
+ });
+ describe('command queuing while streaming', () => {
+ beforeEach(() => {
+ props.streamingState = StreamingState.Responding;
+ props.setQueueErrorMessage = vi.fn();
+ props.onSubmit = vi.fn();
+ });
+
+ it.each([
+ {
+ name: 'should prevent slash commands',
+ bufferText: '/help',
+ shellMode: false,
+ shouldSubmit: false,
+ errorMessage: 'Slash commands cannot be queued',
+ },
+ {
+ name: 'should allow concurrent-safe slash commands',
+ bufferText: '/stats',
+ shellMode: false,
+ shouldSubmit: true,
+ errorMessage: null,
+ },
+ {
+ name: 'should prevent shell commands',
+ bufferText: 'ls',
+ shellMode: true,
+ shouldSubmit: false,
+ errorMessage: 'Shell commands cannot be queued',
+ },
+ {
+ name: 'should allow regular messages',
+ bufferText: 'regular message',
+ shellMode: false,
+ shouldSubmit: true,
+ errorMessage: null,
+ },
+ ])(
+ '$name',
+ async ({ bufferText, shellMode, shouldSubmit, errorMessage }) => {
+ props.buffer.text = bufferText;
+ props.shellModeActive = shellMode;
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+ await act(async () => {
+ stdin.write('\r');
+ });
+ await waitFor(() => {
+ if (shouldSubmit) {
+ expect(props.onSubmit).toHaveBeenCalledWith(bufferText);
+ expect(props.setQueueErrorMessage).not.toHaveBeenCalled();
+ } else {
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ expect(props.setQueueErrorMessage).toHaveBeenCalledWith(
+ errorMessage,
+ );
+ }
+ });
+ unmount();
+ },
+ );
+ });
+
+ describe('IME Cursor Support', () => {
+ it('should report correct cursor position for simple ASCII text', async () => {
+ const text = 'hello';
+ mockBuffer.setText(text);
+ mockBuffer.visualCursor = [0, 3]; // Cursor after 'hel'
+ mockBuffer.visualScrollRow = 0;
+
+ const { stdout, unmount } = await renderWithProviders(
+ ,
+ { uiActions },
+ );
+
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toContain('hello');
+ });
+
+ // Check Text calls from the LAST render
+ const textCalls = vi.mocked(Text).mock.calls;
+ const cursorLineCall = [...textCalls]
+ .reverse()
+ .find((call) => call[0].terminalCursorFocus === true);
+
+ expect(cursorLineCall).toBeDefined();
+ // 'hel' is 3 characters wide
+ expect(cursorLineCall![0].terminalCursorPosition).toBe(3);
+ unmount();
+ });
+
+ it('should report correct cursor position for text with double-width characters', async () => {
+ const text = '👍hello';
+ mockBuffer.setText(text);
+ mockBuffer.visualCursor = [0, 2]; // Cursor after '👍h' (Note: '👍' is one code point but width 2)
+ mockBuffer.visualScrollRow = 0;
+
+ const { stdout, unmount } = await renderWithProviders(
+ ,
+ { uiActions },
+ );
+
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toContain('👍hello');
+ });
+
+ const textCalls = vi.mocked(Text).mock.calls;
+ const cursorLineCall = [...textCalls]
+ .reverse()
+ .find((call) => call[0].terminalCursorFocus === true);
+
+ expect(cursorLineCall).toBeDefined();
+ // '👍' is width 2, 'h' is width 1. Total width = 3.
+ expect(cursorLineCall![0].terminalCursorPosition).toBe(3);
+ unmount();
+ });
+
+ it('should report correct cursor position for a line full of "😀" emojis', async () => {
+ const text = '😀😀😀';
+ mockBuffer.setText(text);
+ mockBuffer.visualCursor = [0, 2]; // Cursor after 2 emojis (each 1 code point, width 2)
+ mockBuffer.visualScrollRow = 0;
+
+ const { stdout, unmount } = await renderWithProviders(
+ ,
+ { uiActions },
+ );
+
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toContain('😀😀😀');
+ });
+
+ const textCalls = vi.mocked(Text).mock.calls;
+ const cursorLineCall = [...textCalls]
+ .reverse()
+ .find((call) => call[0].terminalCursorFocus === true);
+
+ expect(cursorLineCall).toBeDefined();
+ // 2 emojis * width 2 = 4
+ expect(cursorLineCall![0].terminalCursorPosition).toBe(4);
+ unmount();
+ });
+
+ it('should report correct cursor position for mixed emojis and multi-line input', async () => {
+ const lines = ['😀😀', 'hello 😀', 'world'];
+ mockBuffer.text = lines.join('\n');
+ mockBuffer.lines = lines;
+ mockBuffer.allVisualLines = lines;
+ mockBuffer.viewportVisualLines = lines;
+ mockBuffer.viewportHeight = 10;
+ mockBuffer.visualToLogicalMap = [
+ [0, 0],
+ [1, 0],
+ [2, 0],
+ ];
+ mockBuffer.visualCursor = [1, 7]; // Second line, after 'hello 😀' (6 chars + 1 emoji = 7 code points)
+ mockBuffer.visualScrollRow = 0;
+
+ const { stdout, unmount } = await renderWithProviders(
+ ,
+ { uiActions },
+ );
+
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toContain('hello 😀');
+ });
+
+ const textCalls = vi.mocked(Text).mock.calls;
+ const lineCalls = textCalls.filter(
+ (call) => call[0].terminalCursorPosition !== undefined,
+ );
+ const lastRenderLineCalls = lineCalls.slice(-3);
+
+ const focusCall = lastRenderLineCalls.find(
+ (call) => call[0].terminalCursorFocus === true,
+ );
+ expect(focusCall).toBeDefined();
+ // 'hello ' is 6 units, '😀' is 2 units. Total = 8.
+ expect(focusCall![0].terminalCursorPosition).toBe(8);
+ unmount();
+ });
+
+ it('should report correct cursor position and focus for multi-line input', async () => {
+ const lines = ['first line', 'second line', 'third line'];
+ mockBuffer.text = lines.join('\n');
+ mockBuffer.lines = lines;
+ mockBuffer.allVisualLines = lines;
+ mockBuffer.viewportVisualLines = lines;
+ mockBuffer.viewportHeight = 10;
+ mockBuffer.visualToLogicalMap = [
+ [0, 0],
+ [1, 0],
+ [2, 0],
+ ];
+ mockBuffer.visualCursor = [1, 7]; // Cursor on second line, after 'second '
+ mockBuffer.visualScrollRow = 0;
+
+ const { stdout, unmount } = await renderWithProviders(
+ ,
+ { uiActions },
+ );
+
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toContain('second line');
+ });
+
+ const textCalls = vi.mocked(Text).mock.calls;
+
+ // We look for the last set of line calls.
+ // Line calls have terminalCursorPosition set.
+ const lineCalls = textCalls.filter(
+ (call) => call[0].terminalCursorPosition !== undefined,
+ );
+ const lastRenderLineCalls = lineCalls.slice(-3);
+
+ expect(lastRenderLineCalls.length).toBe(3);
+
+ // Only one line should have terminalCursorFocus=true
+ const focusCalls = lastRenderLineCalls.filter(
+ (call) => call[0].terminalCursorFocus === true,
+ );
+ expect(focusCalls.length).toBe(1);
+ expect(focusCalls[0][0].terminalCursorPosition).toBe(7);
+ unmount();
+ });
+
+ it('should report cursor position 0 when input is empty and placeholder is shown', async () => {
+ mockBuffer.text = '';
+ mockBuffer.lines = [''];
+ mockBuffer.allVisualLines = [''];
+ mockBuffer.viewportVisualLines = [''];
+ mockBuffer.visualToLogicalMap = [[0, 0]];
+ mockBuffer.visualCursor = [0, 0];
+ mockBuffer.visualScrollRow = 0;
+
+ const { stdout, unmount } = await renderWithProviders(
+ ,
+ { uiActions },
+ );
+
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toContain('Type here');
+ });
+
+ const textCalls = vi.mocked(Text).mock.calls;
+ const cursorLineCall = [...textCalls]
+ .reverse()
+ .find((call) => call[0].terminalCursorFocus === true);
+
+ expect(cursorLineCall).toBeDefined();
+ expect(cursorLineCall![0].terminalCursorPosition).toBe(0);
+ unmount();
+ });
+ });
+
+ describe('image path transformation snapshots', () => {
+ const logicalLine = '@/path/to/screenshots/screenshot2x.png';
+ const transformations = calculateTransformationsForLine(logicalLine);
+
+ const applyVisualState = (visualLine: string, cursorCol: number): void => {
+ mockBuffer.text = logicalLine;
+ mockBuffer.lines = [logicalLine];
+ mockBuffer.allVisualLines = [visualLine];
+ mockBuffer.viewportVisualLines = [visualLine];
+ mockBuffer.visualToLogicalMap = [[0, 0]];
+ mockBuffer.visualToTransformedMap = [0];
+ mockBuffer.transformationsByLine = [transformations];
+ mockBuffer.cursor = [0, cursorCol];
+ mockBuffer.visualCursor = [0, cursorCol];
+ mockBuffer.visualScrollRow = 0;
+ };
+
+ it('should snapshot collapsed image path', async () => {
+ const { transformedLine } = calculateTransformedLine(
+ logicalLine,
+ 0,
+ [0, transformations[0].logEnd + 5],
+ transformations,
+ );
+ applyVisualState(transformedLine, transformations[0].logEnd + 5);
+
+ const { stdout, unmount } = await renderWithProviders(
+ ,
+ );
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toContain('[Image');
+ });
+ expect(stdout.lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
+ it('should snapshot expanded image path when cursor is on it', async () => {
+ const { transformedLine } = calculateTransformedLine(
+ logicalLine,
+ 0,
+ [0, transformations[0].logStart + 1],
+ transformations,
+ );
+ applyVisualState(transformedLine, transformations[0].logStart + 1);
+
+ const { stdout, unmount } = await renderWithProviders(
+ ,
+ );
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toContain('@/path/to/screenshots');
+ });
+ expect(stdout.lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+ });
+
+ describe('Ctrl+O paste expansion', () => {
+ const CTRL_O = '\x0f'; // Ctrl+O key sequence
+
+ it('Ctrl+O triggers paste expansion via keybinding', async () => {
+ const id = '[Pasted Text: 10 lines]';
+ const toggleFn = vi.fn();
+ const buffer = {
+ ...props.buffer,
+ text: id,
+ cursor: [0, 0] as number[],
+ pastedContent: {
+ [id]: 'line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10',
+ },
+ transformationsByLine: [
+ [
+ {
+ logStart: 0,
+ logEnd: id.length,
+ logicalText: id,
+ collapsedText: id,
+ type: 'paste',
+ id,
+ },
+ ],
+ ],
+ expandedPaste: null,
+ getExpandedPasteAtLine: vi.fn().mockReturnValue(null),
+ togglePasteExpansion: toggleFn,
+ } as unknown as TextBuffer;
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ { uiActions },
+ );
+
+ await act(async () => {
+ stdin.write(CTRL_O);
+ });
+
+ await waitFor(() => {
+ expect(toggleFn).toHaveBeenCalledWith(id, 0, 0);
+ });
+ unmount();
+ });
+
+ it.each([
+ {
+ name: 'hint appears on large paste via Ctrl+V',
+ text: 'line1\nline2\nline3\nline4\nline5\nline6',
+ method: 'ctrl-v',
+ expectHint: true,
+ },
+ {
+ name: 'hint does not appear for small pastes via Ctrl+V',
+ text: 'hello',
+ method: 'ctrl-v',
+ expectHint: false,
+ },
+ {
+ name: 'hint appears on large terminal paste event',
+ text: 'line1\nline2\nline3\nline4\nline5\nline6',
+ method: 'terminal-paste',
+ expectHint: true,
+ },
+ ])('$name', async ({ text, method, expectHint }) => {
+ vi.mocked(clipboardy.read).mockResolvedValue(text);
+ vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
+
+ const emitSpy = vi.spyOn(appEvents, 'emit');
+ const buffer = {
+ ...props.buffer,
+ handleInput: vi.fn().mockReturnValue(true),
+ } as unknown as TextBuffer;
+
+ // Need kitty protocol enabled for terminal paste events
+ if (method === 'terminal-paste') {
+ mockedUseKittyKeyboardProtocol.mockReturnValue({
+ enabled: true,
+ checking: false,
+ });
+ }
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ );
+
+ await act(async () => {
+ if (method === 'ctrl-v') {
+ stdin.write('\x16'); // Ctrl+V
+ } else {
+ stdin.write(`\x1b[200~${text}\x1b[201~`);
+ }
+ });
+
+ await waitFor(() => {
+ if (expectHint) {
+ expect(emitSpy).toHaveBeenCalledWith(AppEvent.TransientMessage, {
+ message: 'Press Ctrl+O to expand pasted text',
+ type: TransientMessageType.Hint,
+ });
+ } else {
+ // If no hint expected, verify buffer was still updated
+ if (method === 'ctrl-v') {
+ expect(mockBuffer.insert).toHaveBeenCalledWith(text, {
+ paste: true,
+ });
+ } else {
+ expect(buffer.handleInput).toHaveBeenCalled();
+ }
+ }
+ });
+
+ if (!expectHint) {
+ expect(emitSpy).not.toHaveBeenCalledWith(
+ AppEvent.TransientMessage,
+ expect.any(Object),
+ );
+ }
+
+ emitSpy.mockRestore();
+ unmount();
+ });
+ });
+
+ describe('tryTogglePasteExpansion', () => {
+ it.each([
+ {
+ name: 'returns false when no pasted content exists',
+ cursor: [0, 0],
+ pastedContent: {},
+ getExpandedPasteAtLine: null,
+ expected: false,
+ },
+ {
+ name: 'expands placeholder under cursor',
+ cursor: [0, 2],
+ pastedContent: { '[Pasted Text: 6 lines]': 'content' },
+ transformations: [
+ {
+ logStart: 0,
+ logEnd: '[Pasted Text: 6 lines]'.length,
+ id: '[Pasted Text: 6 lines]',
+ },
+ ],
+ expected: true,
+ expectedToggle: ['[Pasted Text: 6 lines]', 0, 2],
+ },
+ {
+ name: 'collapses expanded paste when cursor is inside',
+ cursor: [1, 0],
+ pastedContent: { '[Pasted Text: 6 lines]': 'a\nb\nc' },
+ getExpandedPasteAtLine: '[Pasted Text: 6 lines]',
+ expected: true,
+ expectedToggle: ['[Pasted Text: 6 lines]', 1, 0],
+ },
+ {
+ name: 'expands placeholder when cursor is immediately after it',
+ cursor: [0, '[Pasted Text: 6 lines]'.length],
+ pastedContent: { '[Pasted Text: 6 lines]': 'content' },
+ transformations: [
+ {
+ logStart: 0,
+ logEnd: '[Pasted Text: 6 lines]'.length,
+ id: '[Pasted Text: 6 lines]',
+ },
+ ],
+ expected: true,
+ expectedToggle: [
+ '[Pasted Text: 6 lines]',
+ 0,
+ '[Pasted Text: 6 lines]'.length,
+ ],
+ },
+ {
+ name: 'shows hint when cursor is not on placeholder but placeholders exist',
+ cursor: [0, 0],
+ pastedContent: { '[Pasted Text: 6 lines]': 'content' },
+ transformationsByLine: [
+ [],
+ [
+ {
+ logStart: 0,
+ logEnd: '[Pasted Text: 6 lines]'.length,
+ type: 'paste',
+ id: '[Pasted Text: 6 lines]',
+ },
+ ],
+ ],
+ expected: true,
+ expectedHint: 'Move cursor within placeholder to expand',
+ },
+ ])(
+ '$name',
+ ({
+ cursor,
+ pastedContent,
+ transformations,
+ transformationsByLine,
+ getExpandedPasteAtLine,
+ expected,
+ expectedToggle,
+ expectedHint,
+ }) => {
+ const id = '[Pasted Text: 6 lines]';
+ const buffer = {
+ cursor,
+ pastedContent,
+ transformationsByLine: transformationsByLine || [
+ transformations
+ ? transformations.map((t) => ({
+ ...t,
+ logicalText: id,
+ collapsedText: id,
+ type: 'paste',
+ }))
+ : [],
+ ],
+ getExpandedPasteAtLine: vi
+ .fn()
+ .mockReturnValue(getExpandedPasteAtLine),
+ togglePasteExpansion: vi.fn(),
+ } as unknown as TextBuffer;
+
+ const emitSpy = vi.spyOn(appEvents, 'emit');
+ expect(tryTogglePasteExpansion(buffer)).toBe(expected);
+
+ if (expectedToggle) {
+ expect(buffer.togglePasteExpansion).toHaveBeenCalledWith(
+ ...expectedToggle,
+ );
+ } else {
+ expect(buffer.togglePasteExpansion).not.toHaveBeenCalled();
+ }
+
+ if (expectedHint) {
+ expect(emitSpy).toHaveBeenCalledWith(AppEvent.TransientMessage, {
+ message: expectedHint,
+ type: TransientMessageType.Hint,
+ });
+ } else {
+ expect(emitSpy).not.toHaveBeenCalledWith(
+ AppEvent.TransientMessage,
+ expect.any(Object),
+ );
+ }
+ emitSpy.mockRestore();
+ },
+ );
+ });
+
+ describe('History Navigation and Completion Suppression', () => {
+ beforeEach(() => {
+ props.userMessages = ['first message', 'second message'];
+ // Mock useInputHistory to actually call onChange
+ mockedUseInputHistory.mockImplementation(({ onChange, onSubmit }) => ({
+ navigateUp: () => {
+ onChange('second message', 'start');
+ return true;
+ },
+ navigateDown: () => {
+ onChange('first message', 'end');
+ return true;
+ },
+ handleSubmit: vi.fn((val) => onSubmit(val)),
+ }));
+ });
+
+ it.each([
+ { name: 'Up arrow', key: '\u001B[A', position: 'start' },
+ { name: 'Ctrl+P', key: '\u0010', position: 'start' },
+ ])(
+ 'should move cursor to $position on $name (older history)',
+ async ({ key, position }) => {
+ const { stdin } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write(key);
+ });
+
+ await waitFor(() => {
+ expect(mockBuffer.setText).toHaveBeenCalledWith(
+ 'second message',
+ position as 'start' | 'end',
+ );
+ });
+ },
+ );
+
+ it.each([
+ { name: 'Down arrow', key: '\u001B[B', position: 'end' },
+ { name: 'Ctrl+N', key: '\u000E', position: 'end' },
+ ])(
+ 'should move cursor to $position on $name (newer history)',
+ async ({ key, position }) => {
+ const { stdin } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ // First go up
+ await act(async () => {
+ stdin.write('\u001B[A');
+ });
+
+ // Then go down
+ await act(async () => {
+ stdin.write(key);
+ if (key === '\u001B[B') {
+ // Second press to actually navigate history
+ stdin.write(key);
+ }
+ });
+
+ await waitFor(() => {
+ expect(mockBuffer.setText).toHaveBeenCalledWith(
+ 'first message',
+ position as 'start' | 'end',
+ );
+ });
+ },
+ );
+
+ it('should suppress completion after history navigation', async () => {
+ const { stdin } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\u001B[A'); // Up arrow
+ });
+
+ await waitFor(() => {
+ expect(mockedUseCommandCompletion).toHaveBeenLastCalledWith({
+ buffer: mockBuffer,
+ cwd: expect.anything(),
+ slashCommands: expect.anything(),
+ commandContext: expect.anything(),
+ reverseSearchActive: expect.anything(),
+ shellModeActive: expect.anything(),
+ config: expect.anything(),
+ active: false,
+ });
+ });
+ });
+
+ it('should not render suggestions during history navigation', async () => {
+ // 1. Set up a dynamic mock implementation BEFORE rendering
+ mockedUseCommandCompletion.mockImplementation(({ active }) => ({
+ ...mockCommandCompletion,
+ showSuggestions: active,
+ suggestions: active
+ ? [{ value: 'suggestion', label: 'suggestion' }]
+ : [],
+ }));
+
+ const { stdout, stdin, unmount } = await renderWithProviders(
+ ,
+ { uiActions },
+ );
+
+ // 2. Verify suggestions ARE showing initially because active is true by default
+ await waitFor(() => {
+ expect(stdout.lastFrame()).toContain('suggestion');
+ });
+
+ // 3. Trigger history navigation which should set suppressCompletion to true
+ await act(async () => {
+ stdin.write('\u001B[A');
+ });
+
+ // 4. Verify that suggestions are NOT in the output frame after navigation
+ await waitFor(() => {
+ expect(stdout.lastFrame()).not.toContain('suggestion');
+ });
+
+ expect(stdout.lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
+ it('should continue to suppress completion after manual cursor movement', async () => {
+ const { stdin } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ // Navigate history (suppresses)
+ await act(async () => {
+ stdin.write('\u001B[A');
+ });
+
+ // Wait for it to be suppressed
+ await waitFor(() => {
+ expect(mockedUseCommandCompletion).toHaveBeenLastCalledWith({
+ buffer: mockBuffer,
+ cwd: expect.anything(),
+ slashCommands: expect.anything(),
+ commandContext: expect.anything(),
+ reverseSearchActive: expect.anything(),
+ shellModeActive: expect.anything(),
+ config: expect.anything(),
+ active: false,
+ });
+ });
+
+ // Move cursor manually
+ await act(async () => {
+ stdin.write('\u001B[D'); // Left arrow
+ });
+
+ await waitFor(() => {
+ expect(mockedUseCommandCompletion).toHaveBeenLastCalledWith({
+ buffer: mockBuffer,
+ cwd: expect.anything(),
+ slashCommands: expect.anything(),
+ commandContext: expect.anything(),
+ reverseSearchActive: expect.anything(),
+ shellModeActive: expect.anything(),
+ config: expect.anything(),
+ active: false,
+ });
+ });
+ });
+
+ it('should re-enable completion after typing', async () => {
+ const { stdin } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ // Navigate history (suppresses)
+ await act(async () => {
+ stdin.write('\u001B[A');
+ });
+
+ // Wait for it to be suppressed
+ await waitFor(() => {
+ expect(mockedUseCommandCompletion).toHaveBeenLastCalledWith(
+ expect.objectContaining({ active: false }),
+ );
+ });
+
+ // Type a character
+ await act(async () => {
+ stdin.write('a');
+ });
+
+ await waitFor(() => {
+ expect(mockedUseCommandCompletion).toHaveBeenLastCalledWith(
+ expect.objectContaining({ active: true }),
+ );
+ });
+ });
+ });
+
+ describe('shortcuts help visibility', () => {
+ it('opens shortcuts help with ? on empty prompt even when showShortcutsHint is false', async () => {
+ const setShortcutsHelpVisible = vi.fn();
+ const settings = createMockSettings({
+ ui: { showShortcutsHint: false },
+ });
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ settings,
+ uiActions: { setShortcutsHelpVisible },
+ },
+ );
+
+ await act(async () => {
+ stdin.write('?');
+ });
+
+ await waitFor(() => {
+ expect(setShortcutsHelpVisible).toHaveBeenCalledWith(true);
+ });
+ unmount();
+ });
+
+ it('should NOT open shortcuts help with ? in vim NORMAL mode', async () => {
+ const setShortcutsHelpVisible = vi.fn();
+ const vimHandleInput = vi.fn().mockReturnValue(true);
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions: { setShortcutsHelpVisible },
+ },
+ );
+
+ await act(async () => {
+ stdin.write('?');
+ });
+
+ expect(setShortcutsHelpVisible).not.toHaveBeenCalled();
+ expect(vimHandleInput).toHaveBeenCalled();
+ expect(mockBuffer.handleInput).not.toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('should open shortcuts help with ? in vim INSERT mode', async () => {
+ const setShortcutsHelpVisible = vi.fn();
+ const vimHandleInput = vi.fn().mockReturnValue(false);
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions: { setShortcutsHelpVisible },
+ },
+ );
+
+ await act(async () => {
+ stdin.write('?');
+ });
+
+ await waitFor(() => {
+ expect(setShortcutsHelpVisible).toHaveBeenCalledWith(true);
+ });
+
+ unmount();
+ });
+
+ it.each([
+ {
+ name: 'terminal paste event occurs',
+ input: '\x1b[200~pasted text\x1b[201~',
+ },
+ {
+ name: 'Ctrl+V (PASTE_CLIPBOARD) is pressed',
+ input: '\x16',
+ setupMocks: () => {
+ vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
+ vi.mocked(clipboardy.read).mockResolvedValue('clipboard text');
+ },
+ },
+ {
+ name: 'mouse right-click paste occurs',
+ input: '\x1b[<2;1;1m',
+ mouseEventsEnabled: true,
+ setupMocks: () => {
+ vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
+ vi.mocked(clipboardy.read).mockResolvedValue('clipboard text');
+ },
+ },
+ {
+ name: 'Ctrl+R hotkey is pressed',
+ input: '\x12',
+ },
+ {
+ name: 'Ctrl+G hotkey is pressed',
+ input: '\x07',
+ },
+ {
+ name: 'F12 hotkey is pressed',
+ input: '\x1b[24~',
+ },
+ ])(
+ 'should close shortcuts help when a $name',
+ async ({ input, setupMocks, mouseEventsEnabled }) => {
+ setupMocks?.();
+ const setShortcutsHelpVisible = vi.fn();
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiState: { shortcutsHelpVisible: true },
+ uiActions: { setShortcutsHelpVisible },
+ mouseEventsEnabled,
+ },
+ );
+
+ await act(async () => {
+ stdin.write(input);
+ });
+
+ await waitFor(() => {
+ expect(setShortcutsHelpVisible).toHaveBeenCalledWith(false);
+ });
+ unmount();
+ },
+ );
+ });
+
+ describe('Voice Mode', () => {
+ beforeEach(() => {
+ (
+ fakeTranscriptionProvider as unknown as EventEmitter
+ ).removeAllListeners();
+ vi.clearAllMocks();
+ });
+
+ it('should start recording when space is pressed and voice mode is enabled (toggle)', async () => {
+ await act(async () => {
+ mockBuffer.setText('');
+ });
+ const { stdin, unmount, lastFrame } = await renderWithProviders(
+ ,
+ {
+ uiState: { isVoiceModeEnabled: true } as UIState,
+ settings: createMockSettings({
+ experimental: { voice: { activationMode: 'toggle' } },
+ }),
+ },
+ );
+
+ // Initially not recording
+ expect(lastFrame()).toContain('🎤 >');
+ expect(lastFrame()).toContain(
+ 'Type your message or space to talk (Esc to exit)',
+ );
+
+ // Press space to start
+ await act(async () => {
+ stdin.write(' ');
+ });
+
+ unmount();
+ });
+
+ it('should toggle recording off when space is pressed again (toggle)', async () => {
+ await act(async () => {
+ mockBuffer.setText('');
+ });
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiState: { isVoiceModeEnabled: true } as UIState,
+ settings: createMockSettings({
+ experimental: { voice: { activationMode: 'toggle' } },
+ }),
+ },
+ );
+
+ // Start recording
+ await act(async () => {
+ stdin.write(' ');
+ });
+
+ // Stop recording
+ await act(async () => {
+ stdin.write(' ');
+ });
+
+ unmount();
+ });
+
+ it('should resume recording when space is pressed even if buffer is not empty (toggle)', async () => {
+ await act(async () => {
+ mockBuffer.setText('First turn.');
+ });
+ const { stdin, unmount, lastFrame } = await renderWithProviders(
+ ,
+ {
+ uiState: { isVoiceModeEnabled: true } as UIState,
+ settings: createMockSettings({
+ experimental: { voice: { activationMode: 'toggle' } },
+ }),
+ },
+ );
+
+ // Should show voice mode prefix even if buffer is not empty
+ expect(lastFrame()).toContain('🎤 >');
+ expect(lastFrame()).toContain('First turn.');
+
+ // Press space to start recording again
+ await act(async () => {
+ stdin.write(' ');
+ });
+
+ unmount();
+ });
+
+ it('should not start recording if voice mode is disabled (toggle)', async () => {
+ await act(async () => {
+ mockBuffer.setText('');
+ });
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiState: { isVoiceModeEnabled: false } as UIState,
+ settings: createMockSettings({
+ experimental: { voice: { activationMode: 'toggle' } },
+ }),
+ },
+ );
+
+ // Press space
+ await act(async () => {
+ stdin.write(' ');
+ });
+
+ // Should NOT show listening, instead should call handleInput which handles space
+ expect(mockBuffer.handleInput).toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should append transcription correctly across multiple turn updates (toggle)', async () => {
+ await act(async () => {
+ mockBuffer.setText('initial');
+ });
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiState: { isVoiceModeEnabled: true } as UIState,
+ settings: createMockSettings({
+ experimental: { voice: { activationMode: 'toggle' } },
+ }),
+ },
+ );
+
+ // Start recording
+ await act(async () => {
+ stdin.write(' ');
+ });
+
+ // Emit first transcription
+ await act(async () => {
+ (fakeTranscriptionProvider as unknown as EventEmitter).emit(
+ 'transcription',
+ 'hello',
+ );
+ });
+ await waitFor(() => {
+ expect(mockBuffer.setText).toHaveBeenCalledWith('initial hello', 13);
+ });
+
+ // turnComplete advances the baseline; next turn appends after it
+ await act(async () => {
+ (fakeTranscriptionProvider as unknown as EventEmitter).emit(
+ 'turnComplete',
+ );
+ });
+ await act(async () => {
+ (fakeTranscriptionProvider as unknown as EventEmitter).emit(
+ 'transcription',
+ 'world',
+ );
+ });
+ await waitFor(() => {
+ expect(mockBuffer.setText).toHaveBeenCalledWith(
+ 'initial hello world',
+ 19,
+ );
+ });
+
+ unmount();
+ });
+
+ it('should append transcription correctly when resuming voice mode (toggle)', async () => {
+ await act(async () => {
+ mockBuffer.setText('First turn.');
+ });
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiState: { isVoiceModeEnabled: true } as UIState,
+ settings: createMockSettings({
+ experimental: { voice: { activationMode: 'toggle' } },
+ }),
+ },
+ );
+
+ // Start recording (resumed)
+ await act(async () => {
+ stdin.write(' ');
+ });
+
+ // Emit transcription
+ await act(async () => {
+ (fakeTranscriptionProvider as unknown as EventEmitter).emit(
+ 'transcription',
+ 'Second turn.',
+ );
+ });
+
+ await waitFor(() => {
+ expect(mockBuffer.setText).toHaveBeenCalledWith(
+ 'First turn. Second turn.',
+ 24,
+ );
+ });
+
+ unmount();
+ });
+
+ it('should insert transcription at cursor position when buffer has text before and after (toggle)', async () => {
+ await act(async () => {
+ mockBuffer.setText('hello world');
+ mockBuffer.cursor = [0, 5]; // cursor after 'hello'
+ });
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiState: { isVoiceModeEnabled: true } as UIState,
+ settings: createMockSettings({
+ experimental: { voice: { activationMode: 'toggle' } },
+ }),
+ },
+ );
+
+ await act(async () => {
+ stdin.write(' ');
+ });
+ await act(async () => {
+ (fakeTranscriptionProvider as unknown as EventEmitter).emit(
+ 'transcription',
+ 'there',
+ );
+ });
+
+ // 'hello'(5) + ' '(1) + 'there'(5) = cursor at 11; ' world' preserved after
+ await waitFor(() => {
+ expect(mockBuffer.setText).toHaveBeenCalledWith(
+ 'hello there world',
+ 11,
+ );
+ });
+ unmount();
+ });
+
+ describe('push-to-talk', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('should insert a space on a single tap', async () => {
+ const { stdin, unmount, lastFrame } = await renderWithProviders(
+ ,
+ {
+ uiState: { isVoiceModeEnabled: true } as UIState,
+ settings: createMockSettings({
+ experimental: { voice: { activationMode: 'push-to-talk' } },
+ }),
+ },
+ );
+
+ expect(lastFrame()).toContain('🎤 >');
+ expect(lastFrame()).toContain(
+ 'Type your message or hold space to talk (Esc to exit)',
+ );
+
+ // Press space once
+ await act(async () => {
+ stdin.write(' ');
+ });
+
+ // Should insert space optimistically
+ expect(mockBuffer.insert).toHaveBeenCalledWith(' ');
+
+ // Advance timer past HOLD_DELAY_MS
+ await act(async () => {
+ vi.advanceTimersByTime(700);
+ });
+
+ unmount();
+ });
+
+ it('should start recording on hold (simulated by repeat spaces)', async () => {
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiState: { isVoiceModeEnabled: true } as UIState,
+ settings: createMockSettings({
+ experimental: { voice: { activationMode: 'push-to-talk' } },
+ }),
+ },
+ );
+
+ // First space
+ await act(async () => {
+ stdin.write(' ');
+ });
+ expect(mockBuffer.insert).toHaveBeenCalledWith(' ');
+
+ // Second space (repeat)
+ await act(async () => {
+ stdin.write(' ');
+ });
+
+ await waitFor(() => {
+ // Should have backspaced the optimistic space
+ expect(mockBuffer.backspace).toHaveBeenCalled();
+ });
+
+ unmount();
+ });
+
+ it('should stop recording when space heartbeat stops (release)', async () => {
+ const { stdin, unmount, lastFrame } = await renderWithProviders(
+ ,
+ {
+ uiState: { isVoiceModeEnabled: true } as UIState,
+ settings: createMockSettings({
+ experimental: { voice: { activationMode: 'push-to-talk' } },
+ }),
+ },
+ );
+
+ // Start hold
+ await act(async () => {
+ stdin.write(' ');
+ stdin.write(' ');
+ });
+
+ // Simulate heartbeat (held key) - send space first to reset timer, then advance
+ await act(async () => {
+ stdin.write(' ');
+ vi.advanceTimersByTime(100);
+ });
+ expect(lastFrame()).toContain('~~~ >');
+
+ // Stop heartbeat (release)
+ await act(async () => {
+ vi.advanceTimersByTime(400); // Past RELEASE_DELAY_MS
+ });
+
+ unmount();
+ });
+
+ it('should cancel hold state if non-space key is pressed after first space', async () => {
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiState: { isVoiceModeEnabled: true } as UIState,
+ settings: createMockSettings({
+ experimental: { voice: { activationMode: 'push-to-talk' } },
+ }),
+ },
+ );
+
+ // First space
+ await act(async () => {
+ stdin.write(' ');
+ });
+
+ // Type 'a'
+ await act(async () => {
+ stdin.write('a');
+ });
+
+ // Should NOT start recording on next space even if fast
+ await act(async () => {
+ stdin.write(' ');
+ });
+
+ expect(mockBuffer.insert).toHaveBeenCalledTimes(2); // Two spaces inserted
+ expect(mockBuffer.handleInput).toHaveBeenCalledWith(
+ expect.objectContaining({ name: 'a' }),
+ );
+ unmount();
+ });
+ });
+ });
+
+ describe('terminal buffer rendering', () => {
+ it('does not clip the last char of a visual line whose width equals inputWidth', async () => {
+ const fullLine = '1234567890'; // 10 chars, exactly props.inputWidth
+ props.inputWidth = 10;
+ props.suggestionsWidth = 10;
+ vi.spyOn(props.config, 'getUseTerminalBuffer').mockReturnValue(true);
+ mockBuffer.text = fullLine;
+ mockBuffer.lines = [fullLine];
+ mockBuffer.allVisualLines = [fullLine];
+ mockBuffer.viewportVisualLines = [fullLine];
+ mockBuffer.visualToLogicalMap = [[0, 0]];
+ mockBuffer.visualToTransformedMap = [0];
+ mockBuffer.transformationsByLine = [[]];
+ mockBuffer.cursor = [0, fullLine.length];
+ mockBuffer.visualCursor = [0, fullLine.length];
+
+ const { lastFrame, unmount } = await renderWithProviders(
+ ,
+ { uiActions },
+ );
+
+ await waitFor(() => {
+ expect(clean(lastFrame())).toContain(fullLine);
+ });
+ unmount();
+ });
+ });
+});
+
+function clean(str: string | undefined): string {
+ if (!str) return '';
+ // Remove ANSI escape codes and trim whitespace
+ return stripAnsi(str).trim();
+}
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..511c4b6ceb686d803c1866981ab215169e1cc756
--- /dev/null
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -0,0 +1,1933 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import {
+ useCallback,
+ useEffect,
+ useState,
+ useRef,
+ useMemo,
+ Fragment,
+} from 'react';
+import clipboardy from 'clipboardy';
+import { Box, Text, useStdout, type DOMElement } from 'ink';
+import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
+import { theme } from '../semantic-colors.js';
+import { useInputHistory } from '../hooks/useInputHistory.js';
+import { escapeAtSymbols } from '../hooks/atCommandProcessor.js';
+import {
+ ScrollableList,
+ type ScrollableListRef,
+} from './shared/ScrollableList.js';
+import { ListeningIndicator } from './ListeningIndicator.js';
+import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
+import {
+ type TextBuffer,
+ logicalPosToOffset,
+ expandPastePlaceholders,
+ getTransformUnderCursor,
+ LARGE_PASTE_LINE_THRESHOLD,
+ LARGE_PASTE_CHAR_THRESHOLD,
+} from './shared/text-buffer.js';
+import {
+ cpSlice,
+ cpLen,
+ toCodePoints,
+ cpIndexToOffset,
+} from '../utils/textUtils.js';
+import chalk from 'chalk';
+import stringWidth from 'string-width';
+import { useShellHistory } from '../hooks/useShellHistory.js';
+import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
+import {
+ useCommandCompletion,
+ CompletionMode,
+} from '../hooks/useCommandCompletion.js';
+import { useKeypress, type Key } from '../hooks/useKeypress.js';
+import { Command } from '../key/keyMatchers.js';
+import { formatCommand } from '../key/keybindingUtils.js';
+import type { CommandContext, SlashCommand } from '../commands/types.js';
+import {
+ ApprovalMode,
+ coreEvents,
+ debugLogger,
+ type Config,
+} from '@google/gemini-cli-core';
+import { useVoiceMode } from '../hooks/useVoiceMode.js';
+import {
+ parseInputForHighlighting,
+ parseSegmentsFromTokens,
+} from '../utils/highlight.js';
+import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js';
+import {
+ clipboardHasImage,
+ saveClipboardImage,
+ cleanupOldClipboardImages,
+} from '../utils/clipboardUtils.js';
+import {
+ isAutoExecutableCommand,
+ isSlashCommand,
+} from '../utils/commandUtils.js';
+import { parseSlashCommand } from '../../utils/commands.js';
+import * as path from 'node:path';
+import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
+import { useShellFocusState } from '../contexts/ShellFocusContext.js';
+import { useUIState } from '../contexts/UIStateContext.js';
+import { useInputState } from '../contexts/InputContext.js';
+import {
+ appEvents,
+ AppEvent,
+ TransientMessageType,
+} from '../../utils/events.js';
+import { useSettings } from '../contexts/SettingsContext.js';
+import { StreamingState } from '../types.js';
+import { useMouseClick } from '../hooks/useMouseClick.js';
+import { useMouse, type MouseEvent } from '../contexts/MouseContext.js';
+import { useUIActions } from '../contexts/UIActionsContext.js';
+import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
+import { useIsHelpDismissKey } from '../utils/shortcutsHelp.js';
+import { useRepeatedKeyPress } from '../hooks/useRepeatedKeyPress.js';
+import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
+import type { VimMode } from '../contexts/VimModeContext.js';
+
+const SCROLLBAR_GUTTER_WIDTH = 1;
+
+/**
+ * Returns if the terminal can be trusted to handle paste events atomically
+ * rather than potentially sending multiple paste events separated by line
+ * breaks which could trigger unintended command execution.
+ */
+export function isTerminalPasteTrusted(
+ kittyProtocolSupported: boolean,
+): boolean {
+ // Ideally we could trust all VSCode family terminals as well but it appears
+ // we cannot as Cursor users on windows reported being impacted by this
+ // issue (https://github.com/google-gemini/gemini-cli/issues/3763).
+ return kittyProtocolSupported;
+}
+
+export type ScrollableItem =
+ | { type: 'visualLine'; lineText: string; absoluteVisualIdx: number }
+ | { type: 'ghostLine'; ghostLine: string; index: number };
+
+export interface InputPromptProps {
+ onSubmit: (value: string) => void;
+ onClearScreen: () => void;
+ config: Config;
+ slashCommands: readonly SlashCommand[];
+ commandContext: CommandContext;
+ placeholder?: string;
+ focus?: boolean;
+ setShellModeActive: (value: boolean) => void;
+ approvalMode: ApprovalMode;
+ onEscapePromptChange?: (showPrompt: boolean) => void;
+ onSuggestionsVisibilityChange?: (visible: boolean) => void;
+ vimHandleInput?: (key: Key) => boolean;
+ vimEnabled?: boolean;
+ vimMode?: VimMode;
+ isEmbeddedShellFocused?: boolean;
+ setQueueErrorMessage: (message: string | null) => void;
+ streamingState: StreamingState;
+ popAllMessages?: () => string | undefined;
+ onQueueMessage?: (message: string) => void;
+ suggestionsPosition?: 'above' | 'below';
+ setBannerVisible: (visible: boolean) => void;
+}
+
+// The input content, input container, and input suggestions list may have different widths
+export const calculatePromptWidths = (mainContentWidth: number) => {
+ const FRAME_PADDING_AND_BORDER = 4; // Border (2) + padding (2)
+ const PROMPT_PREFIX_WIDTH = 2; // '> ' or '! '
+
+ const FRAME_OVERHEAD = FRAME_PADDING_AND_BORDER + PROMPT_PREFIX_WIDTH;
+ const suggestionsWidth = Math.max(20, mainContentWidth);
+
+ return {
+ inputWidth: Math.max(mainContentWidth - FRAME_OVERHEAD, 1),
+ containerWidth: mainContentWidth,
+ suggestionsWidth,
+ frameOverhead: FRAME_OVERHEAD,
+ } as const;
+};
+
+/**
+ * Returns true if the given text exceeds the thresholds for being considered a "large paste".
+ */
+export function isLargePaste(text: string): boolean {
+ const pasteLineCount = text.split('\n').length;
+ return (
+ pasteLineCount > LARGE_PASTE_LINE_THRESHOLD ||
+ text.length > LARGE_PASTE_CHAR_THRESHOLD
+ );
+}
+
+const DOUBLE_TAB_CLEAN_UI_TOGGLE_WINDOW_MS = 350;
+/**
+ * Attempt to toggle expansion of a paste placeholder in the buffer.
+ * Returns true if a toggle action was performed or hint was shown, false otherwise.
+ */
+export function tryTogglePasteExpansion(buffer: TextBuffer): boolean {
+ if (!buffer.pastedContent || Object.keys(buffer.pastedContent).length === 0) {
+ return false;
+ }
+
+ const [row, col] = buffer.cursor;
+
+ // 1. Check if cursor is on or immediately after a collapsed placeholder
+ const transform = getTransformUnderCursor(
+ row,
+ col,
+ buffer.transformationsByLine,
+ { includeEdge: true },
+ );
+ if (transform?.type === 'paste' && transform.id) {
+ buffer.togglePasteExpansion(transform.id, row, col);
+ return true;
+ }
+
+ // 2. Check if cursor is inside an expanded paste region — collapse it
+ const expandedId = buffer.getExpandedPasteAtLine(row);
+ if (expandedId) {
+ buffer.togglePasteExpansion(expandedId, row, col);
+ return true;
+ }
+
+ // 3. Placeholders exist but cursor isn't on one — show hint
+ appEvents.emit(AppEvent.TransientMessage, {
+ message: 'Move cursor within placeholder to expand',
+ type: TransientMessageType.Hint,
+ });
+ return true;
+}
+
+export const InputPrompt: React.FC = ({
+ onSubmit,
+ onClearScreen,
+ config,
+ slashCommands,
+ commandContext,
+ placeholder = ' Type your message or @path/to/file',
+ focus = true,
+ setShellModeActive,
+ approvalMode,
+ onEscapePromptChange,
+ onSuggestionsVisibilityChange,
+ vimHandleInput,
+ vimEnabled,
+ vimMode,
+ isEmbeddedShellFocused,
+ setQueueErrorMessage,
+ streamingState,
+ popAllMessages,
+ onQueueMessage,
+ suggestionsPosition = 'below',
+ setBannerVisible,
+}) => {
+ const inputState = useInputState();
+ const {
+ buffer,
+ userMessages,
+ shellModeActive,
+ copyModeEnabled,
+ inputWidth,
+ suggestionsWidth,
+ } = inputState;
+ const isHelpDismissKey = useIsHelpDismissKey();
+ const keyMatchers = useKeyMatchers();
+ const { stdout } = useStdout();
+ const { merged: settings } = useSettings();
+ const kittyProtocol = useKittyKeyboardProtocol();
+ const isShellFocused = useShellFocusState();
+ const {
+ setEmbeddedShellFocused,
+ setShortcutsHelpVisible,
+ toggleCleanUiDetailsVisible,
+ setVoiceModeEnabled,
+ } = useUIActions();
+ const {
+ terminalWidth,
+ activePtyId,
+ history,
+ backgroundTasks,
+ backgroundTaskHeight,
+ shortcutsHelpVisible,
+ isVoiceModeEnabled,
+ } = useUIState();
+ const [suppressCompletion, setSuppressCompletion] = useState(false);
+ const { handlePress: registerPlainTabPress, resetCount: resetPlainTabPress } =
+ useRepeatedKeyPress({
+ windowMs: DOUBLE_TAB_CLEAN_UI_TOGGLE_WINDOW_MS,
+ });
+ const [showEscapePrompt, setShowEscapePrompt] = useState(false);
+ const { handlePress: handleEscPress, resetCount: resetEscapeState } =
+ useRepeatedKeyPress({
+ windowMs: 500,
+ onRepeat: (count) => {
+ if (count === 1) {
+ setShowEscapePrompt(true);
+ } else if (count === 2) {
+ resetEscapeState();
+ if (buffer.text.length > 0) {
+ buffer.setText('');
+ resetTurnBaseline();
+ resetCompletionState();
+ } else if (history.length > 0) {
+ onSubmit('/rewind');
+ } else {
+ coreEvents.emitFeedback('info', 'Nothing to rewind to');
+ }
+ }
+ },
+ onReset: () => setShowEscapePrompt(false),
+ });
+ const [recentUnsafePasteTime, setRecentUnsafePasteTime] = useState<
+ number | null
+ >(null);
+ const pasteTimeoutRef = useRef(null);
+ const innerBoxRef = useRef(null);
+ const hasUserNavigatedSuggestions = useRef(false);
+ const listRef = useRef>(null);
+
+ const { isRecording, handleVoiceInput, resetTurnBaseline } = useVoiceMode({
+ buffer,
+ config,
+ settings,
+ setQueueErrorMessage,
+ isVoiceModeEnabled,
+ setVoiceModeEnabled,
+ keyMatchers,
+ });
+
+ const [reverseSearchActive, setReverseSearchActive] = useState(false);
+ const [commandSearchActive, setCommandSearchActive] = useState(false);
+ const [textBeforeReverseSearch, setTextBeforeReverseSearch] = useState('');
+ const [cursorPosition, setCursorPosition] = useState<[number, number]>([
+ 0, 0,
+ ]);
+ const [expandedSuggestionIndex, setExpandedSuggestionIndex] =
+ useState(-1);
+ const shellHistory = useShellHistory(config.getProjectRoot(), config.storage);
+ const shellHistoryData = shellHistory.history;
+
+ const completion = useCommandCompletion({
+ buffer,
+ cwd: config.getTargetDir(),
+ slashCommands,
+ commandContext,
+ reverseSearchActive,
+ shellModeActive,
+ config,
+ active: !suppressCompletion,
+ });
+
+ const reverseSearchCompletion = useReverseSearchCompletion(
+ buffer,
+ shellHistoryData,
+ reverseSearchActive,
+ );
+
+ const reversedUserMessages = useMemo(
+ () => [...userMessages].reverse(),
+ [userMessages],
+ );
+
+ const commandSearchCompletion = useReverseSearchCompletion(
+ buffer,
+ reversedUserMessages,
+ commandSearchActive,
+ );
+
+ const resetCompletionState = completion.resetCompletionState;
+ const resetReverseSearchCompletionState =
+ reverseSearchCompletion.resetCompletionState;
+ const resetCommandSearchCompletionState =
+ commandSearchCompletion.resetCompletionState;
+
+ const getActiveCompletion = useCallback(() => {
+ if (commandSearchActive) return commandSearchCompletion;
+ if (reverseSearchActive) return reverseSearchCompletion;
+ return completion;
+ }, [
+ commandSearchActive,
+ commandSearchCompletion,
+ reverseSearchActive,
+ reverseSearchCompletion,
+ completion,
+ ]);
+
+ const activeCompletion = getActiveCompletion();
+ const shouldShowSuggestions = activeCompletion.showSuggestions;
+
+ const {
+ forceShowShellSuggestions,
+ setForceShowShellSuggestions,
+ isShellSuggestionsVisible,
+ } = completion;
+
+ const effectivePlaceholder = useMemo(() => {
+ if (!isVoiceModeEnabled) return placeholder;
+ const voiceAction =
+ (settings.experimental.voice?.activationMode ?? 'push-to-talk') ===
+ 'push-to-talk'
+ ? 'hold space to talk'
+ : 'space to talk';
+ return ` Type your message or ${voiceAction} (Esc to exit)`;
+ }, [
+ isVoiceModeEnabled,
+ placeholder,
+ settings.experimental.voice?.activationMode,
+ ]);
+
+ const showCursor =
+ focus && isShellFocused && !isEmbeddedShellFocused && !copyModeEnabled;
+
+ useEffect(() => {
+ appEvents.emit(AppEvent.ScrollToBottom);
+ }, [buffer.text, buffer.cursor]);
+
+ // Notify parent component about escape prompt state changes
+ useEffect(() => {
+ if (onEscapePromptChange) {
+ onEscapePromptChange(showEscapePrompt);
+ }
+ }, [showEscapePrompt, onEscapePromptChange]);
+
+ // Clear paste timeout on unmount
+ useEffect(
+ () => () => {
+ if (pasteTimeoutRef.current) {
+ clearTimeout(pasteTimeoutRef.current);
+ }
+ },
+ [],
+ );
+
+ const handleSubmitAndClear = useCallback(
+ (submittedValue: string) => {
+ let processedValue = submittedValue;
+ if (buffer.pastedContent) {
+ processedValue = expandPastePlaceholders(
+ processedValue,
+ buffer.pastedContent,
+ );
+ }
+
+ if (shellModeActive) {
+ shellHistory.addCommandToHistory(processedValue);
+ }
+ // Clear the buffer *before* calling onSubmit to prevent potential re-submission
+ // if onSubmit triggers a re-render while the buffer still holds the old value.
+ buffer.setText('');
+ resetTurnBaseline();
+ onSubmit(processedValue);
+ resetCompletionState();
+ resetReverseSearchCompletionState();
+ },
+ [
+ buffer,
+ onSubmit,
+ resetCompletionState,
+ shellModeActive,
+ shellHistory,
+ resetReverseSearchCompletionState,
+ resetTurnBaseline,
+ ],
+ );
+
+ const customSetTextAndResetCompletionSignal = useCallback(
+ (newText: string, cursorPosition?: 'start' | 'end' | number) => {
+ buffer.setText(newText, cursorPosition);
+ setSuppressCompletion(true);
+ },
+ [buffer, setSuppressCompletion],
+ );
+
+ const inputHistory = useInputHistory({
+ userMessages,
+ onSubmit: handleSubmitAndClear,
+ isActive:
+ (!(completion.showSuggestions && isShellSuggestionsVisible) ||
+ completion.suggestions.length === 1) &&
+ !shellModeActive,
+ currentQuery: buffer.text,
+ currentCursorOffset: buffer.getOffset(),
+ onChange: customSetTextAndResetCompletionSignal,
+ });
+
+ const handleSubmit = useCallback(
+ (submittedValue: string) => {
+ const trimmedMessage = submittedValue.trim();
+ const isSlash = isSlashCommand(trimmedMessage);
+
+ const isShell = shellModeActive;
+ if (
+ (isSlash || isShell) &&
+ streamingState === StreamingState.Responding
+ ) {
+ if (isSlash) {
+ const { commandToExecute } = parseSlashCommand(
+ trimmedMessage,
+ slashCommands,
+ );
+ if (commandToExecute?.isSafeConcurrent) {
+ handleSubmitAndClear(trimmedMessage);
+ return;
+ }
+ }
+
+ setQueueErrorMessage(
+ `${isShell ? 'Shell' : 'Slash'} commands cannot be queued`,
+ );
+ return;
+ }
+ inputHistory.handleSubmit(trimmedMessage);
+ },
+ [
+ inputHistory,
+ shellModeActive,
+ streamingState,
+ setQueueErrorMessage,
+ slashCommands,
+ handleSubmitAndClear,
+ ],
+ );
+
+ // Effect to reset completion if history navigation just occurred and set the text
+ useEffect(() => {
+ if (suppressCompletion) {
+ resetCompletionState();
+ resetReverseSearchCompletionState();
+ resetCommandSearchCompletionState();
+ setExpandedSuggestionIndex(-1);
+ }
+ }, [
+ suppressCompletion,
+ buffer.text,
+ resetCompletionState,
+ setSuppressCompletion,
+ resetReverseSearchCompletionState,
+ resetCommandSearchCompletionState,
+ setExpandedSuggestionIndex,
+ ]);
+
+ // Helper function to handle loading queued messages into input
+ // Returns true if we should continue with input history navigation
+ const tryLoadQueuedMessages = useCallback(() => {
+ if (buffer.text.trim() === '' && popAllMessages) {
+ const allMessages = popAllMessages();
+ if (allMessages) {
+ buffer.setText(allMessages);
+ return true;
+ } else {
+ // No queued messages, proceed with input history
+ inputHistory.navigateUp();
+ }
+ return true; // We handled the up arrow key
+ }
+ return false;
+ }, [buffer, popAllMessages, inputHistory]);
+
+ // Handle clipboard image pasting with Ctrl+V
+ const handleClipboardPaste = useCallback(async () => {
+ if (shortcutsHelpVisible) {
+ setShortcutsHelpVisible(false);
+ }
+ try {
+ if (await clipboardHasImage()) {
+ const imagePath = await saveClipboardImage(config.getTargetDir());
+ if (imagePath) {
+ // Clean up old images
+ cleanupOldClipboardImages(config.getTargetDir()).catch(() => {
+ // Ignore cleanup errors
+ });
+
+ // Get relative path from current directory
+ const relativePath = path.relative(config.getTargetDir(), imagePath);
+
+ // Insert @path reference at cursor position
+ const insertText = `@${relativePath}`;
+ const currentText = buffer.text;
+ const offset = buffer.getOffset();
+
+ // Add spaces around the path if needed
+ let textToInsert = insertText;
+ const charBefore = offset > 0 ? currentText[offset - 1] : '';
+ const charAfter =
+ offset < currentText.length ? currentText[offset] : '';
+
+ if (charBefore && charBefore !== ' ' && charBefore !== '\n') {
+ textToInsert = ' ' + textToInsert;
+ }
+ if (!charAfter || (charAfter !== ' ' && charAfter !== '\n')) {
+ textToInsert = textToInsert + ' ';
+ }
+
+ // Insert at cursor position
+ buffer.replaceRangeByOffset(offset, offset, textToInsert);
+ }
+ }
+
+ if (settings.experimental?.useOSC52Paste) {
+ stdout.write('\x1b]52;c;?\x07');
+ } else {
+ const textToInsert = await clipboardy.read();
+ const escapedText = settings.ui?.escapePastedAtSymbols
+ ? escapeAtSymbols(textToInsert)
+ : textToInsert;
+ buffer.insert(escapedText, { paste: true });
+
+ if (isLargePaste(textToInsert)) {
+ appEvents.emit(AppEvent.TransientMessage, {
+ message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
+ type: TransientMessageType.Hint,
+ });
+ }
+ }
+ } catch (error) {
+ debugLogger.error('Error handling paste:', error);
+ }
+ }, [
+ buffer,
+ config,
+ stdout,
+ settings,
+ shortcutsHelpVisible,
+ setShortcutsHelpVisible,
+ ]);
+
+ useMouseClick(
+ innerBoxRef,
+ (_event, relX, relY) => {
+ setSuppressCompletion(true);
+ if (isEmbeddedShellFocused) {
+ setEmbeddedShellFocused(false);
+ }
+ const currentScrollTop = Math.round(
+ listRef.current?.getScrollState().scrollTop ?? buffer.visualScrollRow,
+ );
+ const visualRow = currentScrollTop + relY;
+ buffer.moveToVisualPosition(visualRow, relX);
+ },
+ { isActive: focus },
+ );
+
+ const isAlternateBuffer = useAlternateBuffer();
+
+ // Double-click to expand/collapse paste placeholders
+ useMouseClick(
+ innerBoxRef,
+ (_event, relX, relY) => {
+ if (!isAlternateBuffer) return;
+
+ const currentScrollTop = Math.round(
+ listRef.current?.getScrollState().scrollTop ?? buffer.visualScrollRow,
+ );
+ const visualLine = buffer.allVisualLines[currentScrollTop + relY];
+ if (!visualLine) return;
+
+ // Even if we click past the end of the line, we might want to collapse an expanded paste
+ const isPastEndOfLine = relX >= stringWidth(visualLine);
+
+ const logicalPos = isPastEndOfLine
+ ? null
+ : buffer.getLogicalPositionFromVisual(currentScrollTop + relY, relX);
+
+ // Check for paste placeholder (collapsed state)
+ if (logicalPos) {
+ const transform = getTransformUnderCursor(
+ logicalPos.row,
+ logicalPos.col,
+ buffer.transformationsByLine,
+ { includeEdge: true },
+ );
+ if (transform?.type === 'paste' && transform.id) {
+ buffer.togglePasteExpansion(
+ transform.id,
+ logicalPos.row,
+ logicalPos.col,
+ );
+ return;
+ }
+ }
+
+ // If we didn't click a placeholder to expand, check if we are inside or after
+ // an expanded paste region and collapse it.
+ const visualRow = currentScrollTop + relY;
+ const mapEntry = buffer.visualToLogicalMap[visualRow];
+ const row = mapEntry ? mapEntry[0] : visualRow;
+ const expandedId = buffer.getExpandedPasteAtLine(row);
+ if (expandedId) {
+ buffer.togglePasteExpansion(
+ expandedId,
+ row,
+ logicalPos?.col ?? relX, // Fallback to relX if past end of line
+ );
+ }
+ },
+ { isActive: focus, name: 'double-click' },
+ );
+
+ useMouse(
+ (event: MouseEvent) => {
+ if (event.name === 'right-release') {
+ setSuppressCompletion(false);
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ handleClipboardPaste();
+ }
+ },
+ { isActive: focus },
+ );
+
+ const handleInput = useCallback(
+ (key: Key) => {
+ if (handleVoiceInput(key)) return true;
+
+ // Determine if this keypress is a history navigation command
+ const isHistoryUp =
+ !shellModeActive &&
+ (keyMatchers[Command.HISTORY_UP](key) ||
+ (keyMatchers[Command.NAVIGATION_UP](key) &&
+ (buffer.allVisualLines.length === 1 ||
+ (buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))));
+ const isHistoryDown =
+ !shellModeActive &&
+ (keyMatchers[Command.HISTORY_DOWN](key) ||
+ (keyMatchers[Command.NAVIGATION_DOWN](key) &&
+ (buffer.allVisualLines.length === 1 ||
+ buffer.visualCursor[0] === buffer.allVisualLines.length - 1)));
+
+ const isHistoryNav = isHistoryUp || isHistoryDown;
+ const isCursorMovement =
+ keyMatchers[Command.MOVE_LEFT](key) ||
+ keyMatchers[Command.MOVE_RIGHT](key) ||
+ keyMatchers[Command.MOVE_UP](key) ||
+ keyMatchers[Command.MOVE_DOWN](key) ||
+ keyMatchers[Command.MOVE_WORD_LEFT](key) ||
+ keyMatchers[Command.MOVE_WORD_RIGHT](key) ||
+ keyMatchers[Command.HOME](key) ||
+ keyMatchers[Command.END](key);
+
+ const isSuggestionsNav =
+ shouldShowSuggestions &&
+ (keyMatchers[Command.COMPLETION_UP](key) ||
+ keyMatchers[Command.COMPLETION_DOWN](key) ||
+ keyMatchers[Command.EXPAND_SUGGESTION](key) ||
+ keyMatchers[Command.COLLAPSE_SUGGESTION](key) ||
+ keyMatchers[Command.ACCEPT_SUGGESTION](key));
+
+ // Reset completion suppression if the user performs any action other than
+ // history navigation or cursor movement.
+ // We explicitly skip this if we are currently navigating suggestions.
+ if (!isSuggestionsNav) {
+ setSuppressCompletion(
+ isHistoryNav || isCursorMovement || keyMatchers[Command.ESCAPE](key),
+ );
+ hasUserNavigatedSuggestions.current = false;
+
+ if (key.name !== 'tab') {
+ setForceShowShellSuggestions(false);
+ }
+ }
+
+ // TODO(jacobr): this special case is likely not needed anymore.
+ // We should probably stop supporting paste if the InputPrompt is not
+ // focused.
+ /// We want to handle paste even when not focused to support drag and drop.
+ if (!focus && key.name !== 'paste') {
+ return false;
+ }
+
+ // Handle escape to close shortcuts panel first, before letting it bubble
+ // up for cancellation. This ensures pressing Escape once closes the panel,
+ // and pressing again cancels the operation.
+ if (shortcutsHelpVisible && key.name === 'escape') {
+ setShortcutsHelpVisible(false);
+ return true;
+ }
+
+ const isGenerating =
+ streamingState === StreamingState.Responding ||
+ streamingState === StreamingState.WaitingForConfirmation;
+
+ const isQueueMessageKey = keyMatchers[Command.QUEUE_MESSAGE](key);
+ const isPlainTab =
+ key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
+ const hasTabCompletionInteraction =
+ (completion.showSuggestions && isShellSuggestionsVisible) ||
+ Boolean(completion.promptCompletion.text) ||
+ reverseSearchActive ||
+ commandSearchActive;
+
+ if (
+ isGenerating &&
+ isQueueMessageKey &&
+ !hasTabCompletionInteraction &&
+ buffer.text.trim().length > 0
+ ) {
+ const trimmedMessage = buffer.text.trim();
+ const isSlash = isSlashCommand(trimmedMessage);
+
+ if (isSlash || shellModeActive) {
+ setQueueErrorMessage(
+ `${shellModeActive ? 'Shell' : 'Slash'} commands cannot be queued`,
+ );
+ } else if (onQueueMessage) {
+ onQueueMessage(buffer.text);
+ buffer.setText('');
+ resetCompletionState();
+ resetReverseSearchCompletionState();
+ }
+ resetPlainTabPress();
+ return true;
+ }
+
+ if (isPlainTab && shellModeActive) {
+ resetPlainTabPress();
+ if (!shouldShowSuggestions) {
+ setSuppressCompletion(false);
+ if (completion.promptCompletion.text) {
+ completion.promptCompletion.accept();
+ return true;
+ } else if (
+ completion.suggestions.length > 0 &&
+ !forceShowShellSuggestions
+ ) {
+ setForceShowShellSuggestions(true);
+ return true;
+ }
+ }
+ } else if (isPlainTab) {
+ if (!hasTabCompletionInteraction) {
+ if (registerPlainTabPress() === 2) {
+ toggleCleanUiDetailsVisible();
+ resetPlainTabPress();
+ return true;
+ }
+ } else {
+ resetPlainTabPress();
+ }
+ } else {
+ resetPlainTabPress();
+ }
+
+ if (key.name === 'paste') {
+ if (shortcutsHelpVisible) {
+ setShortcutsHelpVisible(false);
+ }
+ // Record paste time to prevent accidental auto-submission
+ if (!isTerminalPasteTrusted(kittyProtocol.enabled)) {
+ setRecentUnsafePasteTime(Date.now());
+
+ // Clear any existing paste timeout
+ if (pasteTimeoutRef.current) {
+ clearTimeout(pasteTimeoutRef.current);
+ }
+
+ // Clear the paste protection after a very short delay to prevent
+ // false positives.
+ // Due to how we use a reducer for text buffer state updates, it is
+ // reasonable to expect that key events that are really part of the
+ // same paste will be processed in the same event loop tick. 40ms
+ // is chosen arbitrarily as it is faster than a typical human
+ // could go from pressing paste to pressing enter. The fastest typists
+ // can type at 200 words per minute which roughly translates to 50ms
+ // per letter.
+ pasteTimeoutRef.current = setTimeout(() => {
+ setRecentUnsafePasteTime(null);
+ pasteTimeoutRef.current = null;
+ }, 40);
+ }
+ if (settings.ui?.escapePastedAtSymbols) {
+ buffer.handleInput({
+ ...key,
+ sequence: escapeAtSymbols(key.sequence || ''),
+ });
+ } else {
+ buffer.handleInput(key);
+ }
+
+ if (key.sequence && isLargePaste(key.sequence)) {
+ appEvents.emit(AppEvent.TransientMessage, {
+ message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
+ type: TransientMessageType.Hint,
+ });
+ }
+ return true;
+ }
+
+ if (shortcutsHelpVisible && isHelpDismissKey(key)) {
+ setShortcutsHelpVisible(false);
+ }
+
+ if (shortcutsHelpVisible) {
+ if (
+ key.sequence === '?' &&
+ key.insertable &&
+ (!vimEnabled || vimMode === 'INSERT')
+ ) {
+ setShortcutsHelpVisible(false);
+ buffer.handleInput(key);
+ return true;
+ }
+ // Escape is handled earlier to ensure it closes the panel before
+ // potentially cancelling an operation
+ if (key.name === 'backspace' || key.sequence === '\b') {
+ setShortcutsHelpVisible(false);
+ return true;
+ }
+ if (key.insertable) {
+ setShortcutsHelpVisible(false);
+ }
+ }
+
+ if (
+ key.sequence === '?' &&
+ key.insertable &&
+ !shortcutsHelpVisible &&
+ buffer.text.length === 0 &&
+ (!vimEnabled || vimMode === 'INSERT')
+ ) {
+ setShortcutsHelpVisible(true);
+ return true;
+ }
+
+ if (vimHandleInput && vimHandleInput(key)) {
+ return true;
+ }
+
+ // Reset ESC count and hide prompt on any non-ESC key
+ if (key.name !== 'escape') {
+ resetEscapeState();
+ }
+
+ // Ctrl+O to expand/collapse paste placeholders
+ if (keyMatchers[Command.EXPAND_PASTE](key)) {
+ const handled = tryTogglePasteExpansion(buffer);
+ if (handled) return true;
+ }
+
+ if (
+ key.sequence === '!' &&
+ buffer.text === '' &&
+ !(completion.showSuggestions && isShellSuggestionsVisible)
+ ) {
+ setShellModeActive(!shellModeActive);
+ buffer.setText(''); // Clear the '!' from input
+ resetTurnBaseline();
+ return true;
+ }
+ if (keyMatchers[Command.ESCAPE](key)) {
+ const cancelSearch = (
+ setActive: (active: boolean) => void,
+ resetCompletion: () => void,
+ ) => {
+ setActive(false);
+ resetCompletion();
+ buffer.setText(textBeforeReverseSearch);
+ const offset = logicalPosToOffset(
+ buffer.lines,
+ cursorPosition[0],
+ cursorPosition[1],
+ );
+ buffer.moveToOffset(offset);
+ setExpandedSuggestionIndex(-1);
+ };
+
+ if (reverseSearchActive) {
+ cancelSearch(
+ setReverseSearchActive,
+ reverseSearchCompletion.resetCompletionState,
+ );
+ return true;
+ }
+ if (commandSearchActive) {
+ cancelSearch(
+ setCommandSearchActive,
+ commandSearchCompletion.resetCompletionState,
+ );
+ return true;
+ }
+
+ if (completion.showSuggestions && isShellSuggestionsVisible) {
+ completion.resetCompletionState();
+ setExpandedSuggestionIndex(-1);
+ resetEscapeState();
+ return true;
+ }
+
+ if (shellModeActive) {
+ setShellModeActive(false);
+ resetEscapeState();
+ return true;
+ }
+
+ // If we're generating and no local overlay consumed Escape, let it
+ // propagate to the global cancellation handler.
+ if (isGenerating) {
+ return false;
+ }
+
+ handleEscPress();
+ return true;
+ }
+
+ if (keyMatchers[Command.CLEAR_SCREEN](key)) {
+ setBannerVisible(false);
+ onClearScreen();
+ return true;
+ }
+
+ if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
+ setReverseSearchActive(true);
+ setTextBeforeReverseSearch(buffer.text);
+ setCursorPosition(buffer.cursor);
+ return true;
+ }
+
+ if (reverseSearchActive || commandSearchActive) {
+ const isCommandSearch = commandSearchActive;
+
+ const sc = isCommandSearch
+ ? commandSearchCompletion
+ : reverseSearchCompletion;
+
+ const {
+ activeSuggestionIndex,
+ navigateUp,
+ navigateDown,
+ showSuggestions,
+ suggestions,
+ } = sc;
+ const setActive = isCommandSearch
+ ? setCommandSearchActive
+ : setReverseSearchActive;
+ const resetState = sc.resetCompletionState;
+
+ if (showSuggestions) {
+ if (keyMatchers[Command.NAVIGATION_UP](key)) {
+ navigateUp();
+ return true;
+ }
+ if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
+ navigateDown();
+ return true;
+ }
+ if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
+ if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
+ setExpandedSuggestionIndex(-1);
+ return true;
+ }
+ }
+ if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
+ if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
+ setExpandedSuggestionIndex(activeSuggestionIndex);
+ return true;
+ }
+ }
+ if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) {
+ sc.handleAutocomplete(activeSuggestionIndex);
+ resetState();
+ setActive(false);
+ return true;
+ }
+ }
+
+ if (keyMatchers[Command.SUBMIT_REVERSE_SEARCH](key)) {
+ const textToSubmit =
+ showSuggestions && activeSuggestionIndex > -1
+ ? suggestions[activeSuggestionIndex].value
+ : buffer.text;
+ handleSubmit(textToSubmit);
+ resetState();
+ setActive(false);
+ return true;
+ }
+
+ // Prevent up/down from falling through to regular history navigation
+ if (
+ keyMatchers[Command.NAVIGATION_UP](key) ||
+ keyMatchers[Command.NAVIGATION_DOWN](key)
+ ) {
+ return true;
+ }
+ }
+
+ // If the command is a perfect match, pressing enter should execute it.
+ // We prioritize execution unless the user is explicitly selecting a different suggestion.
+ if (
+ completion.isPerfectMatch &&
+ keyMatchers[Command.SUBMIT](key) &&
+ recentUnsafePasteTime === null &&
+ (!(completion.showSuggestions && isShellSuggestionsVisible) ||
+ (completion.activeSuggestionIndex <= 0 &&
+ !hasUserNavigatedSuggestions.current))
+ ) {
+ handleSubmit(buffer.text);
+ return true;
+ }
+
+ // Newline insertion
+ if (keyMatchers[Command.NEWLINE](key)) {
+ buffer.newline();
+ return true;
+ }
+
+ if (completion.showSuggestions && isShellSuggestionsVisible) {
+ if (completion.suggestions.length > 1) {
+ if (keyMatchers[Command.COMPLETION_UP](key)) {
+ completion.navigateUp();
+ hasUserNavigatedSuggestions.current = true;
+ setExpandedSuggestionIndex(-1); // Reset expansion when navigating
+ return true;
+ }
+ if (keyMatchers[Command.COMPLETION_DOWN](key)) {
+ completion.navigateDown();
+ hasUserNavigatedSuggestions.current = true;
+ setExpandedSuggestionIndex(-1); // Reset expansion when navigating
+ return true;
+ }
+ }
+
+ if (keyMatchers[Command.ACCEPT_SUGGESTION](key)) {
+ if (completion.suggestions.length > 0) {
+ const targetIndex =
+ completion.activeSuggestionIndex === -1
+ ? 0 // Default to the first if none is active
+ : completion.activeSuggestionIndex;
+
+ if (targetIndex < completion.suggestions.length) {
+ const suggestion = completion.suggestions[targetIndex];
+
+ const isEnterKey = key.name === 'enter' && !key.ctrl;
+
+ if (isEnterKey && shellModeActive) {
+ if (hasUserNavigatedSuggestions.current) {
+ completion.handleAutocomplete(
+ completion.activeSuggestionIndex,
+ );
+ setExpandedSuggestionIndex(-1);
+ hasUserNavigatedSuggestions.current = false;
+ return true;
+ }
+ completion.resetCompletionState();
+ setExpandedSuggestionIndex(-1);
+ hasUserNavigatedSuggestions.current = false;
+ if (buffer.text.trim()) {
+ handleSubmit(buffer.text);
+ }
+ return true;
+ }
+
+ if (isEnterKey && buffer.text.startsWith('/')) {
+ if (suggestion.submitValue) {
+ setExpandedSuggestionIndex(-1);
+ handleSubmit(suggestion.submitValue.trim());
+ return true;
+ }
+
+ const { isArgumentCompletion, leafCommand } =
+ completion.slashCompletionRange;
+
+ if (
+ isArgumentCompletion &&
+ isAutoExecutableCommand(leafCommand)
+ ) {
+ // isArgumentCompletion guarantees leafCommand exists
+ const completedText = completion.getCompletedText(suggestion);
+ if (completedText) {
+ setExpandedSuggestionIndex(-1);
+ handleSubmit(completedText.trim());
+ return true;
+ }
+ } else if (!isArgumentCompletion) {
+ // Existing logic for command name completion
+ const command =
+ completion.getCommandFromSuggestion(suggestion);
+
+ // Only auto-execute if the command has no completion function
+ // (i.e., it doesn't require an argument to be selected)
+ if (
+ command &&
+ isAutoExecutableCommand(command) &&
+ !command.completion
+ ) {
+ const completedText =
+ completion.getCompletedText(suggestion);
+
+ if (completedText) {
+ setExpandedSuggestionIndex(-1);
+ handleSubmit(completedText.trim());
+ return true;
+ }
+ }
+ }
+ }
+
+ // Default behavior: auto-complete to prompt box
+ completion.handleAutocomplete(targetIndex);
+ setExpandedSuggestionIndex(-1); // Reset expansion after selection
+ }
+ }
+ return true;
+ }
+ }
+
+ // Handle Tab key for ghost text acceptance
+ if (
+ key.name === 'tab' &&
+ !key.shift &&
+ !(completion.showSuggestions && isShellSuggestionsVisible) &&
+ completion.promptCompletion.text
+ ) {
+ completion.promptCompletion.accept();
+ return true;
+ }
+
+ if (!shellModeActive) {
+ if (keyMatchers[Command.REVERSE_SEARCH](key)) {
+ setCommandSearchActive(true);
+ setTextBeforeReverseSearch(buffer.text);
+ setCursorPosition(buffer.cursor);
+ return true;
+ }
+
+ if (isHistoryUp) {
+ if (
+ keyMatchers[Command.NAVIGATION_UP](key) &&
+ buffer.visualCursor[1] > 0
+ ) {
+ buffer.move('home');
+ return true;
+ }
+ // Check for queued messages first when input is empty
+ // If no queued messages, inputHistory.navigateUp() is called inside tryLoadQueuedMessages
+ if (tryLoadQueuedMessages()) {
+ return true;
+ }
+ // Only navigate history if popAllMessages doesn't exist
+ inputHistory.navigateUp();
+ return true;
+ }
+ if (isHistoryDown) {
+ if (
+ keyMatchers[Command.NAVIGATION_DOWN](key) &&
+ buffer.visualCursor[1] <
+ cpLen(buffer.allVisualLines[buffer.visualCursor[0]] || '')
+ ) {
+ buffer.move('end');
+ return true;
+ }
+ inputHistory.navigateDown();
+ return true;
+ }
+ } else {
+ // Shell History Navigation
+ if (keyMatchers[Command.NAVIGATION_UP](key)) {
+ if (
+ (buffer.allVisualLines.length === 1 ||
+ (buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0)) &&
+ buffer.visualCursor[1] > 0
+ ) {
+ buffer.move('home');
+ return true;
+ }
+ const prevCommand = shellHistory.getPreviousCommand();
+ if (prevCommand !== null) buffer.setText(prevCommand);
+ return true;
+ }
+ if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
+ if (
+ (buffer.allVisualLines.length === 1 ||
+ buffer.visualCursor[0] === buffer.allVisualLines.length - 1) &&
+ buffer.visualCursor[1] <
+ cpLen(buffer.allVisualLines[buffer.visualCursor[0]] || '')
+ ) {
+ buffer.move('end');
+ return true;
+ }
+ const nextCommand = shellHistory.getNextCommand();
+ if (nextCommand !== null) buffer.setText(nextCommand);
+ return true;
+ }
+ }
+
+ if (keyMatchers[Command.SUBMIT](key)) {
+ if (buffer.text.trim()) {
+ // Check if a paste operation occurred recently to prevent accidental auto-submission
+ if (recentUnsafePasteTime !== null) {
+ // Paste occurred recently in a terminal where we don't trust pastes
+ // to be reported correctly so assume this paste was really a
+ // newline that was part of the paste.
+ // This has the added benefit that in the worst case at least users
+ // get some feedback that their keypress was handled rather than
+ // wondering why it was completely ignored.
+ buffer.newline();
+ return true;
+ }
+
+ const [row, col] = buffer.cursor;
+ const line = buffer.lines[row];
+ const charBefore = col > 0 ? cpSlice(line, col - 1, col) : '';
+ if (charBefore === '\\') {
+ buffer.backspace();
+ buffer.newline();
+ } else {
+ handleSubmit(buffer.text);
+ }
+ }
+ return true;
+ }
+
+ // Ctrl+A (Home) / Ctrl+E (End)
+ if (keyMatchers[Command.HOME](key)) {
+ buffer.move('home');
+ return true;
+ }
+ if (keyMatchers[Command.END](key)) {
+ buffer.move('end');
+ return true;
+ }
+
+ // Kill line commands
+ if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
+ buffer.killLineRight();
+ return true;
+ }
+ if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
+ buffer.killLineLeft();
+ return true;
+ }
+
+ if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
+ buffer.deleteWordLeft();
+ return true;
+ }
+
+ // External editor
+ if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ buffer.openInExternalEditor();
+ return true;
+ }
+
+ if (keyMatchers[Command.DEPRECATED_OPEN_EXTERNAL_EDITOR](key)) {
+ const cmdKey = formatCommand(Command.OPEN_EXTERNAL_EDITOR);
+ appEvents.emit(AppEvent.TransientMessage, {
+ message: `Use ${cmdKey} to open the external editor.`,
+ type: TransientMessageType.Hint,
+ });
+ return true;
+ }
+
+ // Ctrl+V for clipboard paste
+ if (keyMatchers[Command.PASTE_CLIPBOARD](key)) {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ handleClipboardPaste();
+ return true;
+ }
+
+ if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
+ return false;
+ }
+
+ if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
+ if (
+ activePtyId ||
+ (backgroundTasks.size > 0 && backgroundTaskHeight > 0)
+ ) {
+ setEmbeddedShellFocused(true);
+ return true;
+ }
+ return false;
+ }
+
+ // Fall back to the text buffer's default input handling for all other keys
+ const handled = buffer.handleInput(key);
+
+ if (handled) {
+ if (keyMatchers[Command.CLEAR_INPUT](key)) {
+ resetCompletionState();
+ }
+
+ // Clear ghost text when user types regular characters (not navigation/control keys)
+ if (
+ completion.promptCompletion.text &&
+ key.sequence &&
+ key.sequence.length === 1 &&
+ !key.alt &&
+ !key.ctrl &&
+ !key.cmd
+ ) {
+ completion.promptCompletion.clear();
+ setExpandedSuggestionIndex(-1);
+ }
+ }
+ return handled;
+ },
+ [
+ focus,
+ buffer,
+ completion,
+ setForceShowShellSuggestions,
+ shellModeActive,
+ setShellModeActive,
+ onClearScreen,
+ inputHistory,
+ handleSubmit,
+ shellHistory,
+ reverseSearchCompletion,
+ handleClipboardPaste,
+ resetCompletionState,
+ resetEscapeState,
+ vimHandleInput,
+ vimEnabled,
+ vimMode,
+ reverseSearchActive,
+ textBeforeReverseSearch,
+ cursorPosition,
+ recentUnsafePasteTime,
+ commandSearchActive,
+ commandSearchCompletion,
+ kittyProtocol.enabled,
+ shortcutsHelpVisible,
+ setShortcutsHelpVisible,
+ tryLoadQueuedMessages,
+ onQueueMessage,
+ setQueueErrorMessage,
+ resetReverseSearchCompletionState,
+ setBannerVisible,
+ activePtyId,
+ setEmbeddedShellFocused,
+ backgroundTasks.size,
+ backgroundTaskHeight,
+ streamingState,
+ handleEscPress,
+ resetTurnBaseline,
+ registerPlainTabPress,
+ resetPlainTabPress,
+ toggleCleanUiDetailsVisible,
+ shouldShowSuggestions,
+ isShellSuggestionsVisible,
+ forceShowShellSuggestions,
+ keyMatchers,
+ isHelpDismissKey,
+ settings,
+ handleVoiceInput,
+ ],
+ );
+ useKeypress(handleInput, {
+ isActive: !isEmbeddedShellFocused && !copyModeEnabled,
+ priority: true,
+ });
+
+ const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
+ buffer.visualCursor;
+
+ const getGhostTextLines = useCallback(() => {
+ if (
+ !completion.promptCompletion.text ||
+ !buffer.text ||
+ !completion.promptCompletion.text.startsWith(buffer.text)
+ ) {
+ return { inlineGhost: '', additionalLines: [] };
+ }
+
+ const ghostSuffix = completion.promptCompletion.text.slice(
+ buffer.text.length,
+ );
+ if (!ghostSuffix) {
+ return { inlineGhost: '', additionalLines: [] };
+ }
+
+ const currentLogicalLine = buffer.lines[buffer.cursor[0]] || '';
+ const cursorCol = buffer.cursor[1];
+
+ const textBeforeCursor = cpSlice(currentLogicalLine, 0, cursorCol);
+ const usedWidth = stringWidth(textBeforeCursor);
+ const remainingWidth = Math.max(0, inputWidth - usedWidth);
+
+ const ghostTextLinesRaw = ghostSuffix.split('\n');
+ const firstLineRaw = ghostTextLinesRaw.shift() || '';
+
+ let inlineGhost = '';
+ let remainingFirstLine = '';
+
+ if (stringWidth(firstLineRaw) <= remainingWidth) {
+ inlineGhost = firstLineRaw;
+ } else {
+ const words = firstLineRaw.split(' ');
+ let currentLine = '';
+ let wordIdx = 0;
+ for (const word of words) {
+ const prospectiveLine = currentLine ? `${currentLine} ${word}` : word;
+ if (stringWidth(prospectiveLine) > remainingWidth) {
+ break;
+ }
+ currentLine = prospectiveLine;
+ wordIdx++;
+ }
+ inlineGhost = currentLine;
+ if (words.length > wordIdx) {
+ remainingFirstLine = words.slice(wordIdx).join(' ');
+ }
+ }
+
+ const linesToWrap = [];
+ if (remainingFirstLine) {
+ linesToWrap.push(remainingFirstLine);
+ }
+ linesToWrap.push(...ghostTextLinesRaw);
+ const remainingGhostText = linesToWrap.join('\n');
+
+ const additionalLines: string[] = [];
+ if (remainingGhostText) {
+ const textLines = remainingGhostText.split('\n');
+ for (const textLine of textLines) {
+ const words = textLine.split(' ');
+ let currentLine = '';
+
+ for (const word of words) {
+ const prospectiveLine = currentLine ? `${currentLine} ${word}` : word;
+ const prospectiveWidth = stringWidth(prospectiveLine);
+
+ if (prospectiveWidth > inputWidth) {
+ if (currentLine) {
+ additionalLines.push(currentLine);
+ }
+
+ let wordToProcess = word;
+ while (stringWidth(wordToProcess) > inputWidth) {
+ let part = '';
+ const wordCP = toCodePoints(wordToProcess);
+ let partWidth = 0;
+ let splitIndex = 0;
+ for (let i = 0; i < wordCP.length; i++) {
+ const char = wordCP[i];
+ const charWidth = stringWidth(char);
+ if (partWidth + charWidth > inputWidth) {
+ break;
+ }
+ part += char;
+ partWidth += charWidth;
+ splitIndex = i + 1;
+ }
+ additionalLines.push(part);
+ wordToProcess = cpSlice(wordToProcess, splitIndex);
+ }
+ currentLine = wordToProcess;
+ } else {
+ currentLine = prospectiveLine;
+ }
+ }
+ if (currentLine) {
+ additionalLines.push(currentLine);
+ }
+ }
+ }
+
+ return { inlineGhost, additionalLines };
+ }, [
+ completion.promptCompletion.text,
+ buffer.text,
+ buffer.lines,
+ buffer.cursor,
+ inputWidth,
+ ]);
+
+ const { inlineGhost, additionalLines } = getGhostTextLines();
+
+ const scrollableData = useMemo(() => {
+ const items: ScrollableItem[] = buffer.allVisualLines.map(
+ (lineText, index) => ({
+ type: 'visualLine',
+ lineText,
+ absoluteVisualIdx: index,
+ }),
+ );
+
+ additionalLines.forEach((ghostLine, index) => {
+ items.push({
+ type: 'ghostLine',
+ ghostLine,
+ index,
+ });
+ });
+
+ return items;
+ }, [buffer.allVisualLines, additionalLines]);
+
+ const renderItem = useCallback(
+ ({ item }: { item: ScrollableItem; index: number }) => {
+ if (item.type === 'ghostLine') {
+ const padding = Math.max(0, inputWidth - stringWidth(item.ghostLine));
+ return (
+
+