mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e41970ed21 | |||
| 675ca07c8b | |||
| 40f505e257 | |||
| 5407878138 | |||
| 14e2e09d10 | |||
| 0365f13caa | |||
| 3183e4137a | |||
| 4aa295994d | |||
| e1bd1d239f | |||
| b84585d0c8 | |||
| 0e7944df4f | |||
| d8837ec95e | |||
| dc37b190fa | |||
| 19b1a74c99 | |||
| 1d045792ce | |||
| a8b4c38c89 | |||
| ad8796b02d | |||
| e7bfd2bf83 | |||
| 1b274b081d | |||
| 5b254c379c | |||
| ed26ea49e9 | |||
| 01e33465bd | |||
| 18cce6a9ab | |||
| 18d7d1a92c | |||
| 0dd0b83612 | |||
| 4e4a55be35 | |||
| f57fd642df | |||
| 5e96373e6b | |||
| 09beb648b8 | |||
| c159c85c89 | |||
| 8cae90f404 | |||
| c5d0fc2c3e | |||
| e860f517c0 | |||
| b0be1f1689 | |||
| 85dd6ef773 | |||
| ae672881d1 | |||
| 9e7c10ad88 | |||
| 381669dce0 | |||
| 707b3e85d5 | |||
| 7d36cc004f | |||
| cb4f0c6fa4 | |||
| b0f38104d7 | |||
| 7469ea0fca | |||
| 00fdb30211 | |||
| 0fe8492569 | |||
| 76387d22ae | |||
| a362b7b382 | |||
| 71308caf05 | |||
| 6396ab1ccb | |||
| 12531a06f8 | |||
| d1cd69a20d | |||
| 62346875e4 | |||
| 13e013230b | |||
| bb6a336ca9 | |||
| f14d0c6a17 | |||
| b611f9a519 | |||
| d3bca5d97a | |||
| 94f4e027f8 | |||
| 95b7d69d5b | |||
| f605628624 | |||
| 2238802e97 |
+31
-11
@@ -1,6 +1,6 @@
|
||||
description = "Reviews a frontend PR or staged changes and automatically initiates a Pickle Fix loop for findings."
|
||||
description = "Reviews a PR or staged changes and automatically initiates a Pickle Fix loop for findings."
|
||||
prompt = """
|
||||
You are an expert Frontend Reviewer and Pickle Rick Worker.
|
||||
You are an expert Reviewer and Pickle Rick Worker.
|
||||
|
||||
Target: {{args}}
|
||||
|
||||
@@ -56,8 +56,6 @@ Follow these steps to conduct a thorough review:
|
||||
pollution.
|
||||
* Use `vi.useFakeTimers()` for tests involving time-based logic to avoid
|
||||
flakiness.
|
||||
* Avoid using `any` in tests; prefer proper types or `unknown` with
|
||||
narrowing.
|
||||
* When creating parameterized tests, give the parameters types to ensure
|
||||
that the tests are type-safe.
|
||||
8. Evaluate all react logic carefully keeping in mind that the author of the
|
||||
@@ -105,7 +103,20 @@ Follow these steps to conduct a thorough review:
|
||||
to include the new property or propose a new provider to add if the
|
||||
property drilling is excessive. Only use providers for properties that are
|
||||
consistent for the entire application.
|
||||
9. General Gemini CLI design principles:
|
||||
9. Evaluate `packages/core` (Services, Tools, Utilities):
|
||||
* Ensure services are implemented as classes with clear lifecycle management (e.g., `initialize()` methods).
|
||||
* Verify that `debugLogger` from `packages/core/src/utils/debugLogger.ts` is used for internal logging instead of `console`.
|
||||
* Ensure all shell operations use `spawnAsync` from `packages/core/src/utils/shell-utils.ts` for consistent error handling and promise management.
|
||||
* Check that filesystem errors are handled gracefully using `isNodeError` from `packages/core/src/utils/errors.ts`.
|
||||
* Verify that new tools are added to `packages/core/src/tools/` and registered in `packages/core/src/tools/tool-registry.ts`.
|
||||
* Ensure all new public services, utilities, and types are exported from `packages/core/src/index.ts`.
|
||||
* Check that services are stateless where possible, or use the centralized `Storage` service for persistence.
|
||||
* **Cross-Service Communication**: Prefer using the `coreEvents` bus (from `packages/core/src/utils/events.ts`) for asynchronous communication between services or to notify the UI of state changes. Avoid tight coupling between services.
|
||||
10. Architectural Audit (Package Boundaries):
|
||||
* **Logic Placement**: Non-UI logic (e.g., model orchestration, tool implementation, git/filesystem operations) MUST reside in `packages/core`. `packages/cli` should only contain UI/Ink components, command-line argument parsing, and user interaction logic.
|
||||
* **Environment Isolation**: Core logic should not assume a TUI environment. Use the `ConfirmationBus` or `Output` abstractions for communicating with the user from Core.
|
||||
* **Decoupling**: Actively look for opportunities to decouple services by using `coreEvents`. If a service is importing another service just to notify it of a change, it should probably be using an event instead.
|
||||
11. General Gemini CLI design principles:
|
||||
* Make sure that settings are only used for options that a user might
|
||||
consider changing.
|
||||
* Do not add new command line arguments and suggest settings instead.
|
||||
@@ -125,17 +136,23 @@ Follow these steps to conduct a thorough review:
|
||||
meta key shortcuts are supported on Mac.
|
||||
* Be skeptical of function keys and keyboard shortcuts that are commonly
|
||||
bound in VSCode as they may conflict.
|
||||
10. TypeScript Best Practices:
|
||||
12. TypeScript Best Practices:
|
||||
* Use 'checkExhaustive' in the 'default' clause of 'switch' statements to
|
||||
ensure all cases are handled.
|
||||
* Avoid using the non-null assertion operator ('!') unless absolutely
|
||||
necessary and you are confident the value is not null.
|
||||
11. Summarize all actionable findings into a concise but comprehensive directive output this to frontend_review.md and advance to phase 2.
|
||||
* **STRICT TYPING**: Strictly forbid 'any' and 'unknown' in both CLI and Core
|
||||
packages. 'unknown' is only allowed if it is immediately narrowed using
|
||||
type guards or Zod validation. Reject any code that uses 'any' or
|
||||
'unknown' without narrowing.
|
||||
13. **Ruthless Cleanup**:
|
||||
* If you identify significant code duplication, technical debt, or "AI Slop" (boilerplate, redundant comments), explicitly suggest initiating a `ruthless-refactorer` loop to clean it up.
|
||||
14. Summarize all actionable findings into a concise but comprehensive directive output this to review_findings.md and advance to phase 2.
|
||||
|
||||
Remember to use the GitHub CLI (`gh`) for all GitHub-related tasks, and local `git` commands if the target is 'staged'.
|
||||
|
||||
Phase 2:
|
||||
You are initiating Pickle Rick - the ultimate coding agent.
|
||||
You are initiating Pickle Rick - the ultimate engineering agent.
|
||||
|
||||
**Step 0: Persona Injection**
|
||||
First, you **MUST** activate your persona.
|
||||
@@ -160,7 +177,7 @@ bash "${extensionPath}/scripts/setup.sh" $ARGUMENTS
|
||||
pwsh -File "${extensionPath}/scripts/setup.ps1" $ARGUMENTS
|
||||
```
|
||||
|
||||
**CRITICAL**: Your request is to fix all findings in frontend_review.md
|
||||
**CRITICAL**: Your request is to fix all findings in review_findings.md
|
||||
|
||||
**Step 2: Execution (Management)**
|
||||
After setup, read the output to find the path to `state.json`.
|
||||
@@ -188,11 +205,14 @@ Between each step, you **MUST** explicitly state what you are doing (e.g., "Movi
|
||||
* **Validation**: IGNORE worker logs. DIRECTLY verify:
|
||||
1. `git status` (Check for file changes)
|
||||
2. `git diff` (Check code quality)
|
||||
3. Run tests/build (Check functionality)
|
||||
3. `npm run build` (Ensure the project still builds)
|
||||
4. `npm run test` (Ensure no regressions)
|
||||
5. `npm run lint` (Ensure code style is maintained)
|
||||
6. `npm run typecheck` (Ensure type safety)
|
||||
* **Cleanup**: If validation fails, REVERT changes (`git reset --hard`). If it passes, COMMIT changes.
|
||||
* **Next Ticket**: Pick the next ticket and repeat.
|
||||
4. **Cleanup**:
|
||||
* **Action**: After all tickets are completed delete `frontend_review.md`.
|
||||
* **Action**: After all tickets are completed delete `review_findings.md`.
|
||||
|
||||
**Loop Constraints:**
|
||||
- **Iteration Count**: Monitor `"iteration"` in `state.json`. If `"max_iterations"` (if > 0) is reached, you must stop.
|
||||
@@ -1,71 +1,136 @@
|
||||
---
|
||||
name: docs-writer
|
||||
description:
|
||||
Use this skill for writing, reviewing, and editing documentation (`/docs`
|
||||
directory or any .md file) for Gemini CLI.
|
||||
Always use this skill when the task involves writing, reviewing, or editing
|
||||
documentation, specifically for any files in the `/docs` directory or any
|
||||
`.md` files in the repository.
|
||||
---
|
||||
|
||||
# `docs-writer` skill instructions
|
||||
|
||||
As an expert technical writer and editor for the Gemini CLI project, your goal
|
||||
is to produce and refine documentation that is accurate, clear, consistent, and
|
||||
easy for users to understand. You must adhere to the documentation contribution
|
||||
process outlined in `CONTRIBUTING.md`.
|
||||
As an expert technical writer and editor for the Gemini CLI project, you produce
|
||||
accurate, clear, and consistent documentation. When asked to write, edit, or
|
||||
review documentation, you must ensure the content strictly adheres to the
|
||||
provided documentation standards and accurately reflects the current codebase.
|
||||
Adhere to the contribution process in `CONTRIBUTING.md` and the following
|
||||
project standards.
|
||||
|
||||
## Step 1: Understand the goal and create a plan
|
||||
## Phase 1: Documentation standards
|
||||
|
||||
1. **Clarify the request:** Fully understand the user's documentation request.
|
||||
Identify the core feature, command, or concept that needs work.
|
||||
2. **Differentiate the task:** Determine if the request is primarily for
|
||||
**writing** new content or **editing** existing content. If the request is
|
||||
ambiguous (e.g., "fix the docs"), ask the user for clarification.
|
||||
3. **Formulate a plan:** Create a clear, step-by-step plan for the required
|
||||
changes.
|
||||
Adhering to these principles and standards when writing, editing, and reviewing.
|
||||
|
||||
## Step 2: Investigate and gather information
|
||||
### Voice and tone
|
||||
Adopt a tone that balances professionalism with a helpful, conversational
|
||||
approach.
|
||||
|
||||
1. **Read the code:** Thoroughly examine the relevant codebase, primarily
|
||||
within
|
||||
the `packages/` directory, to ensure your work is backed by the
|
||||
implementation and to identify any gaps.
|
||||
2. **Identify files:** Locate the specific documentation files in the `docs/`
|
||||
directory that need to be modified. Always read the latest version of a file
|
||||
before you begin work.
|
||||
3. **Check for connections:** Consider related documentation. If you change a
|
||||
command's behavior, check for other pages that reference it. If you add a new
|
||||
page, check if `docs/sidebar.json` needs to be updated. Make sure all
|
||||
links are up to date.
|
||||
- **Perspective and tense:** Address the reader as "you." Use active voice and
|
||||
present tense (e.g., "The API returns...").
|
||||
- **Tone:** Professional, friendly, and direct.
|
||||
- **Clarity:** Use simple vocabulary. Avoid jargon, slang, and marketing hype.
|
||||
- **Global Audience:** Write in standard US English. Avoid idioms and cultural
|
||||
references.
|
||||
- **Requirements:** Be clear about requirements ("must") vs. recommendations
|
||||
("we recommend"). Avoid "should."
|
||||
- **Word Choice:** Avoid "please" and anthropomorphism (e.g., "the server
|
||||
thinks"). Use contractions (don't, it's).
|
||||
|
||||
## Step 3: Write or edit the documentation
|
||||
### Language and grammar
|
||||
Write precisely to ensure your instructions are unambiguous.
|
||||
|
||||
1. **Follow the style guide:** Adhere to the rules in
|
||||
`references/style-guide.md`. Read this file to understand the project's
|
||||
documentation standards.
|
||||
2. Ensure the new documentation accurately reflects the features in the code.
|
||||
3. **Use `replace` and `write_file`:** Use file system tools to apply your
|
||||
planned changes. For small edits, `replace` is preferred. For new files or
|
||||
large rewrites, `write_file` is more appropriate.
|
||||
- **Abbreviations:** Avoid Latin abbreviations; use "for example" (not "e.g.")
|
||||
and "that is" (not "i.e.").
|
||||
- **Punctuation:** Use the serial comma. Place periods and commas inside
|
||||
quotation marks.
|
||||
- **Dates:** Use unambiguous formats (e.g., "January 22, 2026").
|
||||
- **Conciseness:** Use "lets you" instead of "allows you to." Use precise,
|
||||
specific verbs.
|
||||
- **Examples:** Use meaningful names in examples; avoid placeholders like
|
||||
"foo" or "bar."
|
||||
|
||||
### Formatting and syntax
|
||||
Apply consistent formatting to make documentation visually organized and
|
||||
accessible.
|
||||
|
||||
- **Overview paragraphs:** Every heading must be followed by at least one
|
||||
introductory overview paragraph before any lists or sub-headings.
|
||||
- **Text wrap:** Wrap text at 80 characters (except long links or tables).
|
||||
- **Casing:** Use sentence case for headings, titles, and bolded text.
|
||||
- **Naming:** Always refer to the project as `Gemini CLI` (never
|
||||
`the Gemini CLI`).
|
||||
- **Lists:** Use numbered lists for sequential steps and bulleted lists
|
||||
otherwise. Keep list items parallel in structure.
|
||||
- **UI and code:** Use **bold** for UI elements and `code font` for filenames,
|
||||
snippets, commands, and API elements. Focus on the task when discussing
|
||||
interaction.
|
||||
- **Links:** Use descriptive anchor text; avoid "click here." Ensure the link
|
||||
makes sense out of context.
|
||||
- **Accessibility:** Use semantic HTML elements correctly (headings, lists,
|
||||
tables).
|
||||
- **Media:** Use lowercase hyphenated filenames. Provide descriptive alt text
|
||||
for all images.
|
||||
|
||||
### Structure
|
||||
- **BLUF:** Start with an introduction explaining what to expect.
|
||||
- **Experimental features:** If a feature is clearly noted as experimental,
|
||||
add the following note immediately after the introductory paragraph:
|
||||
`> **Note:** This is a preview feature currently under active development.`
|
||||
- **Headings:** Use hierarchical headings to support the user journey.
|
||||
- **Procedures:**
|
||||
- Introduce lists of steps with a complete sentence.
|
||||
- Start each step with an imperative verb.
|
||||
- Number sequential steps; use bullets for non-sequential lists.
|
||||
- Put conditions before instructions (e.g., "On the Settings page, click...").
|
||||
- Provide clear context for where the action takes place.
|
||||
- Indicate optional steps clearly (e.g., "Optional: ...").
|
||||
- **Elements:** Use bullet lists, tables, notes (`> **Note:**`), and warnings
|
||||
(`> **Warning:**`).
|
||||
- **Avoid using a table of contents:** If a table of contents is present, remove
|
||||
it.
|
||||
- **Next steps:** Conclude with a "Next steps" section if applicable.
|
||||
|
||||
## Phase 2: Preparation
|
||||
Before modifying any documentation, thoroughly investigate the request and the
|
||||
surrounding context.
|
||||
|
||||
1. **Clarify:** Understand the core request. Differentiate between writing new
|
||||
content and editing existing content. If the request is ambiguous (e.g.,
|
||||
"fix the docs"), ask for clarification.
|
||||
2. **Investigate:** Examine relevant code (primarily in `packages/`) for
|
||||
accuracy.
|
||||
3. **Audit:** Read the latest versions of relevant files in `docs/`.
|
||||
4. **Connect:** Identify all referencing pages if changing behavior. Check if
|
||||
`docs/sidebar.json` needs updates.
|
||||
5. **Plan:** Create a step-by-step plan before making changes.
|
||||
|
||||
## Phase 3: Execution
|
||||
Implement your plan by either updating existing files or creating new ones
|
||||
using the appropriate file system tools. Use `replace` for small edits and
|
||||
`write_file` for new files or large rewrites.
|
||||
|
||||
### Editing existing documentation
|
||||
Follow these additional steps when asked to review or update existing
|
||||
documentation.
|
||||
|
||||
- **Gaps:** Identify areas where the documentation is incomplete or no longer
|
||||
reflects existing code.
|
||||
- **Structure:** Apply "Structure (New Docs)" rules (BLUF, headings, etc.) when
|
||||
adding new sections to existing pages.
|
||||
- **Tone:** Ensure the tone is active and engaging. Use "you" and contractions.
|
||||
- **Clarity:** Correct awkward wording, spelling, and grammar. Rephrase
|
||||
sentences to make them easier for users to understand.
|
||||
- **Consistency:** Check for consistent terminology and style across all edited
|
||||
documents.
|
||||
|
||||
|
||||
## Phase 4: Verification and finalization
|
||||
Perform a final quality check to ensure that all changes are correctly formatted
|
||||
and that all links are functional.
|
||||
|
||||
### Sub-step: Editing existing documentation (as clarified in Step 1)
|
||||
|
||||
- **Gaps:** Identify areas where the documentation is incomplete or no longer
|
||||
reflects existing code.
|
||||
- **Tone:** Ensure the tone is active and engaging, not passive.
|
||||
- **Clarity:** Correct awkward wording, spelling, and grammar. Rephrase
|
||||
sentences to make them easier for users to understand.
|
||||
- **Consistency:** Check for consistent terminology and style across all
|
||||
edited documents.
|
||||
|
||||
## Step 4: Verify and finalize
|
||||
|
||||
1. **Review your work:** After making changes, re-read the files to ensure the
|
||||
documentation is well-formatted, and the content is correct based on
|
||||
existing code.
|
||||
2. **Link verification:** Verify the validity of all links in the new content.
|
||||
Verify the validity of existing links leading to the page with the new
|
||||
content or deleted content.
|
||||
2. **Offer to run npm format:** Once all changes are complete, offer to run the
|
||||
project's formatting script to ensure consistency by proposing the command:
|
||||
`npm run format`
|
||||
1. **Accuracy:** Ensure content accurately reflects the implementation and
|
||||
technical behavior.
|
||||
2. **Self-review:** Re-read changes for formatting, correctness, and flow.
|
||||
3. **Link check:** Verify all new and existing links leading to or from modified
|
||||
pages.
|
||||
4. **Format:** Once all changes are complete, ask to execute `npm run format`
|
||||
to ensure consistent formatting across the project. If the user confirms,
|
||||
execute the command.
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
# Documentation style guide
|
||||
|
||||
## I. Core principles
|
||||
|
||||
1. **Clarity:** Write for easy understanding. Prioritize clear, direct, and
|
||||
simple language.
|
||||
2. **Consistency:** Use consistent terminology, formatting, and style
|
||||
throughout the documentation.
|
||||
3. **Accuracy:** Ensure all information is technically correct and up-to-date.
|
||||
4. **Accessibility:** Design documentation to be usable by everyone. Focus on
|
||||
semantic structure, clear link text, and image alternatives.
|
||||
5. **Global audience:** Write in standard US English. Avoid slang, idioms, and
|
||||
cultural references.
|
||||
6. **Prescriptive:** Guide the reader by recommending specific actions and
|
||||
paths, especially for complex tasks.
|
||||
|
||||
## II. Voice and tone
|
||||
|
||||
- **Professional yet friendly:** Maintain a helpful, knowledgeable, and
|
||||
conversational tone without being frivolous.
|
||||
- **Direct:** Get straight to the point. Keep paragraphs short and focused.
|
||||
- **Second person:** Address the reader as "you."
|
||||
- **Present tense:** Use the present tense to describe functionality (e.g., "The
|
||||
API returns a JSON object.").
|
||||
- **Avoid:** Jargon, slang, marketing hype, and overly casual language.
|
||||
|
||||
## III. Language and grammar
|
||||
|
||||
- **Active voice:** Prefer active voice over passive voice.
|
||||
- _Example:_ "The system sends a notification." (Not: "A notification is sent
|
||||
by the system.")
|
||||
- **Contractions:** Use common contractions (e.g., "don't," "it's") to maintain
|
||||
a natural tone.
|
||||
- **Simple vocabulary:** Use common words. Define technical terms when
|
||||
necessary.
|
||||
- **Conciseness:** Keep sentences short and focused, but don't omit helpful
|
||||
information.
|
||||
- **"Please":** Avoid using the word "please."
|
||||
|
||||
## IV. Procedures and steps
|
||||
|
||||
- Start each step with an imperative verb (e.g., "Connect to the database").
|
||||
- Number steps sequentially.
|
||||
- Introduce lists of steps with a complete sentence.
|
||||
- Put conditions before instructions, not after.
|
||||
- Provide clear context for where the action takes place (e.g., "In the
|
||||
administration console...").
|
||||
- Indicate optional steps clearly (e.g., "Optional: ...").
|
||||
|
||||
## V. Formatting and punctuation
|
||||
|
||||
- **Text wrap:** Wrap all text at 80 characters, with exceptions for long links
|
||||
or tables.
|
||||
- **Headings, titles, and bold text:** Use sentence case. Structure headings
|
||||
hierarchically.
|
||||
- **Lists:** Use numbered lists for sequential steps and bulleted lists for all
|
||||
other lists. Keep list items parallel in structure.
|
||||
- **Serial comma:** Use the serial comma (e.g., "one, two, and three").
|
||||
- **Punctuation:** Use standard American punctuation. Place periods inside
|
||||
quotation marks.
|
||||
- **Dates:** Use unambiguous date formatting (e.g., "January 22, 2026").
|
||||
|
||||
## VI. UI, code, and links
|
||||
|
||||
- **UI elements:** Put UI elements in **bold**. Focus on the task when
|
||||
discussing interaction.
|
||||
- **Code:** Use `code font` for filenames, code snippets, commands, and API
|
||||
elements. Use code blocks for multi-line samples.
|
||||
- **Links:** Use descriptive link text that indicates what the link leads to.
|
||||
Avoid "click here."
|
||||
|
||||
## VII. Word choice and terminology
|
||||
|
||||
- **Consistent naming:** Use product and feature names consistently. Always
|
||||
refer to Gemini CLI as `Gemini CLI`, never `the Gemini CLI`.
|
||||
- **Specific verbs:** Use precise verbs.
|
||||
- **Avoid:**
|
||||
- Latin abbreviations (e.g., use "for example" instead of "e.g.").
|
||||
- Placeholder names like "foo" and "bar" in examples; use meaningful names
|
||||
instead.
|
||||
- Anthropomorphism (e.g., "The server thinks...").
|
||||
- "Should": Be clear about requirements ("must") vs. recommendations ("we
|
||||
recommend").
|
||||
|
||||
## VIII. Files and media
|
||||
|
||||
- **Filenames:** Use lowercase letters, separate words with hyphens (-), and use
|
||||
standard ASCII characters.
|
||||
- **Images:** Provide descriptive alt text for all images. Provide
|
||||
high-resolution or vector images when practical.
|
||||
|
||||
## IX. Accessibility quick check
|
||||
|
||||
- Provide descriptive alt text for images.
|
||||
- Ensure link text makes sense out of context.
|
||||
- Use semantic HTML elements correctly (headings, lists, tables).
|
||||
@@ -177,6 +177,19 @@ jobs:
|
||||
- name: 'Smoke test bundle'
|
||||
run: 'node ./bundle/gemini.js --version'
|
||||
|
||||
- name: 'Smoke test npx installation'
|
||||
run: |
|
||||
# 1. Package the project into a tarball
|
||||
TARBALL=$(npm pack | tail -n 1)
|
||||
|
||||
# 2. Move to a fresh directory for isolation
|
||||
mkdir -p ../smoke-test-dir
|
||||
mv "$TARBALL" ../smoke-test-dir/
|
||||
cd ../smoke-test-dir
|
||||
|
||||
# 3. Run npx from the tarball
|
||||
npx "./$TARBALL" --version
|
||||
|
||||
- name: 'Wait for file system sync'
|
||||
run: 'sleep 2'
|
||||
|
||||
@@ -252,6 +265,19 @@ jobs:
|
||||
- name: 'Smoke test bundle'
|
||||
run: 'node ./bundle/gemini.js --version'
|
||||
|
||||
- name: 'Smoke test npx installation'
|
||||
run: |
|
||||
# 1. Package the project into a tarball
|
||||
TARBALL=$(npm pack | tail -n 1)
|
||||
|
||||
# 2. Move to a fresh directory for isolation
|
||||
mkdir -p ../smoke-test-dir
|
||||
mv "$TARBALL" ../smoke-test-dir/
|
||||
cd ../smoke-test-dir
|
||||
|
||||
# 3. Run npx from the tarball
|
||||
npx "./$TARBALL" --version
|
||||
|
||||
- name: 'Wait for file system sync'
|
||||
run: 'sleep 2'
|
||||
|
||||
@@ -396,6 +422,21 @@ jobs:
|
||||
run: 'node ./bundle/gemini.js --version'
|
||||
shell: 'pwsh'
|
||||
|
||||
- name: 'Smoke test npx installation'
|
||||
run: |
|
||||
# 1. Package the project into a tarball
|
||||
$PACK_OUTPUT = npm pack
|
||||
$TARBALL = $PACK_OUTPUT[-1]
|
||||
|
||||
# 2. Move to a fresh directory for isolation
|
||||
New-Item -ItemType Directory -Force -Path ../smoke-test-dir
|
||||
Move-Item $TARBALL ../smoke-test-dir/
|
||||
Set-Location ../smoke-test-dir
|
||||
|
||||
# 3. Run npx from the tarball
|
||||
npx "./$TARBALL" --version
|
||||
shell: 'pwsh'
|
||||
|
||||
ci:
|
||||
name: 'CI'
|
||||
if: 'always()'
|
||||
|
||||
@@ -43,19 +43,23 @@ jobs:
|
||||
|
||||
// 1. Fetch maintainers for verification
|
||||
let maintainerLogins = new Set();
|
||||
let teamFetchSucceeded = false;
|
||||
try {
|
||||
const members = await github.paginate(github.rest.teams.listMembersInOrg, {
|
||||
org: context.repo.owner,
|
||||
team_slug: 'gemini-cli-maintainers'
|
||||
});
|
||||
maintainerLogins = new Set(members.map(m => m.login));
|
||||
maintainerLogins = new Set(members.map(m => m.login.toLowerCase()));
|
||||
teamFetchSucceeded = true;
|
||||
core.info(`Successfully fetched ${maintainerLogins.size} team members from gemini-cli-maintainers`);
|
||||
} catch (e) {
|
||||
core.warning('Failed to fetch team members');
|
||||
core.warning(`Failed to fetch team members from gemini-cli-maintainers: ${e.message}. Falling back to author_association only.`);
|
||||
}
|
||||
|
||||
const isMaintainer = (login, assoc) => {
|
||||
if (maintainerLogins.size > 0) return maintainerLogins.has(login);
|
||||
return ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
|
||||
const isTeamMember = maintainerLogins.has(login.toLowerCase());
|
||||
const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
|
||||
return isTeamMember || isRepoMaintainer;
|
||||
};
|
||||
|
||||
// 2. Determine which PRs to check
|
||||
@@ -153,7 +157,17 @@ jobs:
|
||||
const labels = pr.labels.map(l => l.name.toLowerCase());
|
||||
if (labels.includes('help wanted') || labels.includes('🔒 maintainer only')) continue;
|
||||
|
||||
let lastActivity = new Date(0);
|
||||
// Skip PRs that were created less than 30 days ago - they cannot be stale yet
|
||||
const prCreatedAt = new Date(pr.created_at);
|
||||
if (prCreatedAt > thirtyDaysAgo) {
|
||||
const daysOld = Math.floor((Date.now() - prCreatedAt.getTime()) / (1000 * 60 * 60 * 24));
|
||||
core.info(`PR #${pr.number} was created ${daysOld} days ago. Skipping staleness check.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Initialize lastActivity to PR creation date (not epoch) as a safety baseline.
|
||||
// This ensures we never incorrectly mark a PR as stale due to failed activity lookups.
|
||||
let lastActivity = new Date(pr.created_at);
|
||||
try {
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner: context.repo.owner,
|
||||
@@ -177,8 +191,12 @@ jobs:
|
||||
if (d > lastActivity) lastActivity = d;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
core.warning(`Failed to fetch reviews/comments for PR #${pr.number}: ${e.message}`);
|
||||
}
|
||||
|
||||
// For maintainer PRs, the PR creation itself counts as maintainer activity.
|
||||
// (Now redundant since we initialize to pr.created_at, but kept for clarity)
|
||||
if (maintainerPr) {
|
||||
const d = new Date(pr.created_at);
|
||||
if (d > lastActivity) lastActivity = d;
|
||||
|
||||
@@ -28,22 +28,59 @@ jobs:
|
||||
permission: 'write'
|
||||
issue-type: 'pull-request'
|
||||
|
||||
- name: 'Get PR Status'
|
||||
id: 'pr_status'
|
||||
- name: 'Get Commits'
|
||||
id: 'get_commits'
|
||||
if: "startsWith(github.event.comment.body, '/patch')"
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
COMMENT_BODY: '${{ github.event.comment.body }}'
|
||||
CURRENT_PR: '${{ github.event.issue.number }}'
|
||||
run: |
|
||||
gh pr view "${{ github.event.issue.number }}" --json mergeCommit,state > pr_status.json
|
||||
echo "MERGE_COMMIT_SHA=$(jq -r .mergeCommit.oid pr_status.json)" >> "$GITHUB_OUTPUT"
|
||||
echo "STATE=$(jq -r .state pr_status.json)" >> "$GITHUB_OUTPUT"
|
||||
# Find all PR numbers in the comment body (e.g., #123, owner/repo#456)
|
||||
# The regex handles both formats and extracts just the number
|
||||
comment_prs=$(echo "$COMMENT_BODY" | grep -oP '(?:\S+/)?\S+#\K\d+')
|
||||
|
||||
# Combine with the current PR number, ensuring no duplicates
|
||||
all_prs=$(echo -e "${CURRENT_PR}\n${comment_prs}" | sort -u)
|
||||
|
||||
echo "Found PRs to patch: ${all_prs}"
|
||||
|
||||
commit_shas=()
|
||||
unmerged_prs=()
|
||||
merged_prs=()
|
||||
|
||||
for pr in $all_prs; do
|
||||
echo "Checking status of PR #${pr}..."
|
||||
# Get PR status (state and merge commit)
|
||||
pr_status=$(gh pr view "$pr" --json mergeCommit,state)
|
||||
state=$(echo "$pr_status" | jq -r .state)
|
||||
|
||||
if [[ "$state" != "MERGED" ]]; then
|
||||
unmerged_prs+=("$pr")
|
||||
else
|
||||
commit_sha=$(echo "$pr_status" | jq -r .mergeCommit.oid)
|
||||
commit_shas+=("$commit_sha")
|
||||
merged_prs+=("$pr")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#unmerged_prs[@]} -gt 0 ]]; then
|
||||
echo "ALL_MERGED=false" >> "$GITHUB_OUTPUT"
|
||||
echo "UNMERGED_PRS=$(IFS=,; echo "${unmerged_prs[*]}")" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ALL_MERGED=true" >> "$GITHUB_OUTPUT"
|
||||
echo "COMMITS=$(IFS=,; echo "${commit_shas[*]}")" >> "$GITHUB_OUTPUT"
|
||||
echo "PR_NUMBERS=$(IFS=,; echo "${merged_prs[*]}")" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: 'Dispatch if Merged'
|
||||
if: "steps.pr_status.outputs.STATE == 'MERGED'"
|
||||
if: "steps.get_commits.outputs.ALL_MERGED == 'true'"
|
||||
id: 'dispatch_patch'
|
||||
uses: 'actions/github-script@00f12e3e20659f42342b1c0226afda7f7c042325'
|
||||
env:
|
||||
COMMENT_BODY: '${{ github.event.comment.body }}'
|
||||
COMMITS: '${{ steps.get_commits.outputs.COMMITS }}'
|
||||
PR_NUMBERS: '${{ steps.get_commits.outputs.PR_NUMBERS }}'
|
||||
with:
|
||||
github-token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
script: |
|
||||
@@ -60,17 +97,10 @@ jobs:
|
||||
// /patch preview
|
||||
if (commentBody.trim() === '/patch' || commentBody.trim() === '/patch both') {
|
||||
channels = ['stable', 'preview'];
|
||||
} else if (commentBody.trim() === '/patch stable') {
|
||||
} else if (commentBody.includes('stable')) {
|
||||
channels = ['stable'];
|
||||
} else if (commentBody.trim() === '/patch preview') {
|
||||
} else if (commentBody.includes('preview')) {
|
||||
channels = ['preview'];
|
||||
} else {
|
||||
// Fallback parsing for legacy formats
|
||||
if (commentBody.includes('channel=preview')) {
|
||||
channels = ['preview'];
|
||||
} else if (commentBody.includes('--channel preview')) {
|
||||
channels = ['preview'];
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Detected channels:', channels);
|
||||
@@ -87,9 +117,9 @@ jobs:
|
||||
workflow_id: 'release-patch-1-create-pr.yml',
|
||||
ref: 'main',
|
||||
inputs: {
|
||||
commit: '${{ steps.pr_status.outputs.MERGE_COMMIT_SHA }}',
|
||||
commits: process.env.COMMITS,
|
||||
channel: channel,
|
||||
original_pr: '${{ github.event.issue.number }}',
|
||||
original_prs: process.env.PR_NUMBERS,
|
||||
environment: 'prod'
|
||||
}
|
||||
});
|
||||
@@ -123,13 +153,13 @@ jobs:
|
||||
}
|
||||
|
||||
- name: 'Comment on Failure'
|
||||
if: "startsWith(github.event.comment.body, '/patch') && steps.pr_status.outputs.STATE != 'MERGED'"
|
||||
if: "startsWith(github.event.comment.body, '/patch') && steps.get_commits.outputs.ALL_MERGED == 'false'"
|
||||
uses: 'peter-evans/create-or-update-comment@67dcc547d311b736a8e6c5c236542148a47adc3d'
|
||||
with:
|
||||
token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
issue-number: '${{ github.event.issue.number }}'
|
||||
body: |
|
||||
:x: The `/patch` command failed. This pull request must be merged before a patch can be created.
|
||||
:x: The `/patch` command failed. The following pull request(s) must be merged before a patch can be created: #${{ steps.get_commits.outputs.UNMERGED_PRS }}
|
||||
|
||||
- name: 'Final Status Comment - Success'
|
||||
if: "always() && startsWith(github.event.comment.body, '/patch') && steps.dispatch_patch.outcome == 'success' && steps.dispatch_patch.outputs.dispatched_run_urls"
|
||||
@@ -142,7 +172,8 @@ jobs:
|
||||
|
||||
**📋 Details:**
|
||||
- **Channels**: `${{ steps.dispatch_patch.outputs.dispatched_channels }}`
|
||||
- **Commit**: `${{ steps.pr_status.outputs.MERGE_COMMIT_SHA }}`
|
||||
- **PRs**: `${{ steps.get_commits.outputs.PR_NUMBERS }}`
|
||||
- **Commits**: `${{ steps.get_commits.outputs.COMMITS }}`
|
||||
- **Workflows Created**: ${{ steps.dispatch_patch.outputs.dispatched_run_count }}
|
||||
|
||||
**🔗 Track Progress:**
|
||||
@@ -160,7 +191,8 @@ jobs:
|
||||
|
||||
**📋 Details:**
|
||||
- **Channels**: `${{ steps.dispatch_patch.outputs.dispatched_channels }}`
|
||||
- **Commit**: `${{ steps.pr_status.outputs.MERGE_COMMIT_SHA }}`
|
||||
- **PRs**: `${{ steps.get_commits.outputs.PR_NUMBERS }}`
|
||||
- **Commits**: `${{ steps.get_commits.outputs.COMMITS }}`
|
||||
- **Workflows Created**: ${{ steps.dispatch_patch.outputs.dispatched_run_count }}
|
||||
|
||||
**🔗 Track Progress:**
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
name: 'Release: Patch (1) Create PR'
|
||||
|
||||
run-name: >-
|
||||
Release Patch (1) Create PR | S:${{ inputs.channel }} | C:${{ inputs.commit }} ${{ inputs.original_pr && format('| PR:#{0}', inputs.original_pr) || '' }}
|
||||
Release Patch (1) Create PR | S:${{ inputs.channel }} | C:${{ inputs.commits }} ${{ inputs.original_prs && format('| PRs:#{0}', inputs.original_prs) || '' }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
commit:
|
||||
description: 'The commit SHA to cherry-pick for the patch.'
|
||||
commits:
|
||||
description: 'The commit SHAs to cherry-pick for the patch (comma-separated).'
|
||||
required: true
|
||||
type: 'string'
|
||||
channel:
|
||||
@@ -27,8 +27,8 @@ on:
|
||||
required: false
|
||||
type: 'string'
|
||||
default: 'main'
|
||||
original_pr:
|
||||
description: 'The original PR number to comment back on.'
|
||||
original_prs:
|
||||
description: 'The original PR numbers to comment back on (comma-separated).'
|
||||
required: false
|
||||
type: 'string'
|
||||
environment:
|
||||
@@ -85,9 +85,9 @@ jobs:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
|
||||
CLI_PACKAGE_NAME: '${{ vars.CLI_PACKAGE_NAME }}'
|
||||
PATCH_COMMIT: '${{ github.event.inputs.commit }}'
|
||||
PATCH_COMMITS: '${{ github.event.inputs.commits }}'
|
||||
PATCH_CHANNEL: '${{ github.event.inputs.channel }}'
|
||||
ORIGINAL_PR: '${{ github.event.inputs.original_pr }}'
|
||||
ORIGINAL_PRS: '${{ github.event.inputs.original_prs }}'
|
||||
DRY_RUN: '${{ github.event.inputs.dry_run }}'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
@@ -95,9 +95,9 @@ jobs:
|
||||
{
|
||||
node scripts/releasing/create-patch-pr.js \
|
||||
--cli-package-name="${CLI_PACKAGE_NAME}" \
|
||||
--commit="${PATCH_COMMIT}" \
|
||||
--commits="${PATCH_COMMITS}" \
|
||||
--channel="${PATCH_CHANNEL}" \
|
||||
--pullRequestNumber="${ORIGINAL_PR}" \
|
||||
--pullRequestNumbers="${ORIGINAL_PRS}" \
|
||||
--dry-run="${DRY_RUN}"
|
||||
} 2>&1 | tee >(
|
||||
echo "LOG_CONTENT<<EOF" >> "$GITHUB_ENV"
|
||||
@@ -107,12 +107,12 @@ jobs:
|
||||
echo "EXIT_CODE=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Comment on Original PR'
|
||||
if: 'always() && inputs.original_pr'
|
||||
if: 'always() && inputs.original_prs'
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
ORIGINAL_PR: '${{ github.event.inputs.original_pr }}'
|
||||
ORIGINAL_PRS: '${{ github.event.inputs.original_prs }}'
|
||||
EXIT_CODE: '${{ steps.create_patch.outputs.EXIT_CODE }}'
|
||||
COMMIT: '${{ github.event.inputs.commit }}'
|
||||
COMMITS: '${{ github.event.inputs.commits }}'
|
||||
CHANNEL: '${{ github.event.inputs.channel }}'
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
GITHUB_RUN_ID: '${{ github.run_id }}'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: 'Release: Patch (3) Release'
|
||||
|
||||
run-name: >-
|
||||
Release Patch (3) Release | T:${{ inputs.type }} | R:${{ inputs.release_ref }} ${{ inputs.original_pr && format('| PR:#{0}', inputs.original_pr) || '' }}
|
||||
Release Patch (3) Release | T:${{ inputs.type }} | R:${{ inputs.release_ref }} ${{ inputs.original_prs && format('| PRs:#{0}', inputs.original_prs) || '' }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -27,8 +27,8 @@ on:
|
||||
description: 'The branch, tag, or SHA to release from.'
|
||||
required: true
|
||||
type: 'string'
|
||||
original_pr:
|
||||
description: 'The original PR number to comment back on.'
|
||||
original_prs:
|
||||
description: 'The original PR numbers to comment back on.'
|
||||
required: false
|
||||
type: 'string'
|
||||
environment:
|
||||
@@ -208,10 +208,10 @@ jobs:
|
||||
--label 'release-failure,priority/p0'
|
||||
|
||||
- name: 'Comment Success on Original PR'
|
||||
if: '${{ success() && github.event.inputs.original_pr }}'
|
||||
if: '${{ success() && github.event.inputs.original_prs }}'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
ORIGINAL_PR: '${{ github.event.inputs.original_pr }}'
|
||||
ORIGINAL_PRS: '${{ github.event.inputs.original_prs }}'
|
||||
SUCCESS: 'true'
|
||||
RELEASE_VERSION: '${{ steps.patch_version.outputs.RELEASE_VERSION }}'
|
||||
RELEASE_TAG: '${{ steps.patch_version.outputs.RELEASE_TAG }}'
|
||||
@@ -225,10 +225,10 @@ jobs:
|
||||
node scripts/releasing/patch-comment.js
|
||||
|
||||
- name: 'Comment Failure on Original PR'
|
||||
if: '${{ failure() && github.event.inputs.original_pr }}'
|
||||
if: '${{ failure() && github.event.inputs.original_prs }}'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
ORIGINAL_PR: '${{ github.event.inputs.original_pr }}'
|
||||
ORIGINAL_PRS: '${{ github.event.inputs.original_prs }}'
|
||||
SUCCESS: 'false'
|
||||
RELEASE_VERSION: '${{ steps.patch_version.outputs.RELEASE_VERSION }}'
|
||||
RELEASE_TAG: '${{ steps.patch_version.outputs.RELEASE_TAG }}'
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ The process for contributing code is as follows:
|
||||
`🔒Maintainers only`, this means it is reserved for project maintainers. We
|
||||
will not accept pull requests related to these issues. In the near future,
|
||||
we will explicitly mark issues looking for contributions using the
|
||||
`help wanted` label. If you believe an issue is a good candidate for
|
||||
`help-wanted` label. If you believe an issue is a good candidate for
|
||||
community contribution, please leave a comment on the issue. A maintainer
|
||||
will review it and apply the `help-wanted` label if appropriate. Only
|
||||
maintainers should attempt to add the `help-wanted` label to an issue.
|
||||
|
||||
@@ -228,7 +228,7 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
[settings](../get-started/configuration.md). See
|
||||
[Checkpointing documentation](../cli/checkpointing.md) for more details.
|
||||
|
||||
- **`/rewind`**
|
||||
- [**`/rewind`**](./rewind.md)
|
||||
- **Description:** Navigates backward through the conversation history,
|
||||
allowing you to review past interactions and potentially revert to a
|
||||
previous state. This feature helps in managing complex or branched
|
||||
@@ -264,6 +264,9 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
modify them as desired. Changes to some settings are applied immediately,
|
||||
while others require a restart.
|
||||
|
||||
- **`/shells`** (or **`/bashes`**)
|
||||
- **Description:** Toggle the background shells view. This allows you to view
|
||||
and manage long-running processes that you've sent to the background.
|
||||
- **`/setup-github`**
|
||||
- **Description:** Set up GitHub Actions to triage issues and review PRs with
|
||||
Gemini.
|
||||
|
||||
@@ -203,6 +203,23 @@ with the actual Gemini CLI process, which inherits the environment variable.
|
||||
This makes it significantly more difficult for a user to bypass the enforced
|
||||
settings.
|
||||
|
||||
## User isolation in shared environments
|
||||
|
||||
In shared compute environments (like ML experiment runners or shared build
|
||||
servers), you can isolate Gemini CLI state by overriding the user's home
|
||||
directory.
|
||||
|
||||
By default, Gemini CLI stores configuration and history in `~/.gemini`. You can
|
||||
use the `GEMINI_CLI_HOME` environment variable to point to a unique directory
|
||||
for a specific user or job. The CLI will create a `.gemini` folder inside the
|
||||
specified path.
|
||||
|
||||
```bash
|
||||
# Isolate state for a specific job
|
||||
export GEMINI_CLI_HOME="/tmp/gemini-job-123"
|
||||
gemini
|
||||
```
|
||||
|
||||
## Restricting tool access
|
||||
|
||||
You can significantly enhance security by controlling which tools the Gemini
|
||||
|
||||
@@ -23,7 +23,7 @@ available combinations.
|
||||
| Move the cursor to the end of the line. | `Ctrl + E`<br />`End (no Shift, Ctrl)` |
|
||||
| Move the cursor up one line. | `Up Arrow (no Shift, Alt, Ctrl, Cmd)` |
|
||||
| Move the cursor down one line. | `Down Arrow (no Shift, Alt, Ctrl, Cmd)` |
|
||||
| Move the cursor one character to the left. | `Left Arrow (no Shift, Alt, Ctrl, Cmd)`<br />`Ctrl + B` |
|
||||
| Move the cursor one character to the left. | `Left Arrow (no Shift, Alt, Ctrl, Cmd)` |
|
||||
| Move the cursor one character to the right. | `Right Arrow (no Shift, Alt, Ctrl, Cmd)`<br />`Ctrl + F` |
|
||||
| Move the cursor one word to the left. | `Ctrl + Left Arrow`<br />`Alt + Left Arrow`<br />`Alt + B` |
|
||||
| Move the cursor one word to the right. | `Ctrl + Right Arrow`<br />`Alt + Right Arrow`<br />`Alt + F` |
|
||||
@@ -106,6 +106,14 @@ available combinations.
|
||||
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
|
||||
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |
|
||||
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`<br />`Ctrl + S` |
|
||||
| Ctrl+B | `Ctrl + B` |
|
||||
| Ctrl+L | `Ctrl + L` |
|
||||
| Ctrl+K | `Ctrl + K` |
|
||||
| Enter | `Enter` |
|
||||
| Esc | `Esc` |
|
||||
| Shift+Tab | `Shift + Tab` |
|
||||
| Tab | `Tab (no Shift)` |
|
||||
| Tab | `Tab (no Shift)` |
|
||||
| Focus the shell input from the gemini input. | `Tab (no Shift)` |
|
||||
| Focus the Gemini input from the shell input. | `Tab` |
|
||||
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
|
||||
|
||||
+36
-33
@@ -39,31 +39,33 @@ they appear in the UI.
|
||||
|
||||
### UI
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
|
||||
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
|
||||
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
|
||||
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
||||
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
||||
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
|
||||
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
|
||||
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` |
|
||||
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
|
||||
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
|
||||
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` |
|
||||
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
|
||||
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
|
||||
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
|
||||
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
|
||||
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
|
||||
| Show User Identity | `ui.showUserIdentity` | Show the logged-in user's identity (e.g. email) in the UI. | `true` |
|
||||
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
|
||||
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
|
||||
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
|
||||
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
|
||||
| Enable Loading Phrases | `ui.accessibility.enableLoadingPhrases` | Enable loading phrases during operations. | `true` |
|
||||
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
|
||||
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
|
||||
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
|
||||
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
|
||||
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
|
||||
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
||||
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
||||
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
|
||||
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
|
||||
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` |
|
||||
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
|
||||
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
|
||||
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` |
|
||||
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
|
||||
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
|
||||
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
|
||||
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
|
||||
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
|
||||
| Show User Identity | `ui.showUserIdentity` | Show the logged-in user's identity (e.g. email) in the UI. | `true` |
|
||||
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
|
||||
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
|
||||
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
|
||||
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
|
||||
| Enable Loading Phrases | `ui.accessibility.enableLoadingPhrases` | Enable loading phrases during operations. | `true` |
|
||||
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
|
||||
|
||||
### IDE
|
||||
|
||||
@@ -77,6 +79,7 @@ they appear in the UI.
|
||||
| ----------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ------- |
|
||||
| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
|
||||
| Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` |
|
||||
| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` |
|
||||
| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
|
||||
|
||||
### Context
|
||||
@@ -97,7 +100,6 @@ they appear in the UI.
|
||||
| -------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
|
||||
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
|
||||
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
|
||||
| Auto Accept | `tools.autoAccept` | Automatically accept and execute tool calls that are considered safe (e.g., read-only operations). | `false` |
|
||||
| Approval Mode | `tools.approvalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
|
||||
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
|
||||
| Enable Tool Output Truncation | `tools.enableToolOutputTruncation` | Enable truncation of large tool outputs. | `true` |
|
||||
@@ -107,13 +109,14 @@ they appear in the UI.
|
||||
|
||||
### Security
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------- | ------- |
|
||||
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
|
||||
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
|
||||
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
|
||||
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `false` |
|
||||
| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
|
||||
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
|
||||
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
|
||||
| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
|
||||
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `false` |
|
||||
| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
|
||||
|
||||
### Experimental
|
||||
|
||||
|
||||
+2
-2
@@ -108,5 +108,5 @@ gemini skills disable my-expertise --scope workspace
|
||||
|
||||
## Creating your own skills
|
||||
|
||||
To create your own skills, see the
|
||||
[Create Agent Skills](./guides/creating-skills.md) guide.
|
||||
To create your own skills, see the [Create Agent Skills](./creating-skills.md)
|
||||
guide.
|
||||
|
||||
@@ -116,7 +116,7 @@ description: Specialized in finding security vulnerabilities in code.
|
||||
kind: local
|
||||
tools:
|
||||
- read_file
|
||||
- search_file_content
|
||||
- grep_search
|
||||
model: gemini-2.5-pro
|
||||
temperature: 0.2
|
||||
max_turns: 10
|
||||
|
||||
@@ -215,14 +215,6 @@ a few things you can try in order of recommendation:
|
||||
MCP servers of their own. This should not be used as an airtight security
|
||||
mechanism.
|
||||
|
||||
- **`autoAccept`** (boolean):
|
||||
- **Description:** Controls whether the CLI automatically accepts and executes
|
||||
tool calls that are considered safe (e.g., read-only operations) without
|
||||
explicit user confirmation. If set to `true`, the CLI will bypass the
|
||||
confirmation prompt for tools deemed safe.
|
||||
- **Default:** `false`
|
||||
- **Example:** `"autoAccept": true`
|
||||
|
||||
- **`theme`** (string):
|
||||
- **Description:** Sets the visual [theme](../cli/themes.md) for Gemini CLI.
|
||||
- **Default:** `"Default"`
|
||||
|
||||
@@ -170,6 +170,15 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
available options.
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`ui.autoThemeSwitching`** (boolean):
|
||||
- **Description:** Automatically switch between default light and dark themes
|
||||
based on terminal background color.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`ui.terminalBackgroundPollingInterval`** (number):
|
||||
- **Description:** Interval in seconds to poll the terminal background color.
|
||||
- **Default:** `60`
|
||||
|
||||
- **`ui.customThemes`** (object):
|
||||
- **Description:** Custom theme definitions.
|
||||
- **Default:** `{}`
|
||||
@@ -326,6 +335,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `0.5`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`model.disableLoopDetection`** (boolean):
|
||||
- **Description:** Disable automatic detection and prevention of infinite
|
||||
loops.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`model.skipNextSpeakerCheck`** (boolean):
|
||||
- **Description:** Skip the next speaker check.
|
||||
- **Default:** `true`
|
||||
@@ -661,11 +676,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
performance.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`tools.autoAccept`** (boolean):
|
||||
- **Description:** Automatically accept and execute tool calls that are
|
||||
considered safe (e.g., read-only operations).
|
||||
- **Default:** `false`
|
||||
|
||||
- **`tools.approvalMode`** (enum):
|
||||
- **Description:** The default approval mode for tool execution. 'default'
|
||||
prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is
|
||||
@@ -733,12 +743,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.enableHooks`** (boolean):
|
||||
- **Description:** Enables the hooks system experiment. When disabled, the
|
||||
hooks system is completely deactivated regardless of other settings.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `mcp`
|
||||
|
||||
- **`mcp.serverCommand`** (string):
|
||||
@@ -779,6 +783,13 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`security.allowedExtensions`** (array):
|
||||
- **Description:** List of Regex patterns for allowed extensions. If nonempty,
|
||||
only extensions that match the patterns in this list are allowed. Overrides
|
||||
the blockGitExtensions setting.
|
||||
- **Default:** `[]`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`security.folderTrust.enabled`** (boolean):
|
||||
- **Description:** Setting to track whether Folder trust is enabled.
|
||||
- **Default:** `false`
|
||||
@@ -1165,6 +1176,13 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
|
||||
- Specifies the default Gemini model to use.
|
||||
- Overrides the hardcoded default
|
||||
- Example: `export GEMINI_MODEL="gemini-3-flash-preview"`
|
||||
- **`GEMINI_CLI_HOME`**:
|
||||
- Specifies the root directory for Gemini CLI's user-level configuration and
|
||||
storage.
|
||||
- By default, this is the user's system home directory. The CLI will create a
|
||||
`.gemini` folder inside this directory.
|
||||
- Useful for shared compute environments or keeping CLI state isolated.
|
||||
- Example: `export GEMINI_CLI_HOME="/path/to/user/config"`
|
||||
- **`GOOGLE_API_KEY`**:
|
||||
- Your Google Cloud API key.
|
||||
- Required for using Vertex AI in express mode.
|
||||
|
||||
@@ -167,8 +167,8 @@ case is response validation and automatic retries.
|
||||
- `reason`: Required if denied. This text is sent **to the agent as a new
|
||||
prompt** to request a correction.
|
||||
- `continue`: Set to `false` to **stop the session** without retrying.
|
||||
- `clearContext`: If `true`, clears conversation history (LLM memory) while
|
||||
preserving UI display.
|
||||
- `hookSpecificOutput.clearContext`: If `true`, clears conversation history
|
||||
(LLM memory) while preserving UI display.
|
||||
- **Exit Code 2 (Retry)**: Rejects the response and triggers an automatic retry
|
||||
turn using `stderr` as the feedback prompt.
|
||||
|
||||
|
||||
+99
-125
@@ -1,149 +1,123 @@
|
||||
# Welcome to Gemini CLI documentation
|
||||
# Gemini CLI documentation
|
||||
|
||||
This documentation provides a comprehensive guide to installing, using, and
|
||||
developing Gemini CLI, a tool that lets you interact with Gemini models through
|
||||
a command-line interface.
|
||||
Gemini CLI is an open-source AI agent that brings the power of Gemini directly
|
||||
into your terminal. It is designed to be a terminal-first, extensible, and
|
||||
powerful tool for developers, engineers, SREs, and beyond.
|
||||
|
||||
## Gemini CLI overview
|
||||
Gemini CLI integrates with your local environment. It can read and edit files,
|
||||
execute shell commands, and search the web, all while maintaining your project
|
||||
context.
|
||||
|
||||
Gemini CLI brings the capabilities of Gemini models to your terminal in an
|
||||
interactive Read-Eval-Print Loop (REPL) environment. Gemini CLI consists of a
|
||||
client-side application (`packages/cli`) that communicates with a local server
|
||||
(`packages/core`), which in turn manages requests to the Gemini API and its AI
|
||||
models. Gemini CLI also contains a variety of tools for tasks such as performing
|
||||
file system operations, running shells, and web fetching, which are managed by
|
||||
`packages/core`.
|
||||
## Get started
|
||||
|
||||
## Navigating the documentation
|
||||
Begin your journey with Gemini CLI by setting up your environment and learning
|
||||
the basics.
|
||||
|
||||
This documentation is organized into the following sections:
|
||||
- **[Quickstart](./get-started/index.md):** A streamlined guide to get you
|
||||
chatting in minutes.
|
||||
- **[Installation](./get-started/installation.md):** Instructions for macOS,
|
||||
Linux, and Windows.
|
||||
- **[Authentication](./get-started/authentication.md):** Set up access using
|
||||
Google OAuth, API keys, or Vertex AI.
|
||||
- **[Examples](./get-started/examples.md):** View common usage scenarios to
|
||||
inspire your own workflows.
|
||||
|
||||
### Overview
|
||||
## Use Gemini CLI
|
||||
|
||||
- **[Architecture overview](./architecture.md):** Understand the high-level
|
||||
design of Gemini CLI, including its components and how they interact.
|
||||
- **[Contribution guide](../CONTRIBUTING.md):** Information for contributors and
|
||||
developers, including setup, building, testing, and coding conventions.
|
||||
Master the core capabilities that let Gemini CLI interact with your system
|
||||
safely and effectively.
|
||||
|
||||
### Get started
|
||||
|
||||
- **[Gemini CLI quickstart](./get-started/index.md):** Let's get started with
|
||||
Gemini CLI.
|
||||
- **[Gemini 3 Pro on Gemini CLI](./get-started/gemini-3.md):** Learn how to
|
||||
enable and use Gemini 3.
|
||||
- **[Authentication](./get-started/authentication.md):** Authenticate to Gemini
|
||||
CLI.
|
||||
- **[Configuration](./get-started/configuration.md):** Learn how to configure
|
||||
the CLI.
|
||||
- **[Installation](./get-started/installation.md):** Install and run Gemini CLI.
|
||||
- **[Examples](./get-started/examples.md):** Example usage of Gemini CLI.
|
||||
|
||||
### CLI
|
||||
|
||||
- **[Introduction: Gemini CLI](./cli/index.md):** Overview of the command-line
|
||||
- **[Using the CLI](./cli/index.md):** Learn the basics of the command-line
|
||||
interface.
|
||||
- **[Commands](./cli/commands.md):** Description of available CLI commands.
|
||||
- **[Checkpointing](./cli/checkpointing.md):** Documentation for the
|
||||
checkpointing feature.
|
||||
- **[Custom commands](./cli/custom-commands.md):** Create your own commands and
|
||||
shortcuts for frequently used prompts.
|
||||
- **[Enterprise](./cli/enterprise.md):** Gemini CLI for enterprise.
|
||||
- **[Headless mode](./cli/headless.md):** Use Gemini CLI programmatically for
|
||||
scripting and automation.
|
||||
- **[Keyboard shortcuts](./cli/keyboard-shortcuts.md):** A reference for all
|
||||
keyboard shortcuts to improve your workflow.
|
||||
- **[Model selection](./cli/model.md):** Select the model used to process your
|
||||
commands with `/model`.
|
||||
- **[Sandbox](./cli/sandbox.md):** Isolate tool execution in a secure,
|
||||
containerized environment.
|
||||
- **[Agent Skills](./cli/skills.md):** Extend the CLI with specialized expertise
|
||||
and procedural workflows.
|
||||
- **[Settings](./cli/settings.md):** Configure various aspects of the CLI's
|
||||
behavior and appearance with `/settings`.
|
||||
- **[Telemetry](./cli/telemetry.md):** Overview of telemetry in the CLI.
|
||||
- **[Themes](./cli/themes.md):** Themes for Gemini CLI.
|
||||
- **[Token caching](./cli/token-caching.md):** Token caching and optimization.
|
||||
- **[Trusted Folders](./cli/trusted-folders.md):** An overview of the Trusted
|
||||
Folders security feature.
|
||||
- **[Tutorials](./cli/tutorials.md):** Tutorials for Gemini CLI.
|
||||
- **[Uninstall](./cli/uninstall.md):** Methods for uninstalling the Gemini CLI.
|
||||
- **[File management](./tools/file-system.md):** Grant the model the ability to
|
||||
read code and apply changes directly to your files.
|
||||
- **[Shell commands](./tools/shell.md):** Allow the model to run builds, tests,
|
||||
and git commands.
|
||||
- **[Memory management](./tools/memory.md):** Teach Gemini CLI facts about your
|
||||
project and preferences that persist across sessions.
|
||||
- **[Project context](./cli/gemini-md.md):** Use `GEMINI.md` files to provide
|
||||
persistent context for your projects.
|
||||
- **[Web search and fetch](./tools/web-search.md):** Enable the model to fetch
|
||||
real-time information from the internet.
|
||||
- **[Session management](./cli/session-management.md):** Save, resume, and
|
||||
organize your chat sessions.
|
||||
|
||||
### Core
|
||||
## Configuration
|
||||
|
||||
- **[Introduction: Gemini CLI core](./core/index.md):** Information about Gemini
|
||||
CLI core.
|
||||
- **[Memport](./core/memport.md):** Using the Memory Import Processor.
|
||||
- **[Tools API](./core/tools-api.md):** Information on how the core manages and
|
||||
exposes tools.
|
||||
- **[System Prompt Override](./cli/system-prompt.md):** Replace built-in system
|
||||
instructions using `GEMINI_SYSTEM_MD`.
|
||||
Customize Gemini CLI to match your workflow and preferences.
|
||||
|
||||
- **[Policy Engine](./core/policy-engine.md):** Use the Policy Engine for
|
||||
fine-grained control over tool execution.
|
||||
- **[Settings](./cli/settings.md):** Control response creativity, output
|
||||
verbosity, and more.
|
||||
- **[Model selection](./cli/model.md):** Choose the best Gemini model for your
|
||||
specific task.
|
||||
- **[Ignore files](./cli/gemini-ignore.md):** Use `.geminiignore` to keep
|
||||
sensitive files out of the model's context.
|
||||
- **[Trusted folders](./cli/trusted-folders.md):** Define security boundaries
|
||||
for file access and execution.
|
||||
- **[Token caching](./cli/token-caching.md):** Optimize performance and cost by
|
||||
caching context.
|
||||
- **[Themes](./cli/themes.md):** Personalize the visual appearance of the CLI.
|
||||
|
||||
### Tools
|
||||
## Advanced features
|
||||
|
||||
- **[Introduction: Gemini CLI tools](./tools/index.md):** Information about
|
||||
Gemini CLI's tools.
|
||||
- **[File system tools](./tools/file-system.md):** Documentation for the
|
||||
`read_file` and `write_file` tools.
|
||||
- **[Shell tool](./tools/shell.md):** Documentation for the `run_shell_command`
|
||||
tool.
|
||||
- **[Web fetch tool](./tools/web-fetch.md):** Documentation for the `web_fetch`
|
||||
tool.
|
||||
- **[Web search tool](./tools/web-search.md):** Documentation for the
|
||||
`google_web_search` tool.
|
||||
- **[Memory tool](./tools/memory.md):** Documentation for the `save_memory`
|
||||
tool.
|
||||
- **[Todo tool](./tools/todos.md):** Documentation for the `write_todos` tool.
|
||||
- **[MCP servers](./tools/mcp-server.md):** Using MCP servers with Gemini CLI.
|
||||
Explore powerful features for complex workflows and enterprise environments.
|
||||
|
||||
### Extensions
|
||||
- **[Headless mode](./cli/headless.md):** Run Gemini CLI in scripts or CI/CD
|
||||
pipelines for automated reasoning.
|
||||
- **[Sandboxing](./cli/sandbox.md):** Execute untrusted code or tools in a
|
||||
secure, isolated container.
|
||||
- **[Checkpointing](./cli/checkpointing.md):** Save and restore workspace state
|
||||
to recover from experimental changes.
|
||||
- **[Custom commands](./cli/custom-commands.md):** Create shortcuts for
|
||||
frequently used prompts.
|
||||
- **[System prompt override](./cli/system-prompt.md):** Customize the core
|
||||
instructions given to the model.
|
||||
- **[Telemetry](./cli/telemetry.md):** Understand how usage data is collected
|
||||
and managed.
|
||||
- **[Enterprise](./cli/enterprise.md):** Manage configurations and policies for
|
||||
large teams.
|
||||
|
||||
- **[Introduction: Extensions](./extensions/index.md):** How to extend the CLI
|
||||
with new functionality.
|
||||
## Extensions
|
||||
|
||||
Extend Gemini CLI's capabilities with new tools and behaviors using extensions.
|
||||
|
||||
- **[Introduction](./extensions/index.md):** Learn about the extension system
|
||||
and how to manage extensions.
|
||||
- **[Writing extensions](./extensions/writing-extensions.md):** Learn how to
|
||||
build your own extension.
|
||||
- **[Extension releasing](./extensions/releasing.md):** How to release Gemini
|
||||
CLI extensions.
|
||||
create your first extension.
|
||||
- **[Extensions reference](./extensions/reference.md):** Deeply understand the
|
||||
extension format, commands, and configuration.
|
||||
- **[Best practices](./extensions/best-practices.md):** Learn strategies for
|
||||
building great extensions.
|
||||
- **[Extensions releasing](./extensions/releasing.md):** Learn how to share your
|
||||
extensions with the world.
|
||||
|
||||
### Hooks
|
||||
## Ecosystem and extensibility
|
||||
|
||||
- **[Hooks](./hooks/index.md):** Intercept and customize Gemini CLI behavior at
|
||||
key lifecycle points.
|
||||
- **[Writing Hooks](./hooks/writing-hooks.md):** Learn how to create your first
|
||||
hook with a comprehensive example.
|
||||
- **[Best Practices](./hooks/best-practices.md):** Security, performance, and
|
||||
debugging guidelines for hooks.
|
||||
Connect Gemini CLI to external services and other development tools.
|
||||
|
||||
### IDE integration
|
||||
- **[MCP servers](./tools/mcp-server.md):** Connect to external services using
|
||||
the Model Context Protocol.
|
||||
- **[IDE integration](./ide-integration/index.md):** Use Gemini CLI alongside VS
|
||||
Code.
|
||||
- **[Hooks](./hooks/index.md):** (Preview) Write scripts that run on specific
|
||||
CLI events.
|
||||
- **[Agent skills](./cli/skills.md):** (Preview) Add specialized expertise and
|
||||
workflows.
|
||||
- **[Sub-agents](./core/subagents.md):** (Preview) Delegate tasks to specialized
|
||||
agents.
|
||||
|
||||
- **[Introduction to IDE integration](./ide-integration/index.md):** Connect the
|
||||
CLI to your editor.
|
||||
- **[IDE companion extension spec](./ide-integration/ide-companion-spec.md):**
|
||||
Spec for building IDE companion extensions.
|
||||
## Development and reference
|
||||
|
||||
### Development
|
||||
Deep dive into the architecture and contribute to the project.
|
||||
|
||||
- **[NPM](./npm.md):** Details on how the project's packages are structured.
|
||||
- **[Releases](./releases.md):** Information on the project's releases and
|
||||
deployment cadence.
|
||||
- **[Changelog](./changelogs/index.md):** Highlights and notable changes to
|
||||
- **[Architecture](./architecture.md):** Understand the technical design of
|
||||
Gemini CLI.
|
||||
- **[Integration tests](./integration-tests.md):** Information about the
|
||||
integration testing framework used in this project.
|
||||
- **[Issue and PR automation](./issue-and-pr-automation.md):** A detailed
|
||||
overview of the automated processes we use to manage and triage issues and
|
||||
pull requests.
|
||||
|
||||
### Support
|
||||
|
||||
- **[FAQ](./faq.md):** Frequently asked questions.
|
||||
- **[Troubleshooting guide](./troubleshooting.md):** Find solutions to common
|
||||
problems.
|
||||
- **[Quota and pricing](./quota-and-pricing.md):** Learn about the free tier and
|
||||
paid options.
|
||||
- **[Terms of service and privacy notice](./tos-privacy.md):** Information on
|
||||
the terms of service and privacy notices applicable to your use of Gemini CLI.
|
||||
|
||||
We hope this documentation helps you make the most of Gemini CLI!
|
||||
- **[Command reference](./cli/commands.md):** A complete list of available
|
||||
commands.
|
||||
- **[Local development](./local-development.md):** Set up your environment to
|
||||
contribute to Gemini CLI.
|
||||
- **[Contributing](../CONTRIBUTING.md):** Learn how to submit pull requests and
|
||||
report issues.
|
||||
- **[FAQ](./faq.md):** Answers to common questions.
|
||||
- **[Troubleshooting](./troubleshooting.md):** Solutions for common issues.
|
||||
|
||||
@@ -117,14 +117,13 @@ directories) will be created.
|
||||
`Found 5 file(s) matching "*.ts" within src, sorted by modification time (newest first):\nsrc/file1.ts\nsrc/subdir/file2.ts...`
|
||||
- **Confirmation:** No.
|
||||
|
||||
## 5. `search_file_content` (SearchText)
|
||||
## 5. `grep_search` (SearchText)
|
||||
|
||||
`search_file_content` searches for a regular expression pattern within the
|
||||
content of files in a specified directory. Can filter files by a glob pattern.
|
||||
Returns the lines containing matches, along with their file paths and line
|
||||
numbers.
|
||||
`grep_search` searches for a regular expression pattern within the content of
|
||||
files in a specified directory. Can filter files by a glob pattern. Returns the
|
||||
lines containing matches, along with their file paths and line numbers.
|
||||
|
||||
- **Tool name:** `search_file_content`
|
||||
- **Tool name:** `grep_search`
|
||||
- **Display name:** SearchText
|
||||
- **File:** `grep.ts`
|
||||
- **Parameters:**
|
||||
|
||||
+57
-1
@@ -7,9 +7,13 @@
|
||||
import { it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { TestRig } from '@google/gemini-cli-test-utils';
|
||||
import { createUnauthorizedToolError } from '@google/gemini-cli-core';
|
||||
import {
|
||||
createUnauthorizedToolError,
|
||||
parseAgentMarkdown,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
export * from '@google/gemini-cli-test-utils';
|
||||
|
||||
@@ -42,10 +46,55 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
|
||||
rig.setup(evalCase.name, evalCase.params);
|
||||
|
||||
if (evalCase.files) {
|
||||
const acknowledgedAgents: Record<string, Record<string, string>> = {};
|
||||
const projectRoot = fs.realpathSync(rig.testDir!);
|
||||
|
||||
for (const [filePath, content] of Object.entries(evalCase.files)) {
|
||||
const fullPath = path.join(rig.testDir!, filePath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content);
|
||||
|
||||
// If it's an agent file, calculate hash for acknowledgement
|
||||
if (
|
||||
filePath.startsWith('.gemini/agents/') &&
|
||||
filePath.endsWith('.md')
|
||||
) {
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(content)
|
||||
.digest('hex');
|
||||
|
||||
try {
|
||||
const agentDefs = await parseAgentMarkdown(fullPath, content);
|
||||
if (agentDefs.length > 0) {
|
||||
const agentName = agentDefs[0].name;
|
||||
if (!acknowledgedAgents[projectRoot]) {
|
||||
acknowledgedAgents[projectRoot] = {};
|
||||
}
|
||||
acknowledgedAgents[projectRoot][agentName] = hash;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to parse agent for test acknowledgement: ${filePath}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write acknowledged_agents.json to the home directory
|
||||
if (Object.keys(acknowledgedAgents).length > 0) {
|
||||
const ackPath = path.join(
|
||||
rig.homeDir!,
|
||||
'.gemini',
|
||||
'acknowledgments',
|
||||
'agents.json',
|
||||
);
|
||||
fs.mkdirSync(path.dirname(ackPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
ackPath,
|
||||
JSON.stringify(acknowledgedAgents, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
const execOptions = { cwd: rig.testDir!, stdio: 'inherit' as const };
|
||||
@@ -66,6 +115,7 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
|
||||
const result = await rig.run({
|
||||
args: evalCase.prompt,
|
||||
approvalMode: evalCase.approvalMode ?? 'yolo',
|
||||
timeout: evalCase.timeout,
|
||||
env: {
|
||||
GEMINI_CLI_ACTIVITY_LOG_FILE: activityLogFile,
|
||||
},
|
||||
@@ -88,6 +138,11 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
|
||||
});
|
||||
}
|
||||
|
||||
if (rig._lastRunStderr) {
|
||||
const stderrFile = path.join(logDir, `${sanitizedName}.stderr.log`);
|
||||
await fs.promises.writeFile(stderrFile, rig._lastRunStderr);
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(
|
||||
logFile,
|
||||
JSON.stringify(rig.readToolLogs(), null, 2),
|
||||
@@ -114,6 +169,7 @@ export interface EvalCase {
|
||||
name: string;
|
||||
params?: Record<string, any>;
|
||||
prompt: string;
|
||||
timeout?: number;
|
||||
files?: Record<string, string>;
|
||||
approvalMode?: 'default' | 'auto_edit' | 'yolo' | 'plan';
|
||||
assert: (rig: TestRig, result: string) => Promise<void>;
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { spawn, ChildProcess } from 'node:child_process';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { Writable, Readable } from 'node:stream';
|
||||
import { env } from 'node:process';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
|
||||
const sandboxEnv = env['GEMINI_SANDBOX'];
|
||||
const itMaybe = sandboxEnv && sandboxEnv !== 'false' ? it.skip : it;
|
||||
|
||||
class MockClient implements acp.Client {
|
||||
updates: acp.SessionNotification[] = [];
|
||||
sessionUpdate = async (params: acp.SessionNotification) => {
|
||||
this.updates.push(params);
|
||||
};
|
||||
requestPermission = async (): Promise<acp.RequestPermissionResponse> => {
|
||||
throw new Error('unexpected');
|
||||
};
|
||||
}
|
||||
|
||||
describe('ACP Environment and Auth', () => {
|
||||
let rig: TestRig;
|
||||
let child: ChildProcess | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
child?.kill();
|
||||
child = undefined;
|
||||
await rig.cleanup();
|
||||
});
|
||||
|
||||
itMaybe(
|
||||
'should load .env from project directory and use the provided API key',
|
||||
async () => {
|
||||
rig.setup('acp-env-loading');
|
||||
|
||||
// Create a project directory with a .env file containing a recognizable invalid key
|
||||
const projectDir = resolve(join(rig.testDir!, 'project'));
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(projectDir, '.env'),
|
||||
'GEMINI_API_KEY=test-key-from-env\n',
|
||||
);
|
||||
|
||||
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
|
||||
|
||||
child = spawn('node', [bundlePath, '--experimental-acp'], {
|
||||
cwd: rig.homeDir!,
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
env: {
|
||||
...process.env,
|
||||
GEMINI_CLI_HOME: rig.homeDir!,
|
||||
GEMINI_API_KEY: undefined,
|
||||
VERBOSE: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
const input = Writable.toWeb(child.stdin!);
|
||||
const output = Readable.toWeb(
|
||||
child.stdout!,
|
||||
) as ReadableStream<Uint8Array>;
|
||||
const testClient = new MockClient();
|
||||
const stream = acp.ndJsonStream(input, output);
|
||||
const connection = new acp.ClientSideConnection(() => testClient, stream);
|
||||
|
||||
await connection.initialize({
|
||||
protocolVersion: acp.PROTOCOL_VERSION,
|
||||
clientCapabilities: {
|
||||
fs: { readTextFile: false, writeTextFile: false },
|
||||
},
|
||||
});
|
||||
|
||||
// 1. newSession should succeed because it finds the key in .env
|
||||
const { sessionId } = await connection.newSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(sessionId).toBeDefined();
|
||||
|
||||
// 2. prompt should fail because the key is invalid,
|
||||
// but the error should come from the API, not the internal auth check.
|
||||
await expect(
|
||||
connection.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'hello' }],
|
||||
}),
|
||||
).rejects.toSatisfy((error: unknown) => {
|
||||
const acpError = error as acp.RequestError;
|
||||
const errorData = acpError.data as
|
||||
| { error?: { message?: string } }
|
||||
| undefined;
|
||||
const message = String(errorData?.error?.message || acpError.message);
|
||||
// It should NOT be our internal "Authentication required" message
|
||||
expect(message).not.toContain('Authentication required');
|
||||
// It SHOULD be an API error mentioning the invalid key
|
||||
expect(message).toContain('API key not valid');
|
||||
return true;
|
||||
});
|
||||
|
||||
child.stdin!.end();
|
||||
},
|
||||
);
|
||||
|
||||
itMaybe(
|
||||
'should fail with authRequired when no API key is found',
|
||||
async () => {
|
||||
rig.setup('acp-auth-failure');
|
||||
|
||||
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
|
||||
|
||||
child = spawn('node', [bundlePath, '--experimental-acp'], {
|
||||
cwd: rig.homeDir!,
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
env: {
|
||||
...process.env,
|
||||
GEMINI_CLI_HOME: rig.homeDir!,
|
||||
GEMINI_API_KEY: undefined,
|
||||
VERBOSE: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
const input = Writable.toWeb(child.stdin!);
|
||||
const output = Readable.toWeb(
|
||||
child.stdout!,
|
||||
) as ReadableStream<Uint8Array>;
|
||||
const testClient = new MockClient();
|
||||
const stream = acp.ndJsonStream(input, output);
|
||||
const connection = new acp.ClientSideConnection(() => testClient, stream);
|
||||
|
||||
await connection.initialize({
|
||||
protocolVersion: acp.PROTOCOL_VERSION,
|
||||
clientCapabilities: {
|
||||
fs: { readTextFile: false, writeTextFile: false },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
connection.newSession({
|
||||
cwd: resolve(rig.testDir!),
|
||||
mcpServers: [],
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
message: expect.stringContaining(
|
||||
'Gemini API key is missing or not configured.',
|
||||
),
|
||||
});
|
||||
|
||||
child.stdin!.end();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -28,6 +28,10 @@ class MockConfig {
|
||||
return true;
|
||||
}
|
||||
|
||||
getFileFilteringRespectGitIgnore() {
|
||||
return true;
|
||||
}
|
||||
|
||||
getFileFilteringRespectGeminiIgnore() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -564,11 +564,17 @@ describe('run_shell_command', () => {
|
||||
|
||||
it('rejects invalid shell expressions', async () => {
|
||||
await rig.setup('rejects invalid shell expressions', {
|
||||
settings: { tools: { core: ['run_shell_command'] } },
|
||||
settings: {
|
||||
tools: {
|
||||
core: ['run_shell_command'],
|
||||
allowed: ['run_shell_command(echo)'], // Specifically allow echo
|
||||
},
|
||||
},
|
||||
});
|
||||
const invalidCommand = getInvalidCommand();
|
||||
const result = await rig.run({
|
||||
args: `I am testing the error handling of the run_shell_command tool. Please attempt to run the following command, which I know has invalid syntax: \`${invalidCommand}\`. If the command fails as expected, please return the word FAIL, otherwise return the word SUCCESS.`,
|
||||
approvalMode: 'default', // Use default mode so safety fallback triggers confirmation
|
||||
});
|
||||
expect(result).toContain('FAIL');
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AuthType } from '@google/gemini-cli-core';
|
||||
import { loadEnvironment, loadSettings } from './settings.js';
|
||||
|
||||
export function validateAuthMethod(authMethod: string): string | null {
|
||||
loadEnvironment(loadSettings().merged);
|
||||
loadEnvironment(loadSettings().merged, process.cwd());
|
||||
if (
|
||||
authMethod === AuthType.LOGIN_WITH_GOOGLE ||
|
||||
authMethod === AuthType.COMPUTE_ADC
|
||||
|
||||
@@ -596,9 +596,9 @@ describe('parseArguments', () => {
|
||||
|
||||
it('should set isCommand to true for hooks command', async () => {
|
||||
process.argv = ['node', 'script.js', 'hooks', 'migrate'];
|
||||
// Hooks command enabled via tools settings
|
||||
// Hooks command enabled via hooksConfig settings
|
||||
const settings = createTestMergedSettings({
|
||||
tools: { enableHooks: true },
|
||||
hooksConfig: { enabled: true },
|
||||
});
|
||||
const argv = await parseArguments(settings);
|
||||
expect(argv.isCommand).toBe(true);
|
||||
@@ -1255,7 +1255,7 @@ describe('Approval mode tool exclusion logic', () => {
|
||||
});
|
||||
|
||||
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
|
||||
'Cannot start in YOLO mode since it is disabled by your admin',
|
||||
'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2928,7 +2928,7 @@ describe('loadCliConfig disableYoloMode', () => {
|
||||
security: { disableYoloMode: true },
|
||||
});
|
||||
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
|
||||
'Cannot start in YOLO mode since it is disabled by your admin',
|
||||
'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2960,7 +2960,7 @@ describe('loadCliConfig secureModeEnabled', () => {
|
||||
});
|
||||
|
||||
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
|
||||
'Cannot start in YOLO mode since it is disabled by your admin',
|
||||
'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2974,7 +2974,7 @@ describe('loadCliConfig secureModeEnabled', () => {
|
||||
});
|
||||
|
||||
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
|
||||
'Cannot start in YOLO mode since it is disabled by your admin',
|
||||
'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
type OutputFormat,
|
||||
coreEvents,
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
getAdminErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
type Settings,
|
||||
@@ -308,7 +309,7 @@ export async function parseArguments(
|
||||
yargsInstance.command(skillsCommand);
|
||||
}
|
||||
// Register hooks command if hooks are enabled
|
||||
if (settings.tools?.enableHooks) {
|
||||
if (settings.hooksConfig.enabled) {
|
||||
yargsInstance.command(hooksCommand);
|
||||
}
|
||||
|
||||
@@ -432,7 +433,7 @@ export async function loadCliConfig(
|
||||
const ideMode = settings.ide?.enabled ?? false;
|
||||
|
||||
const folderTrust = settings.security?.folderTrust?.enabled ?? false;
|
||||
const trustedFolder = isWorkspaceTrusted(settings)?.isTrusted ?? false;
|
||||
const trustedFolder = isWorkspaceTrusted(settings, cwd)?.isTrusted ?? false;
|
||||
|
||||
// Set the context filename in the server's memoryTool module BEFORE loading memory
|
||||
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
|
||||
@@ -550,7 +551,7 @@ export async function loadCliConfig(
|
||||
);
|
||||
}
|
||||
throw new FatalConfigError(
|
||||
'Cannot start in YOLO mode since it is disabled by your admin',
|
||||
getAdminErrorMessage('YOLO mode', undefined /* config */),
|
||||
);
|
||||
}
|
||||
} else if (approvalMode === ApprovalMode.YOLO) {
|
||||
@@ -761,6 +762,7 @@ export async function loadCliConfig(
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||
ideMode,
|
||||
disableLoopDetection: settings.model?.disableLoopDetection,
|
||||
compressionThreshold: settings.model?.compressionThreshold,
|
||||
folderTrust,
|
||||
interactive,
|
||||
@@ -790,10 +792,8 @@ export async function loadCliConfig(
|
||||
acceptRawOutputRisk: argv.acceptRawOutputRisk,
|
||||
modelConfigServiceConfig: settings.modelConfigs,
|
||||
// TODO: loading of hooks based on workspace trust
|
||||
enableHooks:
|
||||
(settings.tools?.enableHooks ?? true) &&
|
||||
(settings.hooksConfig?.enabled ?? true),
|
||||
enableHooksUI: settings.tools?.enableHooks ?? true,
|
||||
enableHooks: settings.hooksConfig.enabled,
|
||||
enableHooksUI: settings.hooksConfig.enabled,
|
||||
hooks: settings.hooks || {},
|
||||
disabledHooks: settings.hooksConfig?.disabled || [],
|
||||
projectHooks: projectHooks || {},
|
||||
|
||||
@@ -227,7 +227,6 @@ System using model: \${MODEL_NAME}
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
experimental: { extensionConfig: true },
|
||||
tools: { enableHooks: true },
|
||||
hooksConfig: { enabled: true },
|
||||
}),
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
|
||||
@@ -51,7 +51,6 @@ describe('ExtensionManager theme loading', () => {
|
||||
experimental: { extensionConfig: true },
|
||||
security: { blockGitExtensions: false },
|
||||
admin: { extensions: { enabled: true }, mcp: { enabled: true } },
|
||||
tools: { enableHooks: true },
|
||||
}),
|
||||
requestConsent: async () => true,
|
||||
requestSetting: async () => '',
|
||||
|
||||
@@ -144,6 +144,26 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
previousExtensionConfig?: ExtensionConfig,
|
||||
): Promise<GeminiCLIExtension> {
|
||||
if (
|
||||
this.settings.security?.allowedExtensions &&
|
||||
this.settings.security?.allowedExtensions.length > 0
|
||||
) {
|
||||
const extensionAllowed = this.settings.security?.allowedExtensions.some(
|
||||
(pattern) => {
|
||||
try {
|
||||
return new RegExp(pattern).test(installMetadata.source);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (!extensionAllowed) {
|
||||
throw new Error(
|
||||
`Installing extension from source "${installMetadata.source}" is not allowed by the "allowedExtensions" security setting.`,
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
(installMetadata.type === 'git' ||
|
||||
installMetadata.type === 'github-release') &&
|
||||
this.settings.security.blockGitExtensions
|
||||
@@ -152,6 +172,7 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
'Installing extensions from remote sources is disallowed by your current settings.',
|
||||
);
|
||||
}
|
||||
|
||||
const isUpdate = !!previousExtensionConfig;
|
||||
let newExtensionConfig: ExtensionConfig | null = null;
|
||||
let localSourcePath: string | undefined;
|
||||
@@ -522,10 +543,39 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
const installMetadata = loadInstallMetadata(extensionDir);
|
||||
let effectiveExtensionPath = extensionDir;
|
||||
if (
|
||||
this.settings.security?.allowedExtensions &&
|
||||
this.settings.security?.allowedExtensions.length > 0
|
||||
) {
|
||||
if (!installMetadata?.source) {
|
||||
throw new Error(
|
||||
`Failed to load extension ${extensionDir}. The ${INSTALL_METADATA_FILENAME} file is missing or misconfigured.`,
|
||||
);
|
||||
}
|
||||
const extensionAllowed = this.settings.security?.allowedExtensions.some(
|
||||
(pattern) => {
|
||||
try {
|
||||
return new RegExp(pattern).test(installMetadata?.source);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (!extensionAllowed) {
|
||||
debugLogger.warn(
|
||||
`Failed to load extension ${extensionDir}. This extension is not allowed by the "allowedExtensions" security setting.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
} else if (
|
||||
(installMetadata?.type === 'git' ||
|
||||
installMetadata?.type === 'github-release') &&
|
||||
this.settings.security.blockGitExtensions
|
||||
) {
|
||||
debugLogger.warn(
|
||||
`Failed to load extension ${extensionDir}. Extensions from remote sources is disallowed by your current settings.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -639,10 +689,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
};
|
||||
|
||||
let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
|
||||
if (
|
||||
this.settings.tools.enableHooks &&
|
||||
this.settings.hooksConfig.enabled
|
||||
) {
|
||||
if (this.settings.hooksConfig.enabled) {
|
||||
hooks = await this.loadExtensionHooks(
|
||||
effectiveExtensionPath,
|
||||
hydrationContext,
|
||||
|
||||
@@ -622,6 +622,7 @@ describe('extension tests', () => {
|
||||
});
|
||||
|
||||
it('should not load github extensions if blockGitExtensions is set', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'my-ext',
|
||||
@@ -645,6 +646,73 @@ describe('extension tests', () => {
|
||||
const extension = extensions.find((e) => e.name === 'my-ext');
|
||||
|
||||
expect(extension).toBeUndefined();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Extensions from remote sources is disallowed by your current settings.',
|
||||
),
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should load allowed extensions if the allowlist is set.', async () => {
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'my-ext',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: 'http://allowed.com/foo/bar',
|
||||
},
|
||||
});
|
||||
const extensionAllowlistSetting = createTestMergedSettings({
|
||||
security: {
|
||||
allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'],
|
||||
},
|
||||
});
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: mockRequestConsent,
|
||||
requestSetting: mockPromptForSettings,
|
||||
settings: extensionAllowlistSetting,
|
||||
});
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
|
||||
expect(extensions).toHaveLength(1);
|
||||
expect(extensions[0].name).toBe('my-ext');
|
||||
});
|
||||
|
||||
it('should not load disallowed extensions if the allowlist is set.', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'my-ext',
|
||||
version: '1.0.0',
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: 'http://notallowed.com/foo/bar',
|
||||
},
|
||||
});
|
||||
const extensionAllowlistSetting = createTestMergedSettings({
|
||||
security: {
|
||||
allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'],
|
||||
},
|
||||
});
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: mockRequestConsent,
|
||||
requestSetting: mockPromptForSettings,
|
||||
settings: extensionAllowlistSetting,
|
||||
});
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const extension = extensions.find((e) => e.name === 'my-ext');
|
||||
|
||||
expect(extension).toBeUndefined();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'This extension is not allowed by the "allowedExtensions" security setting',
|
||||
),
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not load any extensions if admin.extensions.enabled is false', async () => {
|
||||
@@ -1116,6 +1184,30 @@ describe('extension tests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not install a disallowed extension if the allowlist is set', async () => {
|
||||
const gitUrl = 'https://somehost.com/somerepo.git';
|
||||
const allowedExtensionsSetting = createTestMergedSettings({
|
||||
security: {
|
||||
allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'],
|
||||
},
|
||||
});
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: mockRequestConsent,
|
||||
requestSetting: mockPromptForSettings,
|
||||
settings: allowedExtensionsSetting,
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
await expect(
|
||||
extensionManager.installOrUpdateExtension({
|
||||
source: gitUrl,
|
||||
type: 'git',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
`Installing extension from source "${gitUrl}" is not allowed by the "allowedExtensions" security setting.`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should prompt for trust if workspace is not trusted', async () => {
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
|
||||
@@ -72,6 +72,15 @@ export enum Command {
|
||||
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
|
||||
PASTE_CLIPBOARD = 'input.paste',
|
||||
|
||||
BACKGROUND_SHELL_ESCAPE = 'backgroundShellEscape',
|
||||
BACKGROUND_SHELL_SELECT = 'backgroundShellSelect',
|
||||
TOGGLE_BACKGROUND_SHELL = 'toggleBackgroundShell',
|
||||
TOGGLE_BACKGROUND_SHELL_LIST = 'toggleBackgroundShellList',
|
||||
KILL_BACKGROUND_SHELL = 'backgroundShell.kill',
|
||||
UNFOCUS_BACKGROUND_SHELL = 'backgroundShell.unfocus',
|
||||
UNFOCUS_BACKGROUND_SHELL_LIST = 'backgroundShell.listUnfocus',
|
||||
SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'backgroundShell.unfocusWarning',
|
||||
|
||||
// App Controls
|
||||
SHOW_ERROR_DETAILS = 'app.showErrorDetails',
|
||||
SHOW_FULL_TODOS = 'app.showFullTodos',
|
||||
@@ -139,7 +148,6 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
],
|
||||
[Command.MOVE_LEFT]: [
|
||||
{ key: 'left', shift: false, alt: false, ctrl: false, cmd: false },
|
||||
{ key: 'b', ctrl: true },
|
||||
],
|
||||
[Command.MOVE_RIGHT]: [
|
||||
{ key: 'right', shift: false, alt: false, ctrl: false, cmd: false },
|
||||
@@ -265,6 +273,16 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
[Command.TOGGLE_COPY_MODE]: [{ key: 's', ctrl: true }],
|
||||
[Command.TOGGLE_YOLO]: [{ key: 'y', ctrl: true }],
|
||||
[Command.CYCLE_APPROVAL_MODE]: [{ key: 'tab', shift: true }],
|
||||
[Command.TOGGLE_BACKGROUND_SHELL]: [{ key: 'b', ctrl: true }],
|
||||
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: [{ key: 'l', ctrl: true }],
|
||||
[Command.KILL_BACKGROUND_SHELL]: [{ key: 'k', ctrl: true }],
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL]: [{ key: 'tab', shift: true }],
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]: [{ key: 'tab', shift: false }],
|
||||
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [
|
||||
{ key: 'tab', shift: false },
|
||||
],
|
||||
[Command.BACKGROUND_SHELL_SELECT]: [{ key: 'return' }],
|
||||
[Command.BACKGROUND_SHELL_ESCAPE]: [{ key: 'escape' }],
|
||||
[Command.SHOW_MORE_LINES]: [
|
||||
{ key: 'o', ctrl: true },
|
||||
{ key: 's', ctrl: true },
|
||||
@@ -379,6 +397,14 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
Command.TOGGLE_YOLO,
|
||||
Command.CYCLE_APPROVAL_MODE,
|
||||
Command.SHOW_MORE_LINES,
|
||||
Command.TOGGLE_BACKGROUND_SHELL,
|
||||
Command.TOGGLE_BACKGROUND_SHELL_LIST,
|
||||
Command.KILL_BACKGROUND_SHELL,
|
||||
Command.BACKGROUND_SHELL_SELECT,
|
||||
Command.BACKGROUND_SHELL_ESCAPE,
|
||||
Command.UNFOCUS_BACKGROUND_SHELL,
|
||||
Command.UNFOCUS_BACKGROUND_SHELL_LIST,
|
||||
Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
|
||||
Command.FOCUS_SHELL_INPUT,
|
||||
Command.UNFOCUS_SHELL_INPUT,
|
||||
Command.CLEAR_SCREEN,
|
||||
@@ -470,6 +496,14 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
|
||||
[Command.SHOW_MORE_LINES]:
|
||||
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
|
||||
[Command.BACKGROUND_SHELL_SELECT]: 'Enter',
|
||||
[Command.BACKGROUND_SHELL_ESCAPE]: 'Esc',
|
||||
[Command.TOGGLE_BACKGROUND_SHELL]: 'Ctrl+B',
|
||||
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Ctrl+L',
|
||||
[Command.KILL_BACKGROUND_SHELL]: 'Ctrl+K',
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL]: 'Shift+Tab',
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]: 'Tab',
|
||||
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: 'Tab',
|
||||
[Command.FOCUS_SHELL_INPUT]: 'Focus the shell input from the gemini input.',
|
||||
[Command.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.',
|
||||
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
|
||||
|
||||
@@ -164,7 +164,6 @@ describe('Policy Engine Integration Tests', () => {
|
||||
it('should handle complex mixed configurations', async () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
autoAccept: true, // Allows read-only tools
|
||||
allowed: ['custom-tool', 'my-server__special-tool'],
|
||||
exclude: ['glob', 'dangerous-tool'],
|
||||
},
|
||||
@@ -438,7 +437,6 @@ describe('Policy Engine Integration Tests', () => {
|
||||
it('should verify priority ordering works correctly in practice', async () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
autoAccept: true, // Priority 50
|
||||
allowed: ['specific-tool'], // Priority 100
|
||||
exclude: ['blocked-tool'], // Priority 200
|
||||
},
|
||||
@@ -614,7 +612,6 @@ describe('Policy Engine Integration Tests', () => {
|
||||
it('should verify rules are created with correct priorities', async () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
autoAccept: true,
|
||||
allowed: ['tool1', 'tool2'],
|
||||
exclude: ['tool3'],
|
||||
},
|
||||
|
||||
@@ -124,7 +124,6 @@ describe('settings-validation', () => {
|
||||
},
|
||||
tools: {
|
||||
sandbox: 'inherit',
|
||||
autoAccept: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -29,10 +29,11 @@ vi.mock('./settings.js', async (importActual) => {
|
||||
});
|
||||
|
||||
// Mock trustedFolders
|
||||
import * as trustedFolders from './trustedFolders.js';
|
||||
vi.mock('./trustedFolders.js', () => ({
|
||||
isWorkspaceTrusted: vi
|
||||
.fn()
|
||||
.mockReturnValue({ isTrusted: true, source: 'file' }),
|
||||
isWorkspaceTrusted: vi.fn(),
|
||||
isFolderTrustEnabled: vi.fn(),
|
||||
loadTrustedFolders: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./settingsSchema.js', async (importOriginal) => {
|
||||
@@ -151,7 +152,7 @@ describe('Settings Loading and Merging', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(false);
|
||||
(fs.readFileSync as Mock).mockReturnValue('{}'); // Return valid empty JSON
|
||||
(mockFsMkdirSync as Mock).mockImplementation(() => undefined);
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
@@ -1635,7 +1636,7 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
|
||||
it('should NOT merge workspace settings when workspace is not trusted', () => {
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
@@ -1666,23 +1667,60 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(settings.merged.context?.fileName).toBe('USER.md'); // User setting
|
||||
expect(settings.merged.ui?.theme).toBe('dark'); // User setting
|
||||
});
|
||||
|
||||
it('should NOT merge workspace settings when workspace trust is undefined', () => {
|
||||
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
|
||||
isTrusted: undefined,
|
||||
source: undefined,
|
||||
});
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const userSettingsContent = {
|
||||
ui: { theme: 'dark' },
|
||||
tools: { sandbox: false },
|
||||
context: { fileName: 'USER.md' },
|
||||
};
|
||||
const workspaceSettingsContent = {
|
||||
tools: { sandbox: true },
|
||||
context: { fileName: 'WORKSPACE.md' },
|
||||
};
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(settings.merged.tools?.sandbox).toBe(false); // User setting
|
||||
expect(settings.merged.context?.fileName).toBe('USER.md'); // User setting
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadEnvironment', () => {
|
||||
function setup({
|
||||
isFolderTrustEnabled = true,
|
||||
isWorkspaceTrustedValue = true,
|
||||
isWorkspaceTrustedValue = true as boolean | undefined,
|
||||
}) {
|
||||
delete process.env['TESTTEST']; // reset
|
||||
const geminiEnvPath = path.resolve(path.join(GEMINI_DIR, '.env'));
|
||||
const geminiEnvPath = path.resolve(
|
||||
path.join(MOCK_WORKSPACE_DIR, GEMINI_DIR, '.env'),
|
||||
);
|
||||
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
|
||||
isTrusted: isWorkspaceTrustedValue,
|
||||
source: 'file',
|
||||
});
|
||||
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) =>
|
||||
[USER_SETTINGS_PATH, geminiEnvPath].includes(p.toString()),
|
||||
);
|
||||
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => {
|
||||
const normalizedP = path.resolve(p.toString());
|
||||
return [path.resolve(USER_SETTINGS_PATH), geminiEnvPath].includes(
|
||||
normalizedP,
|
||||
);
|
||||
});
|
||||
const userSettingsContent: Settings = {
|
||||
ui: {
|
||||
theme: 'dark',
|
||||
@@ -1698,9 +1736,10 @@ describe('Settings Loading and Merging', () => {
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
const normalizedP = path.resolve(p.toString());
|
||||
if (normalizedP === path.resolve(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === geminiEnvPath) return 'TESTTEST=1234';
|
||||
if (normalizedP === geminiEnvPath) return 'TESTTEST=1234';
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
@@ -1708,14 +1747,34 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
it('sets environment variables from .env files', () => {
|
||||
setup({ isFolderTrustEnabled: false, isWorkspaceTrustedValue: true });
|
||||
loadEnvironment(loadSettings(MOCK_WORKSPACE_DIR).merged);
|
||||
const settings = {
|
||||
security: { folderTrust: { enabled: false } },
|
||||
} as Settings;
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
||||
|
||||
expect(process.env['TESTTEST']).toEqual('1234');
|
||||
});
|
||||
|
||||
it('does not load env files from untrusted spaces', () => {
|
||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
||||
loadEnvironment(loadSettings(MOCK_WORKSPACE_DIR).merged);
|
||||
const settings = {
|
||||
security: { folderTrust: { enabled: true } },
|
||||
} as Settings;
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
||||
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
});
|
||||
|
||||
it('does not load env files when trust is undefined', () => {
|
||||
delete process.env['TESTTEST'];
|
||||
// isWorkspaceTrusted returns {isTrusted: undefined} for matched rules with no trust value, or no matching rules.
|
||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: undefined });
|
||||
const settings = {
|
||||
security: { folderTrust: { enabled: true } },
|
||||
} as Settings;
|
||||
|
||||
const mockTrustFn = vi.fn().mockReturnValue({ isTrusted: undefined });
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, mockTrustFn);
|
||||
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
});
|
||||
@@ -1731,7 +1790,7 @@ describe('Settings Loading and Merging', () => {
|
||||
mockFsExistsSync.mockReturnValue(true);
|
||||
mockFsReadFileSync = vi.mocked(fs.readFileSync);
|
||||
mockFsReadFileSync.mockReturnValue('{}');
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: undefined,
|
||||
});
|
||||
|
||||
@@ -50,7 +50,9 @@ import {
|
||||
formatValidationError,
|
||||
} from './settings-validation.js';
|
||||
|
||||
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
|
||||
export function getMergeStrategyForPath(
|
||||
path: string[],
|
||||
): MergeStrategy | undefined {
|
||||
let current: SettingDefinition | undefined = undefined;
|
||||
let currentSchema: SettingsSchema | undefined = getSettingsSchema();
|
||||
let parent: SettingDefinition | undefined = undefined;
|
||||
@@ -432,10 +434,15 @@ export function setUpCloudShellEnvironment(envFilePath: string | null): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function loadEnvironment(settings: Settings): void {
|
||||
const envFilePath = findEnvFile(process.cwd());
|
||||
export function loadEnvironment(
|
||||
settings: Settings,
|
||||
workspaceDir: string,
|
||||
isWorkspaceTrustedFn = isWorkspaceTrusted,
|
||||
): void {
|
||||
const envFilePath = findEnvFile(workspaceDir);
|
||||
const trustResult = isWorkspaceTrustedFn(settings, workspaceDir);
|
||||
|
||||
if (!isWorkspaceTrusted(settings).isTrusted) {
|
||||
if (trustResult.isTrusted !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -600,7 +607,8 @@ export function loadSettings(
|
||||
userSettings,
|
||||
);
|
||||
const isTrusted =
|
||||
isWorkspaceTrusted(initialTrustCheckSettings as Settings).isTrusted ?? true;
|
||||
isWorkspaceTrusted(initialTrustCheckSettings as Settings, workspaceDir)
|
||||
.isTrusted ?? false;
|
||||
|
||||
// Create a temporary merged settings object to pass to loadEnvironment.
|
||||
const tempMergedSettings = mergeSettings(
|
||||
@@ -613,7 +621,7 @@ export function loadSettings(
|
||||
|
||||
// loadEnvironment depends on settings so we have to create a temp version of
|
||||
// the settings to avoid a cycle
|
||||
loadEnvironment(tempMergedSettings);
|
||||
loadEnvironment(tempMergedSettings, workspaceDir);
|
||||
|
||||
// Check for any fatal errors before proceeding
|
||||
const fatalErrors = settingsErrors.filter((e) => e.severity === 'error');
|
||||
|
||||
@@ -351,6 +351,26 @@ const SETTINGS_SCHEMA = {
|
||||
'The color theme for the UI. See the CLI themes guide for available options.',
|
||||
showInDialog: false,
|
||||
},
|
||||
autoThemeSwitching: {
|
||||
type: 'boolean',
|
||||
label: 'Auto Theme Switching',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description:
|
||||
'Automatically switch between default light and dark themes based on terminal background color.',
|
||||
showInDialog: true,
|
||||
},
|
||||
terminalBackgroundPollingInterval: {
|
||||
type: 'number',
|
||||
label: 'Terminal Background Polling Interval',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: 60,
|
||||
description:
|
||||
'Interval in seconds to poll the terminal background color.',
|
||||
showInDialog: true,
|
||||
},
|
||||
customThemes: {
|
||||
type: 'object',
|
||||
label: 'Custom Themes',
|
||||
@@ -739,6 +759,16 @@ const SETTINGS_SCHEMA = {
|
||||
'The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).',
|
||||
showInDialog: true,
|
||||
},
|
||||
disableLoopDetection: {
|
||||
type: 'boolean',
|
||||
label: 'Disable Loop Detection',
|
||||
category: 'Model',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Disable automatic detection and prevention of infinite loops.',
|
||||
showInDialog: true,
|
||||
},
|
||||
skipNextSpeakerCheck: {
|
||||
type: 'boolean',
|
||||
label: 'Skip Next Speaker Check',
|
||||
@@ -1041,17 +1071,6 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
autoAccept: {
|
||||
type: 'boolean',
|
||||
label: 'Auto Accept',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: oneLine`
|
||||
Automatically accept and execute tool calls that are considered safe (e.g., read-only operations).
|
||||
`,
|
||||
showInDialog: true,
|
||||
},
|
||||
approvalMode: {
|
||||
type: 'enum',
|
||||
label: 'Approval Mode',
|
||||
@@ -1179,16 +1198,6 @@ const SETTINGS_SCHEMA = {
|
||||
`,
|
||||
showInDialog: true,
|
||||
},
|
||||
enableHooks: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Hooks System (Experimental)',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description:
|
||||
'Enables the hooks system experiment. When disabled, the hooks system is completely deactivated regardless of other settings.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1278,6 +1287,17 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Blocks installing and loading extensions from Git.',
|
||||
showInDialog: true,
|
||||
},
|
||||
allowedExtensions: {
|
||||
type: 'array',
|
||||
label: 'Extension Source Regex Allowlist',
|
||||
category: 'Security',
|
||||
requiresRestart: true,
|
||||
default: [] as string[],
|
||||
description:
|
||||
'List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting.',
|
||||
showInDialog: true,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
folderTrust: {
|
||||
type: 'object',
|
||||
label: 'Folder Trust',
|
||||
|
||||
@@ -326,6 +326,19 @@ describe('isWorkspaceTrusted', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should use workspaceDir instead of process.cwd() when provided', () => {
|
||||
mockCwd = '/home/user/untrusted';
|
||||
const workspaceDir = '/home/user/projectA';
|
||||
mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER;
|
||||
mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST;
|
||||
|
||||
// process.cwd() is untrusted, but workspaceDir is trusted
|
||||
expect(isWorkspaceTrusted(mockSettings, workspaceDir)).toEqual({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle path normalization', () => {
|
||||
mockCwd = '/home/user/projectA';
|
||||
mockRules[`/home/user/../user/${path.basename('/home/user/projectA')}`] =
|
||||
|
||||
@@ -227,6 +227,7 @@ export function isFolderTrustEnabled(settings: Settings): boolean {
|
||||
}
|
||||
|
||||
function getWorkspaceTrustFromLocalConfig(
|
||||
workspaceDir: string,
|
||||
trustConfig?: Record<string, TrustLevel>,
|
||||
): TrustResult {
|
||||
const folders = loadTrustedFolders();
|
||||
@@ -241,7 +242,7 @@ function getWorkspaceTrustFromLocalConfig(
|
||||
);
|
||||
}
|
||||
|
||||
const isTrusted = folders.isPathTrusted(process.cwd(), configToUse);
|
||||
const isTrusted = folders.isPathTrusted(workspaceDir, configToUse);
|
||||
return {
|
||||
isTrusted,
|
||||
source: isTrusted !== undefined ? 'file' : undefined,
|
||||
@@ -250,6 +251,7 @@ function getWorkspaceTrustFromLocalConfig(
|
||||
|
||||
export function isWorkspaceTrusted(
|
||||
settings: Settings,
|
||||
workspaceDir: string = process.cwd(),
|
||||
trustConfig?: Record<string, TrustLevel>,
|
||||
): TrustResult {
|
||||
if (!isFolderTrustEnabled(settings)) {
|
||||
@@ -262,5 +264,5 @@ export function isWorkspaceTrusted(
|
||||
}
|
||||
|
||||
// Fall back to the local user configuration
|
||||
return getWorkspaceTrustFromLocalConfig(trustConfig);
|
||||
return getWorkspaceTrustFromLocalConfig(workspaceDir, trustConfig);
|
||||
}
|
||||
|
||||
@@ -16,11 +16,10 @@ import type { ArgumentsCamelCase, CommandModule } from 'yargs';
|
||||
import type { MergedSettings } from './config/settings.js';
|
||||
import type { MockInstance } from 'vitest';
|
||||
|
||||
const { mockRunExitCleanup, mockDebugLogger } = vi.hoisted(() => ({
|
||||
const { mockRunExitCleanup, mockCoreEvents } = vi.hoisted(() => ({
|
||||
mockRunExitCleanup: vi.fn(),
|
||||
mockDebugLogger: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
mockCoreEvents: {
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -28,7 +27,7 @@ vi.mock('@google/gemini-cli-core', async () => {
|
||||
const actual = await vi.importActual('@google/gemini-cli-core');
|
||||
return {
|
||||
...actual,
|
||||
debugLogger: mockDebugLogger,
|
||||
coreEvents: mockCoreEvents,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -55,8 +54,7 @@ describe('deferred', () => {
|
||||
describe('runDeferredCommand', () => {
|
||||
it('should do nothing if no deferred command is set', async () => {
|
||||
await runDeferredCommand(createMockSettings());
|
||||
expect(mockDebugLogger.log).not.toHaveBeenCalled();
|
||||
expect(mockDebugLogger.error).not.toHaveBeenCalled();
|
||||
expect(mockCoreEvents.emitFeedback).not.toHaveBeenCalled();
|
||||
expect(mockExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -85,8 +83,9 @@ describe('deferred', () => {
|
||||
const settings = createMockSettings({ mcp: { enabled: false } });
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Error: MCP is disabled by your admin.',
|
||||
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'MCP is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
);
|
||||
expect(mockRunExitCleanup).toHaveBeenCalled();
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
@@ -102,8 +101,9 @@ describe('deferred', () => {
|
||||
const settings = createMockSettings({ extensions: { enabled: false } });
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Error: Extensions are disabled by your admin.',
|
||||
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Extensions is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
);
|
||||
expect(mockRunExitCleanup).toHaveBeenCalled();
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
@@ -119,8 +119,9 @@ describe('deferred', () => {
|
||||
const settings = createMockSettings({ skills: { enabled: false } });
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Error: Agent skills are disabled by your admin.',
|
||||
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Agent skills is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
);
|
||||
expect(mockRunExitCleanup).toHaveBeenCalled();
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
@@ -183,8 +184,9 @@ describe('deferred', () => {
|
||||
const mcpSettings = createMockSettings({ mcp: { enabled: false } });
|
||||
await runDeferredCommand(mcpSettings);
|
||||
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Error: MCP is disabled by your admin.',
|
||||
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'MCP is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { ArgumentsCamelCase, CommandModule } from 'yargs';
|
||||
import { debugLogger, ExitCodes } from '@google/gemini-cli-core';
|
||||
import {
|
||||
coreEvents,
|
||||
ExitCodes,
|
||||
getAdminErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { runExitCleanup } from './utils/cleanup.js';
|
||||
import type { MergedSettings } from './config/settings.js';
|
||||
import process from 'node:process';
|
||||
@@ -30,7 +34,10 @@ export async function runDeferredCommand(settings: MergedSettings) {
|
||||
const commandName = deferredCommand.commandName;
|
||||
|
||||
if (commandName === 'mcp' && adminSettings?.mcp?.enabled === false) {
|
||||
debugLogger.error('Error: MCP is disabled by your admin.');
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
getAdminErrorMessage('MCP', undefined /* config */),
|
||||
);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
}
|
||||
@@ -39,13 +46,19 @@ export async function runDeferredCommand(settings: MergedSettings) {
|
||||
commandName === 'extensions' &&
|
||||
adminSettings?.extensions?.enabled === false
|
||||
) {
|
||||
debugLogger.error('Error: Extensions are disabled by your admin.');
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
getAdminErrorMessage('Extensions', undefined /* config */),
|
||||
);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
}
|
||||
|
||||
if (commandName === 'skills' && adminSettings?.skills?.enabled === false) {
|
||||
debugLogger.error('Error: Agent skills are disabled by your admin.');
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
getAdminErrorMessage('Agent skills', undefined /* config */),
|
||||
);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
}
|
||||
|
||||
+16
-13
@@ -98,6 +98,7 @@ import { deleteSession, listSessions } from './utils/sessions.js';
|
||||
import { createPolicyUpdater } from './config/policy.js';
|
||||
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
|
||||
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
||||
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
|
||||
|
||||
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
|
||||
import { profiler } from './ui/components/DebugProfiler.js';
|
||||
@@ -228,19 +229,21 @@ export async function startInteractiveUI(
|
||||
settings.merged.general.debugKeystrokeLogging
|
||||
}
|
||||
>
|
||||
<ScrollProvider>
|
||||
<SessionStatsProvider>
|
||||
<VimModeProvider settings={settings}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
resumedSessionData={resumedSessionData}
|
||||
initializationResult={initializationResult}
|
||||
/>
|
||||
</VimModeProvider>
|
||||
</SessionStatsProvider>
|
||||
</ScrollProvider>
|
||||
<TerminalProvider>
|
||||
<ScrollProvider>
|
||||
<SessionStatsProvider>
|
||||
<VimModeProvider settings={settings}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
resumedSessionData={resumedSessionData}
|
||||
initializationResult={initializationResult}
|
||||
/>
|
||||
</VimModeProvider>
|
||||
</SessionStatsProvider>
|
||||
</ScrollProvider>
|
||||
</TerminalProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>
|
||||
|
||||
@@ -258,6 +258,9 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
undefined,
|
||||
false,
|
||||
'Test input',
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Hello World\n');
|
||||
// Note: Telemetry shutdown is now handled in runExitCleanup() in cleanup.ts
|
||||
@@ -374,6 +377,9 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Tool response' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-2',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Final answer\n');
|
||||
});
|
||||
@@ -531,6 +537,9 @@ describe('runNonInteractive', () => {
|
||||
],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-3',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Sorry, let me try again.\n');
|
||||
});
|
||||
@@ -670,6 +679,9 @@ describe('runNonInteractive', () => {
|
||||
processedParts,
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-7',
|
||||
undefined,
|
||||
false,
|
||||
rawInput,
|
||||
);
|
||||
|
||||
// 6. Assert the final output is correct
|
||||
@@ -703,6 +715,9 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
undefined,
|
||||
false,
|
||||
'Test input',
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
@@ -833,6 +848,9 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Empty response test' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-empty',
|
||||
undefined,
|
||||
false,
|
||||
'Empty response test',
|
||||
);
|
||||
|
||||
// This should output JSON with empty response but include stats
|
||||
@@ -967,6 +985,9 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Prompt from command' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
undefined,
|
||||
false,
|
||||
'/testcommand',
|
||||
);
|
||||
|
||||
expect(getWrittenOutput()).toBe('Response from command\n');
|
||||
@@ -1010,6 +1031,9 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Slash command output' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
undefined,
|
||||
false,
|
||||
'/help',
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Response to slash command\n');
|
||||
handleSlashCommandSpy.mockRestore();
|
||||
@@ -1184,6 +1208,9 @@ describe('runNonInteractive', () => {
|
||||
[{ text: '/unknowncommand' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-unknown',
|
||||
undefined,
|
||||
false,
|
||||
'/unknowncommand',
|
||||
);
|
||||
|
||||
expect(getWrittenOutput()).toBe('Response to unknown\n');
|
||||
|
||||
@@ -301,6 +301,9 @@ export async function runNonInteractive({
|
||||
currentMessages[0]?.parts || [],
|
||||
abortController.signal,
|
||||
prompt_id,
|
||||
undefined,
|
||||
false,
|
||||
turnCount === 1 ? input : undefined,
|
||||
);
|
||||
|
||||
let responseText = '';
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
type CommandContext,
|
||||
} from '../ui/commands/types.js';
|
||||
import type { MessageActionReturn, Config } from '@google/gemini-cli-core';
|
||||
import { isNightly, startupProfiler } from '@google/gemini-cli-core';
|
||||
import {
|
||||
isNightly,
|
||||
startupProfiler,
|
||||
getAdminErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { aboutCommand } from '../ui/commands/aboutCommand.js';
|
||||
import { agentsCommand } from '../ui/commands/agentsCommand.js';
|
||||
import { authCommand } from '../ui/commands/authCommand.js';
|
||||
@@ -47,6 +51,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||
import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
||||
import { skillsCommand } from '../ui/commands/skillsCommand.js';
|
||||
import { settingsCommand } from '../ui/commands/settingsCommand.js';
|
||||
import { shellsCommand } from '../ui/commands/shellsCommand.js';
|
||||
import { vimCommand } from '../ui/commands/vimCommand.js';
|
||||
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
||||
@@ -101,7 +106,10 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
): Promise<MessageActionReturn> => ({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Extensions are disabled by your admin.',
|
||||
content: getAdminErrorMessage(
|
||||
'Extensions',
|
||||
this.config ?? undefined,
|
||||
),
|
||||
}),
|
||||
},
|
||||
]
|
||||
@@ -126,7 +134,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
): Promise<MessageActionReturn> => ({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'MCP is disabled by your admin.',
|
||||
content: getAdminErrorMessage('MCP', this.config ?? undefined),
|
||||
}),
|
||||
},
|
||||
]
|
||||
@@ -157,13 +165,17 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
): Promise<MessageActionReturn> => ({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Agent skills are disabled by your admin.',
|
||||
content: getAdminErrorMessage(
|
||||
'Agent skills',
|
||||
this.config ?? undefined,
|
||||
),
|
||||
}),
|
||||
},
|
||||
]
|
||||
: [skillsCommand]
|
||||
: []),
|
||||
settingsCommand,
|
||||
shellsCommand,
|
||||
vimCommand,
|
||||
setupGithubCommand,
|
||||
terminalSetupCommand,
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js';
|
||||
import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js';
|
||||
import { AskUserActionsProvider } from '../ui/contexts/AskUserActionsContext.js';
|
||||
import { TerminalProvider } from '../ui/contexts/TerminalContext.js';
|
||||
|
||||
import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
|
||||
import { FakePersistentState } from './persistentStateFake.js';
|
||||
@@ -157,6 +158,9 @@ const baseMockUiState = {
|
||||
terminalHeight: 40,
|
||||
currentModel: 'gemini-pro',
|
||||
terminalBackgroundColor: undefined,
|
||||
activePtyId: undefined,
|
||||
backgroundShells: new Map(),
|
||||
backgroundShellHeight: 0,
|
||||
};
|
||||
|
||||
export const mockAppState: AppState = {
|
||||
@@ -201,7 +205,11 @@ const mockUIActions: UIActions = {
|
||||
handleApiKeyCancel: vi.fn(),
|
||||
setBannerVisible: vi.fn(),
|
||||
setEmbeddedShellFocused: vi.fn(),
|
||||
dismissBackgroundShell: vi.fn(),
|
||||
setActiveBackgroundShellPid: vi.fn(),
|
||||
setIsBackgroundShellListOpen: vi.fn(),
|
||||
setAuthContext: vi.fn(),
|
||||
handleWarning: vi.fn(),
|
||||
handleRestart: vi.fn(),
|
||||
handleNewAgentsSelect: vi.fn(),
|
||||
};
|
||||
@@ -310,16 +318,18 @@ export const renderWithProviders = (
|
||||
<MouseProvider
|
||||
mouseEventsEnabled={mouseEventsEnabled}
|
||||
>
|
||||
<ScrollProvider>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{component}
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
<TerminalProvider>
|
||||
<ScrollProvider>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{component}
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</TerminalProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</AskUserActionsProvider>
|
||||
|
||||
@@ -88,6 +88,7 @@ describe('App', () => {
|
||||
defaultText: 'Mock Banner Text',
|
||||
warningText: '',
|
||||
},
|
||||
backgroundShells: new Map(),
|
||||
};
|
||||
|
||||
it('should render main content and composer when not quitting', () => {
|
||||
@@ -234,7 +235,6 @@ describe('App', () => {
|
||||
expect(lastFrame()).toContain('Tips for getting started');
|
||||
expect(lastFrame()).toContain('Notifications');
|
||||
expect(lastFrame()).toContain('Action Required'); // From ToolConfirmationQueue
|
||||
expect(lastFrame()).toContain('1 of 1');
|
||||
expect(lastFrame()).toContain('Composer');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -32,13 +32,7 @@ import {
|
||||
UserAccountManager,
|
||||
type ContentGeneratorConfig,
|
||||
type AgentDefinition,
|
||||
MessageBusType,
|
||||
QuestionType,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
AskUserActionsContext,
|
||||
type AskUserState,
|
||||
} from './contexts/AskUserActionsContext.js';
|
||||
|
||||
// Mock coreEvents
|
||||
const mockCoreEvents = vi.hoisted(() => ({
|
||||
@@ -124,11 +118,9 @@ vi.mock('ink', async (importOriginal) => {
|
||||
// so we can assert against them in our tests.
|
||||
let capturedUIState: UIState;
|
||||
let capturedUIActions: UIActions;
|
||||
let capturedAskUserRequest: AskUserState | null;
|
||||
function TestContextConsumer() {
|
||||
capturedUIState = useContext(UIStateContext)!;
|
||||
capturedUIActions = useContext(UIActionsContext)!;
|
||||
capturedAskUserRequest = useContext(AskUserActionsContext)?.request ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -165,6 +157,12 @@ vi.mock('./components/shared/text-buffer.js');
|
||||
vi.mock('./hooks/useLogger.js');
|
||||
vi.mock('./hooks/useInputHistoryStore.js');
|
||||
vi.mock('./hooks/useHookDisplayState.js');
|
||||
vi.mock('./hooks/useTerminalTheme.js', () => ({
|
||||
useTerminalTheme: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
||||
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
|
||||
|
||||
// Mock external utilities
|
||||
vi.mock('../utils/events.js');
|
||||
@@ -193,7 +191,6 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||
import { useLogger } from './hooks/useLogger.js';
|
||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
||||
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
||||
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
||||
import { measureElement } from 'ink';
|
||||
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
||||
@@ -268,6 +265,26 @@ describe('AppContainer State Management', () => {
|
||||
const mockedUseKeypress = useKeypress as Mock;
|
||||
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
|
||||
const mockedUseHookDisplayState = useHookDisplayState as Mock;
|
||||
const mockedUseTerminalTheme = useTerminalTheme as Mock;
|
||||
|
||||
const DEFAULT_GEMINI_STREAM_MOCK = {
|
||||
streamingState: 'idle',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
handleApprovalModeChange: vi.fn(),
|
||||
activePtyId: null,
|
||||
loopDetectionConfirmationRequest: null,
|
||||
backgroundShellCount: 0,
|
||||
isBackgroundShellVisible: false,
|
||||
toggleBackgroundShell: vi.fn(),
|
||||
backgroundCurrentShell: vi.fn(),
|
||||
backgroundShells: new Map(),
|
||||
registerBackgroundShell: vi.fn(),
|
||||
dismissBackgroundShell: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -279,7 +296,6 @@ describe('AppContainer State Management', () => {
|
||||
mocks.mockStdout.write.mockClear();
|
||||
|
||||
capturedUIState = null!;
|
||||
capturedAskUserRequest = null;
|
||||
|
||||
// **Provide a default return value for EVERY mocked hook.**
|
||||
mockedUseQuotaAndFallback.mockReturnValue({
|
||||
@@ -334,14 +350,7 @@ describe('AppContainer State Management', () => {
|
||||
handleNewMessage: vi.fn(),
|
||||
clearConsoleMessages: vi.fn(),
|
||||
});
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'idle',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
});
|
||||
mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK);
|
||||
mockedUseVim.mockReturnValue({ handleInput: vi.fn() });
|
||||
mockedUseFolderTrust.mockReturnValue({
|
||||
isFolderTrustDialogOpen: false,
|
||||
@@ -368,7 +377,9 @@ describe('AppContainer State Management', () => {
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: '',
|
||||
setText: vi.fn(),
|
||||
// Add other properties if AppContainer uses them
|
||||
lines: [''],
|
||||
cursor: [0, 0],
|
||||
handleInput: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
mockedUseLogger.mockReturnValue({
|
||||
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
|
||||
@@ -383,6 +394,7 @@ describe('AppContainer State Management', () => {
|
||||
currentLoadingPhrase: '',
|
||||
});
|
||||
mockedUseHookDisplayState.mockReturnValue([]);
|
||||
mockedUseTerminalTheme.mockReturnValue(undefined);
|
||||
|
||||
// Mock Config
|
||||
mockConfig = makeFakeConfig();
|
||||
@@ -1193,12 +1205,9 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Mock the streaming state as Active
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Some thought' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
});
|
||||
|
||||
// Act: Render the container
|
||||
@@ -1234,12 +1243,9 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Mock the streaming state
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Some thought' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
});
|
||||
|
||||
// Act: Render the container
|
||||
@@ -1306,12 +1312,9 @@ describe('AppContainer State Management', () => {
|
||||
// Mock the streaming state and thought
|
||||
const thoughtSubject = 'Processing request';
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: thoughtSubject },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
});
|
||||
|
||||
// Act: Render the container
|
||||
@@ -1347,14 +1350,7 @@ describe('AppContainer State Management', () => {
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
// Mock the streaming state as Idle with no thought
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'idle',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
});
|
||||
mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK);
|
||||
|
||||
// Act: Render the container
|
||||
const { unmount } = renderAppContainer({
|
||||
@@ -1391,12 +1387,9 @@ describe('AppContainer State Management', () => {
|
||||
// Mock the streaming state and thought
|
||||
const thoughtSubject = 'Confirm tool execution';
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'waiting_for_confirmation',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: thoughtSubject },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
});
|
||||
|
||||
// Act: Render the container
|
||||
@@ -1448,16 +1441,11 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Mock an active shell pty but not focused
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Executing shell command' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
pendingToolCalls: [],
|
||||
handleApprovalModeChange: vi.fn(),
|
||||
activePtyId: 'pty-1',
|
||||
loopDetectionConfirmationRequest: null,
|
||||
lastOutputTime: startTime + 100, // Trigger aggressive delay
|
||||
retryStatus: null,
|
||||
});
|
||||
@@ -1512,12 +1500,9 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Mock an active shell pty with redirection active
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Executing shell command' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
pendingToolCalls: [
|
||||
{
|
||||
request: {
|
||||
@@ -1527,9 +1512,7 @@ describe('AppContainer State Management', () => {
|
||||
status: 'executing',
|
||||
} as unknown as TrackedToolCall,
|
||||
],
|
||||
handleApprovalModeChange: vi.fn(),
|
||||
activePtyId: 'pty-1',
|
||||
loopDetectionConfirmationRequest: null,
|
||||
lastOutputTime: startTime,
|
||||
retryStatus: null,
|
||||
});
|
||||
@@ -1587,16 +1570,11 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Mock an active shell pty with NO output since operation started (silent)
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Executing shell command' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
pendingToolCalls: [],
|
||||
handleApprovalModeChange: vi.fn(),
|
||||
activePtyId: 'pty-1',
|
||||
loopDetectionConfirmationRequest: null,
|
||||
lastOutputTime: startTime, // lastOutputTime <= operationStartTime
|
||||
retryStatus: null,
|
||||
});
|
||||
@@ -1643,12 +1621,9 @@ describe('AppContainer State Management', () => {
|
||||
// Mock an active shell pty but not focused
|
||||
let lastOutputTime = startTime + 1000;
|
||||
mockedUseGeminiStream.mockImplementation(() => ({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Executing shell command' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
activePtyId: 'pty-1',
|
||||
lastOutputTime,
|
||||
}));
|
||||
@@ -1669,12 +1644,9 @@ describe('AppContainer State Management', () => {
|
||||
// Update lastOutputTime to simulate new output
|
||||
lastOutputTime = startTime + 21000;
|
||||
mockedUseGeminiStream.mockImplementation(() => ({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Executing shell command' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
activePtyId: 'pty-1',
|
||||
lastOutputTime,
|
||||
}));
|
||||
@@ -1734,12 +1706,9 @@ describe('AppContainer State Management', () => {
|
||||
// Mock the streaming state and thought with a short subject
|
||||
const shortTitle = 'Short';
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: shortTitle },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
});
|
||||
|
||||
// Act: Render the container
|
||||
@@ -1778,12 +1747,9 @@ describe('AppContainer State Management', () => {
|
||||
// Mock the streaming state and thought
|
||||
const title = 'Test Title';
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: title },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
});
|
||||
|
||||
// Act: Render the container
|
||||
@@ -1821,12 +1787,8 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Mock the streaming state
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
});
|
||||
|
||||
// Act: Render the container
|
||||
@@ -1928,12 +1890,7 @@ describe('AppContainer State Management', () => {
|
||||
mockedMeasureElement.mockReturnValue({ width: 80, height: 10 }); // Footer is taller than the screen
|
||||
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'idle',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: 'some-id',
|
||||
});
|
||||
|
||||
@@ -1952,7 +1909,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => {
|
||||
let handleGlobalKeypress: (key: Key) => void;
|
||||
let handleGlobalKeypress: (key: Key) => boolean;
|
||||
let mockHandleSlashCommand: Mock;
|
||||
let mockCancelOngoingRequest: Mock;
|
||||
let rerender: () => void;
|
||||
@@ -1987,9 +1944,11 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
// Capture the keypress handler from the AppContainer
|
||||
mockedUseKeypress.mockImplementation((callback: (key: Key) => void) => {
|
||||
handleGlobalKeypress = callback;
|
||||
});
|
||||
mockedUseKeypress.mockImplementation(
|
||||
(callback: (key: Key) => boolean) => {
|
||||
handleGlobalKeypress = callback;
|
||||
},
|
||||
);
|
||||
|
||||
// Mock slash command handler
|
||||
mockHandleSlashCommand = vi.fn();
|
||||
@@ -2005,11 +1964,7 @@ describe('AppContainer State Management', () => {
|
||||
// Mock request cancellation
|
||||
mockCancelOngoingRequest = vi.fn();
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'idle',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
cancelOngoingRequest: mockCancelOngoingRequest,
|
||||
});
|
||||
|
||||
@@ -2017,6 +1972,9 @@ describe('AppContainer State Management', () => {
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: '',
|
||||
setText: vi.fn(),
|
||||
lines: [''],
|
||||
cursor: [0, 0],
|
||||
handleInput: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
@@ -2030,11 +1988,8 @@ describe('AppContainer State Management', () => {
|
||||
describe('CTRL+C', () => {
|
||||
it('should cancel ongoing request on first press', async () => {
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: mockCancelOngoingRequest,
|
||||
});
|
||||
await setupKeypressTest();
|
||||
@@ -2079,19 +2034,6 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('CTRL+D', () => {
|
||||
it('should do nothing if text buffer is not empty', async () => {
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: 'some text',
|
||||
setText: vi.fn(),
|
||||
});
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'd', ctrl: true }, 2);
|
||||
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should quit on second press if buffer is empty', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
@@ -2106,6 +2048,50 @@ describe('AppContainer State Management', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT quit if buffer is not empty (bubbles from InputPrompt)', async () => {
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: 'some text',
|
||||
setText: vi.fn(),
|
||||
lines: ['some text'],
|
||||
cursor: [0, 9], // At the end
|
||||
handleInput: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
await setupKeypressTest();
|
||||
|
||||
// Capture return value
|
||||
let result = true;
|
||||
const originalPressKey = (key: Partial<Key>) => {
|
||||
act(() => {
|
||||
result = handleGlobalKeypress({
|
||||
name: 'd',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
...key,
|
||||
} as Key);
|
||||
});
|
||||
rerender();
|
||||
};
|
||||
|
||||
originalPressKey({ name: 'd', ctrl: true });
|
||||
|
||||
// AppContainer's handler should return true if it reaches it
|
||||
expect(result).toBe(true);
|
||||
// But it should only be called once, so count is 1, not quitting yet.
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
|
||||
originalPressKey({ name: 'd', ctrl: true });
|
||||
// Now count is 2, it should quit.
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||
'/quit',
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should reset press count after a timeout', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
@@ -2125,7 +2111,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('Copy Mode (CTRL+S)', () => {
|
||||
let handleGlobalKeypress: (key: Key) => void;
|
||||
let handleGlobalKeypress: (key: Key) => boolean;
|
||||
let rerender: () => void;
|
||||
let unmount: () => void;
|
||||
|
||||
@@ -2155,9 +2141,11 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.mockStdout.write.mockClear();
|
||||
mockedUseKeypress.mockImplementation((callback: (key: Key) => void) => {
|
||||
handleGlobalKeypress = callback;
|
||||
});
|
||||
mockedUseKeypress.mockImplementation(
|
||||
(callback: (key: Key) => boolean) => {
|
||||
handleGlobalKeypress = callback;
|
||||
},
|
||||
);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
@@ -2510,6 +2498,59 @@ describe('AppContainer State Management', () => {
|
||||
expect(capturedUIState.activeHooks).toEqual(mockHooks);
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('handles consent request events', async () => {
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
const handler = mockCoreEvents.on.mock.calls.find(
|
||||
(call: unknown[]) => call[0] === CoreEvent.ConsentRequest,
|
||||
)?.[1];
|
||||
expect(handler).toBeDefined();
|
||||
|
||||
const onConfirm = vi.fn();
|
||||
const payload = {
|
||||
prompt: 'Do you consent?',
|
||||
onConfirm,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
handler(payload);
|
||||
});
|
||||
|
||||
expect(capturedUIState.authConsentRequest).toBeDefined();
|
||||
expect(capturedUIState.authConsentRequest?.prompt).toBe(
|
||||
'Do you consent?',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
capturedUIState.authConsentRequest?.onConfirm(true);
|
||||
});
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(true);
|
||||
expect(capturedUIState.authConsentRequest).toBeNull();
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('unsubscribes from ConsentRequest on unmount', async () => {
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await waitFor(() => expect(capturedUIState).toBeTruthy());
|
||||
|
||||
unmount!();
|
||||
|
||||
expect(mockCoreEvents.off).toHaveBeenCalledWith(
|
||||
CoreEvent.ConsentRequest,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shell Interaction', () => {
|
||||
@@ -2521,12 +2562,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'idle',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: 'some-pty-id', // Make sure activePtyId is set
|
||||
});
|
||||
|
||||
@@ -2676,41 +2712,6 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
unmount!();
|
||||
});
|
||||
|
||||
it('should show ask user dialog when request is received', async () => {
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = renderAppContainer();
|
||||
unmount = result.unmount;
|
||||
});
|
||||
|
||||
const questions = [
|
||||
{
|
||||
question: 'What is your favorite color?',
|
||||
header: 'Color Preference',
|
||||
type: QuestionType.TEXT,
|
||||
},
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
await mockConfig.getMessageBus().publish({
|
||||
type: MessageBusType.ASK_USER_REQUEST,
|
||||
questions,
|
||||
correlationId: 'test-id',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(capturedAskUserRequest).not.toBeNull();
|
||||
expect(capturedAskUserRequest?.questions).toEqual(questions);
|
||||
expect(capturedAskUserRequest?.correlationId).toBe('test-id');
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
unmount!();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regression Tests', () => {
|
||||
|
||||
@@ -27,13 +27,10 @@ import {
|
||||
type HistoryItemWithoutId,
|
||||
type HistoryItemToolGroup,
|
||||
AuthState,
|
||||
type ConfirmationRequest,
|
||||
} from './types.js';
|
||||
import { MessageType, StreamingState } from './types.js';
|
||||
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
|
||||
import {
|
||||
AskUserActionsProvider,
|
||||
type AskUserState,
|
||||
} from './contexts/AskUserActionsContext.js';
|
||||
import {
|
||||
type EditorType,
|
||||
type Config,
|
||||
@@ -68,8 +65,7 @@ import {
|
||||
SessionStartSource,
|
||||
SessionEndReason,
|
||||
generateSummary,
|
||||
MessageBusType,
|
||||
type AskUserRequest,
|
||||
type ConsentRequestPayload,
|
||||
type AgentsDiscoveredPayload,
|
||||
ChangeAuthRequestedError,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -97,6 +93,7 @@ import { computeTerminalTitle } from '../utils/windowTitle.js';
|
||||
import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||
import { useLogger } from './hooks/useLogger.js';
|
||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||
import { type BackgroundShell } from './hooks/shellCommandProcessor.js';
|
||||
import { useVim } from './hooks/vim.js';
|
||||
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
|
||||
import { type InitializationResult } from '../core/initializer.js';
|
||||
@@ -136,6 +133,7 @@ import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js'
|
||||
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
||||
import { useBanner } from './hooks/useBanner.js';
|
||||
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
||||
import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js';
|
||||
import {
|
||||
WARNING_PROMPT_DURATION_MS,
|
||||
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
||||
@@ -143,6 +141,7 @@ import {
|
||||
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
|
||||
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
|
||||
import { isSlashCommand } from './utils/commandUtils.js';
|
||||
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
|
||||
|
||||
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
||||
return pendingHistoryItems.some((item) => {
|
||||
@@ -257,6 +256,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
);
|
||||
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
|
||||
const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false);
|
||||
const toggleBackgroundShellRef = useRef<() => void>(() => {});
|
||||
const isBackgroundShellVisibleRef = useRef<boolean>(false);
|
||||
const backgroundShellsRef = useRef<Map<number, BackgroundShell>>(new Map());
|
||||
|
||||
const [adminSettingsChanged, setAdminSettingsChanged] = useState(false);
|
||||
|
||||
const [shellModeActive, setShellModeActive] = useState(false);
|
||||
@@ -336,11 +339,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
AgentDefinition | undefined
|
||||
>();
|
||||
|
||||
// AskUser dialog state
|
||||
const [askUserRequest, setAskUserRequest] = useState<AskUserState | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const openAgentConfigDialog = useCallback(
|
||||
(name: string, displayName: string, definition: AgentDefinition) => {
|
||||
setSelectedAgentName(name);
|
||||
@@ -358,56 +356,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
setSelectedAgentDefinition(undefined);
|
||||
}, []);
|
||||
|
||||
// Subscribe to ASK_USER_REQUEST messages from the message bus
|
||||
useEffect(() => {
|
||||
const messageBus = config.getMessageBus();
|
||||
|
||||
const handler = (msg: AskUserRequest) => {
|
||||
setAskUserRequest({
|
||||
questions: msg.questions,
|
||||
correlationId: msg.correlationId,
|
||||
});
|
||||
};
|
||||
|
||||
messageBus.subscribe(MessageBusType.ASK_USER_REQUEST, handler);
|
||||
|
||||
return () => {
|
||||
messageBus.unsubscribe(MessageBusType.ASK_USER_REQUEST, handler);
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
// Handler to submit ask_user answers
|
||||
const handleAskUserSubmit = useCallback(
|
||||
async (answers: { [questionIndex: string]: string }) => {
|
||||
if (!askUserRequest) return;
|
||||
|
||||
const messageBus = config.getMessageBus();
|
||||
await messageBus.publish({
|
||||
type: MessageBusType.ASK_USER_RESPONSE,
|
||||
correlationId: askUserRequest.correlationId,
|
||||
answers,
|
||||
});
|
||||
|
||||
setAskUserRequest(null);
|
||||
},
|
||||
[config, askUserRequest],
|
||||
);
|
||||
|
||||
// Handler to cancel ask_user dialog
|
||||
const handleAskUserCancel = useCallback(async () => {
|
||||
if (!askUserRequest) return;
|
||||
|
||||
const messageBus = config.getMessageBus();
|
||||
await messageBus.publish({
|
||||
type: MessageBusType.ASK_USER_RESPONSE,
|
||||
correlationId: askUserRequest.correlationId,
|
||||
answers: {},
|
||||
cancelled: true,
|
||||
});
|
||||
|
||||
setAskUserRequest(null);
|
||||
}, [config, askUserRequest]);
|
||||
|
||||
const toggleDebugProfiler = useCallback(
|
||||
() => setShowDebugProfiler((prev) => !prev),
|
||||
[],
|
||||
@@ -487,6 +435,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
registerCleanup(async () => {
|
||||
// Turn off mouse scroll.
|
||||
disableMouseEvents();
|
||||
|
||||
// Kill all background shells
|
||||
for (const pid of backgroundShellsRef.current.keys()) {
|
||||
ShellExecutionService.kill(pid);
|
||||
}
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.disconnect();
|
||||
|
||||
@@ -579,6 +533,14 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
shellModeActive,
|
||||
getPreferredEditor,
|
||||
});
|
||||
const bufferRef = useRef(buffer);
|
||||
useEffect(() => {
|
||||
bufferRef.current = buffer;
|
||||
}, [buffer]);
|
||||
|
||||
const stableSetText = useCallback((text: string) => {
|
||||
bufferRef.current.setText(text);
|
||||
}, []);
|
||||
|
||||
// Initialize input history from logger (past sessions)
|
||||
useEffect(() => {
|
||||
@@ -640,6 +602,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
initializationResult.themeError,
|
||||
);
|
||||
|
||||
// Poll for terminal background color changes to auto-switch theme
|
||||
useTerminalTheme(handleThemeSelect, config);
|
||||
|
||||
const {
|
||||
authState,
|
||||
setAuthState,
|
||||
@@ -835,6 +800,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const { toggleVimEnabled } = useVimMode();
|
||||
|
||||
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
|
||||
() => {},
|
||||
);
|
||||
|
||||
const slashCommandActions = useMemo(
|
||||
() => ({
|
||||
openAuthDialog: () => setAuthState(AuthState.Updating),
|
||||
@@ -858,7 +827,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
toggleDebugProfiler,
|
||||
dispatchExtensionStateUpdate,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
setText: (text: string) => buffer.setText(text),
|
||||
toggleBackgroundShell: () => {
|
||||
toggleBackgroundShellRef.current();
|
||||
if (!isBackgroundShellVisibleRef.current) {
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundShellsRef.current.size > 1) {
|
||||
setIsBackgroundShellListOpenRef.current(true);
|
||||
} else {
|
||||
setIsBackgroundShellListOpenRef.current(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
setText: stableSetText,
|
||||
}),
|
||||
[
|
||||
setAuthState,
|
||||
@@ -876,7 +856,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
openPermissionsDialog,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
toggleDebugProfiler,
|
||||
buffer,
|
||||
stableSetText,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -885,7 +865,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
slashCommands,
|
||||
pendingHistoryItems: pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
confirmationRequest,
|
||||
confirmationRequest: commandConfirmationRequest,
|
||||
} = useSlashCommandProcessor(
|
||||
config,
|
||||
settings,
|
||||
@@ -902,6 +882,26 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setCustomDialog,
|
||||
);
|
||||
|
||||
const [authConsentRequest, setAuthConsentRequest] =
|
||||
useState<ConfirmationRequest | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleConsentRequest = (payload: ConsentRequestPayload) => {
|
||||
setAuthConsentRequest({
|
||||
prompt: payload.prompt,
|
||||
onConfirm: (confirmed: boolean) => {
|
||||
setAuthConsentRequest(null);
|
||||
payload.onConfirm(confirmed);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
coreEvents.on(CoreEvent.ConsentRequest, handleConsentRequest);
|
||||
return () => {
|
||||
coreEvents.off(CoreEvent.ConsentRequest, handleConsentRequest);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const performMemoryRefresh = useCallback(async () => {
|
||||
historyManager.addItem(
|
||||
{
|
||||
@@ -989,6 +989,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
activePtyId,
|
||||
loopDetectionConfirmationRequest,
|
||||
lastOutputTime,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
toggleBackgroundShell,
|
||||
backgroundCurrentShell,
|
||||
backgroundShells,
|
||||
dismissBackgroundShell,
|
||||
retryStatus,
|
||||
} = useGeminiStream(
|
||||
config.getGeminiClient(),
|
||||
@@ -1011,7 +1017,30 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
embeddedShellFocused,
|
||||
);
|
||||
|
||||
toggleBackgroundShellRef.current = toggleBackgroundShell;
|
||||
isBackgroundShellVisibleRef.current = isBackgroundShellVisible;
|
||||
backgroundShellsRef.current = backgroundShells;
|
||||
|
||||
const {
|
||||
activeBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
isBackgroundShellListOpen,
|
||||
setActiveBackgroundShellPid,
|
||||
backgroundShellHeight,
|
||||
} = useBackgroundShellManager({
|
||||
backgroundShells,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
setIsBackgroundShellListOpenRef.current = setIsBackgroundShellListOpen;
|
||||
|
||||
const lastOutputTimeRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
lastOutputTimeRef.current = lastOutputTime;
|
||||
}, [lastOutputTime]);
|
||||
@@ -1160,7 +1189,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
// Compute available terminal height based on controls measurement
|
||||
const availableTerminalHeight = Math.max(
|
||||
0,
|
||||
terminalHeight - controlsHeight - staticExtraHeight - 2,
|
||||
terminalHeight -
|
||||
controlsHeight -
|
||||
staticExtraHeight -
|
||||
2 -
|
||||
backgroundShellHeight,
|
||||
);
|
||||
|
||||
config.setShellExecutionConfig({
|
||||
@@ -1384,7 +1417,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
if (ctrlCPressCount > 1) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
handleSlashCommand('/quit', undefined, undefined, false);
|
||||
} else {
|
||||
} else if (ctrlCPressCount > 0) {
|
||||
ctrlCTimerRef.current = setTimeout(() => {
|
||||
setCtrlCPressCount(0);
|
||||
ctrlCTimerRef.current = null;
|
||||
@@ -1403,7 +1436,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
if (ctrlDPressCount > 1) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
handleSlashCommand('/quit', undefined, undefined, false);
|
||||
} else {
|
||||
} else if (ctrlDPressCount > 0) {
|
||||
ctrlDTimerRef.current = setTimeout(() => {
|
||||
setCtrlDPressCount(0);
|
||||
ctrlDTimerRef.current = null;
|
||||
@@ -1444,7 +1477,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
});
|
||||
|
||||
const handleGlobalKeypress = useCallback(
|
||||
(key: Key) => {
|
||||
(key: Key): boolean => {
|
||||
if (copyModeEnabled) {
|
||||
setCopyModeEnabled(false);
|
||||
enableMouseEvents();
|
||||
@@ -1464,10 +1497,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.QUIT](key)) {
|
||||
// Skip when ask_user dialog is open (use Esc to cancel instead)
|
||||
if (askUserRequest) {
|
||||
return;
|
||||
}
|
||||
// If the user presses Ctrl+C, we want to cancel any ongoing requests.
|
||||
// This should happen regardless of the count.
|
||||
cancelOngoingRequest?.();
|
||||
@@ -1475,9 +1504,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setCtrlCPressCount((prev) => prev + 1);
|
||||
return true;
|
||||
} else if (keyMatchers[Command.EXIT](key)) {
|
||||
if (buffer.text.length > 0) {
|
||||
return false;
|
||||
}
|
||||
setCtrlDPressCount((prev) => prev + 1);
|
||||
return true;
|
||||
}
|
||||
@@ -1520,9 +1546,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setConstrainHeight(false);
|
||||
return true;
|
||||
} else if (
|
||||
keyMatchers[Command.UNFOCUS_SHELL_INPUT](key) &&
|
||||
activePtyId &&
|
||||
embeddedShellFocused
|
||||
keyMatchers[Command.FOCUS_SHELL_INPUT](key) &&
|
||||
(activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0))
|
||||
) {
|
||||
if (key.name === 'tab' && key.shift) {
|
||||
// Always change focus
|
||||
@@ -1530,26 +1555,72 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
return true;
|
||||
}
|
||||
|
||||
if (embeddedShellFocused) {
|
||||
handleWarning('Press Shift+Tab to focus out.');
|
||||
return true;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
// If the shell hasn't produced output in the last 100ms, it's considered idle.
|
||||
const isIdle = now - lastOutputTimeRef.current >= 100;
|
||||
if (isIdle) {
|
||||
if (isIdle && !activePtyId) {
|
||||
if (tabFocusTimeoutRef.current) {
|
||||
clearTimeout(tabFocusTimeoutRef.current);
|
||||
}
|
||||
tabFocusTimeoutRef.current = setTimeout(() => {
|
||||
tabFocusTimeoutRef.current = null;
|
||||
// If the shell produced output since the tab press, we assume it handled the tab
|
||||
// (e.g. autocomplete) so we should not toggle focus.
|
||||
if (lastOutputTimeRef.current > now) {
|
||||
handleWarning('Press Shift+Tab to focus out.');
|
||||
return;
|
||||
toggleBackgroundShell();
|
||||
if (!isBackgroundShellVisible) {
|
||||
// We are about to show it, so focus it
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundShells.size > 1) {
|
||||
setIsBackgroundShellListOpen(true);
|
||||
}
|
||||
setEmbeddedShellFocused(false);
|
||||
}, 100);
|
||||
} else {
|
||||
// We are about to hide it
|
||||
tabFocusTimeoutRef.current = setTimeout(() => {
|
||||
tabFocusTimeoutRef.current = null;
|
||||
// If the shell produced output since the tab press, we assume it handled the tab
|
||||
// (e.g. autocomplete) so we should not toggle focus.
|
||||
if (lastOutputTimeRef.current > now) {
|
||||
handleWarning('Press Shift+Tab to focus out.');
|
||||
return;
|
||||
}
|
||||
setEmbeddedShellFocused(false);
|
||||
}, 100);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
handleWarning('Press Shift+Tab to focus out.');
|
||||
|
||||
// Not idle, just focus it
|
||||
setEmbeddedShellFocused(true);
|
||||
return true;
|
||||
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
|
||||
if (activePtyId) {
|
||||
backgroundCurrentShell();
|
||||
// After backgrounding, we explicitly do NOT show or focus the background UI.
|
||||
} else {
|
||||
if (isBackgroundShellVisible && !embeddedShellFocused) {
|
||||
setEmbeddedShellFocused(true);
|
||||
} else {
|
||||
toggleBackgroundShell();
|
||||
// Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
|
||||
if (!isBackgroundShellVisible && backgroundShells.size > 0) {
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundShells.size > 1) {
|
||||
setIsBackgroundShellListOpen(true);
|
||||
}
|
||||
} else {
|
||||
setEmbeddedShellFocused(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
|
||||
if (backgroundShells.size > 0 && isBackgroundShellVisible) {
|
||||
if (!embeddedShellFocused) {
|
||||
setEmbeddedShellFocused(true);
|
||||
}
|
||||
setIsBackgroundShellListOpen(true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -1561,11 +1632,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
config,
|
||||
ideContextState,
|
||||
setCtrlCPressCount,
|
||||
buffer.text.length,
|
||||
setCtrlDPressCount,
|
||||
handleSlashCommand,
|
||||
cancelOngoingRequest,
|
||||
askUserRequest,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
settings.merged.general.debugKeystrokeLogging,
|
||||
@@ -1573,6 +1642,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setCopyModeEnabled,
|
||||
copyModeEnabled,
|
||||
isAlternateBuffer,
|
||||
backgroundCurrentShell,
|
||||
toggleBackgroundShell,
|
||||
backgroundShells,
|
||||
isBackgroundShellVisible,
|
||||
setIsBackgroundShellListOpen,
|
||||
lastOutputTimeRef,
|
||||
tabFocusTimeoutRef,
|
||||
handleWarning,
|
||||
],
|
||||
);
|
||||
@@ -1586,7 +1662,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const paddedTitle = computeTerminalTitle({
|
||||
streamingState,
|
||||
thoughtSubject: thought?.subject,
|
||||
isConfirming: !!confirmationRequest || shouldShowActionRequiredTitle,
|
||||
isConfirming:
|
||||
!!commandConfirmationRequest || shouldShowActionRequiredTitle,
|
||||
isSilentWorking: shouldShowSilentWorkingTitle,
|
||||
folderName: basename(config.getTargetDir()),
|
||||
showThoughts: !!settings.merged.ui.showStatusInTitle,
|
||||
@@ -1602,7 +1679,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}, [
|
||||
streamingState,
|
||||
thought,
|
||||
confirmationRequest,
|
||||
commandConfirmationRequest,
|
||||
shouldShowActionRequiredTitle,
|
||||
shouldShowSilentWorkingTitle,
|
||||
settings.merged.ui.showStatusInTitle,
|
||||
@@ -1678,11 +1755,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const nightly = props.version.includes('nightly');
|
||||
|
||||
const dialogsVisible =
|
||||
!!askUserRequest ||
|
||||
shouldShowIdePrompt ||
|
||||
isFolderTrustDialogOpen ||
|
||||
adminSettingsChanged ||
|
||||
!!confirmationRequest ||
|
||||
!!commandConfirmationRequest ||
|
||||
!!authConsentRequest ||
|
||||
!!customDialog ||
|
||||
confirmUpdateExtensionRequests.length > 0 ||
|
||||
!!loopDetectionConfirmationRequest ||
|
||||
@@ -1792,7 +1869,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
slashCommands,
|
||||
pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
confirmationRequest,
|
||||
commandConfirmationRequest,
|
||||
authConsentRequest,
|
||||
confirmUpdateExtensionRequests,
|
||||
loopDetectionConfirmationRequest,
|
||||
geminiMdFileCount,
|
||||
@@ -1853,6 +1931,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isRestarting,
|
||||
extensionsUpdateState,
|
||||
activePtyId,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
embeddedShellFocused,
|
||||
showDebugProfiler,
|
||||
customDialog,
|
||||
@@ -1862,6 +1942,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
bannerVisible,
|
||||
terminalBackgroundColor: config.getTerminalBackground(),
|
||||
settingsNonce,
|
||||
backgroundShells,
|
||||
activeBackgroundShellPid,
|
||||
backgroundShellHeight,
|
||||
isBackgroundShellListOpen,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
}),
|
||||
@@ -1890,7 +1974,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
slashCommands,
|
||||
pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
confirmationRequest,
|
||||
commandConfirmationRequest,
|
||||
authConsentRequest,
|
||||
confirmUpdateExtensionRequests,
|
||||
loopDetectionConfirmationRequest,
|
||||
geminiMdFileCount,
|
||||
@@ -1951,6 +2036,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
currentModel,
|
||||
extensionsUpdateState,
|
||||
activePtyId,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
historyManager,
|
||||
embeddedShellFocused,
|
||||
showDebugProfiler,
|
||||
@@ -1963,6 +2050,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
bannerVisible,
|
||||
config,
|
||||
settingsNonce,
|
||||
backgroundShellHeight,
|
||||
isBackgroundShellListOpen,
|
||||
activeBackgroundShellPid,
|
||||
backgroundShells,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
],
|
||||
@@ -2010,7 +2101,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
setBannerVisible,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
setAuthContext,
|
||||
handleRestart: async () => {
|
||||
if (process.send) {
|
||||
@@ -2082,7 +2177,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
setBannerVisible,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
setAuthContext,
|
||||
newAgents,
|
||||
config,
|
||||
@@ -2113,15 +2212,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}}
|
||||
>
|
||||
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
|
||||
<AskUserActionsProvider
|
||||
request={askUserRequest}
|
||||
onSubmit={handleAskUserSubmit}
|
||||
onCancel={handleAskUserCancel}
|
||||
>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
<App />
|
||||
</ShellFocusContext.Provider>
|
||||
</AskUserActionsProvider>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
<App />
|
||||
</ShellFocusContext.Provider>
|
||||
</ToolActionsProvider>
|
||||
</AppContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
|
||||
@@ -125,7 +125,7 @@ Tips for getting started:
|
||||
4. /help for more information.
|
||||
HistoryItemDisplay
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Action Required 1 of 1 │
|
||||
│ Action Required │
|
||||
│ │
|
||||
│ ? ls list directory │
|
||||
│ │
|
||||
|
||||
@@ -30,9 +30,6 @@ describe('hooksCommand', () => {
|
||||
hooksConfig?: {
|
||||
disabled?: string[];
|
||||
};
|
||||
tools?: {
|
||||
enableHooks?: boolean;
|
||||
};
|
||||
};
|
||||
setValue: ReturnType<typeof vi.fn>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -187,8 +184,8 @@ describe('hooksCommand', () => {
|
||||
it('should display panel when no hooks are configured', async () => {
|
||||
mockHookSystem.getAllHooks.mockReturnValue([]);
|
||||
(mockContext.services.settings.merged as Record<string, unknown>)[
|
||||
'tools'
|
||||
] = { enableHooks: true };
|
||||
'hooksConfig'
|
||||
] = { enabled: true };
|
||||
|
||||
const panelCmd = hooksCommand.subCommands!.find(
|
||||
(cmd) => cmd.name === 'panel',
|
||||
@@ -215,8 +212,8 @@ describe('hooksCommand', () => {
|
||||
|
||||
mockHookSystem.getAllHooks.mockReturnValue(mockHooks);
|
||||
(mockContext.services.settings.merged as Record<string, unknown>)[
|
||||
'tools'
|
||||
] = { enableHooks: true };
|
||||
'hooksConfig'
|
||||
] = { enabled: true };
|
||||
|
||||
const panelCmd = hooksCommand.subCommands!.find(
|
||||
(cmd) => cmd.name === 'panel',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type OpenCustomDialogActionReturn,
|
||||
} from './types.js';
|
||||
import { TriageDuplicates } from '../components/triage/TriageDuplicates.js';
|
||||
import { TriageIssues } from '../components/triage/TriageIssues.js';
|
||||
|
||||
export const oncallCommand: SlashCommand = {
|
||||
name: 'oncall',
|
||||
@@ -49,5 +50,63 @@ export const oncallCommand: SlashCommand = {
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'audit',
|
||||
description: 'Triage issues labeled as status/need-triage',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (context, args): Promise<OpenCustomDialogActionReturn> => {
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
throw new Error('Config not available');
|
||||
}
|
||||
|
||||
let limit = 100;
|
||||
let until: string | undefined;
|
||||
|
||||
if (args && args.trim().length > 0) {
|
||||
const argArray = args.trim().split(/\s+/);
|
||||
for (let i = 0; i < argArray.length; i++) {
|
||||
const arg = argArray[i];
|
||||
if (arg === '--until') {
|
||||
if (i + 1 >= argArray.length) {
|
||||
throw new Error('Flag --until requires a value (YYYY-MM-DD).');
|
||||
}
|
||||
const val = argArray[i + 1];
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(val)) {
|
||||
throw new Error(
|
||||
`Invalid date format for --until: "${val}". Expected YYYY-MM-DD.`,
|
||||
);
|
||||
}
|
||||
until = val;
|
||||
i++;
|
||||
} else if (arg.startsWith('--')) {
|
||||
throw new Error(`Unknown flag: ${arg}`);
|
||||
} else {
|
||||
const parsedLimit = parseInt(arg, 10);
|
||||
if (!isNaN(parsedLimit) && parsedLimit > 0) {
|
||||
limit = parsedLimit;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Invalid argument: "${arg}". Expected a positive number or --until flag.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'custom_dialog',
|
||||
component: (
|
||||
<TriageIssues
|
||||
config={config}
|
||||
initialLimit={limit}
|
||||
until={until}
|
||||
onExit={() => context.ui.removeComponent()}
|
||||
/>
|
||||
),
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -14,14 +14,14 @@ import { type HistoryItem } from '../types.js';
|
||||
import { convertSessionToHistoryFormats } from '../hooks/useSessionBrowser.js';
|
||||
import { revertFileChanges } from '../utils/rewindFileOps.js';
|
||||
import { RewindOutcome } from '../components/RewindConfirmation.js';
|
||||
import { checkExhaustive } from '../../utils/checks.js';
|
||||
|
||||
import type { Content } from '@google/genai';
|
||||
import type {
|
||||
ChatRecordingService,
|
||||
GeminiClient,
|
||||
import {
|
||||
checkExhaustive,
|
||||
coreEvents,
|
||||
debugLogger,
|
||||
type ChatRecordingService,
|
||||
type GeminiClient,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
* Helper function to handle the core logic of rewinding a conversation.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { shellsCommand } from './shellsCommand.js';
|
||||
import type { CommandContext } from './types.js';
|
||||
|
||||
describe('shellsCommand', () => {
|
||||
it('should call toggleBackgroundShell', async () => {
|
||||
const toggleBackgroundShell = vi.fn();
|
||||
const context = {
|
||||
ui: {
|
||||
toggleBackgroundShell,
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
|
||||
if (shellsCommand.action) {
|
||||
await shellsCommand.action(context, '');
|
||||
}
|
||||
|
||||
expect(toggleBackgroundShell).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should have correct name and altNames', () => {
|
||||
expect(shellsCommand.name).toBe('shells');
|
||||
expect(shellsCommand.altNames).toContain('bashes');
|
||||
});
|
||||
|
||||
it('should auto-execute', () => {
|
||||
expect(shellsCommand.autoExecute).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
|
||||
export const shellsCommand: SlashCommand = {
|
||||
name: 'shells',
|
||||
altNames: ['bashes'],
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'Toggle background shells view',
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
context.ui.toggleBackgroundShell();
|
||||
},
|
||||
};
|
||||
@@ -58,6 +58,7 @@ describe('skillsCommand', () => {
|
||||
(name: string) => skills.find((s) => s.name === name) ?? null,
|
||||
),
|
||||
}),
|
||||
getContentGenerator: vi.fn(),
|
||||
} as unknown as Config,
|
||||
settings: {
|
||||
merged: createTestMergedSettings({ skills: { disabled: [] } }),
|
||||
@@ -367,7 +368,7 @@ describe('skillsCommand', () => {
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Agent skills are disabled by your admin.',
|
||||
text: 'Agent skills is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
@@ -385,7 +386,7 @@ describe('skillsCommand', () => {
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Agent skills are disabled by your admin.',
|
||||
text: 'Agent skills is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
@@ -11,13 +11,15 @@ import {
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import {
|
||||
MessageType,
|
||||
type HistoryItemSkillsList,
|
||||
type HistoryItemInfo,
|
||||
type HistoryItemSkillsList,
|
||||
MessageType,
|
||||
} from '../types.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { enableSkill, disableSkill } from '../../utils/skillSettings.js';
|
||||
import { disableSkill, enableSkill } from '../../utils/skillSettings.js';
|
||||
|
||||
import { getAdminErrorMessage } from '@google/gemini-cli-core';
|
||||
import { renderSkillActionFeedback } from '../../utils/skillUtils.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
|
||||
async function listAction(
|
||||
context: CommandContext,
|
||||
@@ -83,7 +85,10 @@ async function disableAction(
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Agent skills are disabled by your admin.',
|
||||
text: getAdminErrorMessage(
|
||||
'Agent skills',
|
||||
context.services.config ?? undefined,
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
@@ -141,7 +146,10 @@ async function enableAction(
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Agent skills are disabled by your admin.',
|
||||
text: getAdminErrorMessage(
|
||||
'Agent skills',
|
||||
context.services.config ?? undefined,
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
@@ -84,6 +84,7 @@ export interface CommandContext {
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
|
||||
removeComponent: () => void;
|
||||
toggleBackgroundShell: () => void;
|
||||
};
|
||||
// Session-specific data
|
||||
session: {
|
||||
|
||||
@@ -10,6 +10,7 @@ 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';
|
||||
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) => {
|
||||
@@ -41,6 +42,7 @@ describe('AskUserDialog', () => {
|
||||
questions={authQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -105,6 +107,7 @@ describe('AskUserDialog', () => {
|
||||
questions={questions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -124,6 +127,7 @@ describe('AskUserDialog', () => {
|
||||
questions={authQuestion}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -153,12 +157,57 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ useAlternateBuffer: true, expectedArrows: false },
|
||||
{ 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',
|
||||
options: Array.from({ length: 15 }, (_, i) => ({
|
||||
label: `Option ${i + 1}`,
|
||||
description: `Description ${i + 1}`,
|
||||
})),
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={80}
|
||||
availableHeight={10} // Small height to force scrolling
|
||||
/>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
if (expectedArrows) {
|
||||
expect(lastFrame()).toContain('▲');
|
||||
expect(lastFrame()).toContain('▼');
|
||||
} else {
|
||||
expect(lastFrame()).not.toContain('▲');
|
||||
expect(lastFrame()).not.toContain('▼');
|
||||
}
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => {
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={authQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -209,6 +258,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -222,6 +272,7 @@ describe('AskUserDialog', () => {
|
||||
questions={authQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -235,6 +286,7 @@ describe('AskUserDialog', () => {
|
||||
questions={authQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -265,6 +317,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -306,6 +359,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -373,6 +427,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -401,6 +456,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -445,6 +501,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -480,6 +537,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -512,6 +570,7 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -533,6 +592,7 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -554,6 +614,7 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -588,6 +649,7 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -618,6 +680,7 @@ describe('AskUserDialog', () => {
|
||||
questions={mixedQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -664,6 +727,7 @@ describe('AskUserDialog', () => {
|
||||
questions={mixedQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -713,6 +777,7 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -738,6 +803,7 @@ describe('AskUserDialog', () => {
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -783,6 +849,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -841,6 +908,7 @@ describe('AskUserDialog', () => {
|
||||
questions={multiQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
@@ -872,4 +940,139 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('uses availableTerminalHeight from UIStateContext if availableHeight prop is missing', () => {
|
||||
const questions: Question[] = [
|
||||
{
|
||||
question: 'Choose an option',
|
||||
header: 'Context Test',
|
||||
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 } = renderWithProviders(
|
||||
<UIStateContext.Provider value={mockUIState}>
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={80}
|
||||
/>
|
||||
</UIStateContext.Provider>,
|
||||
{ useAlternateBuffer: false },
|
||||
);
|
||||
|
||||
// With height 5 and alternate buffer disabled, it should show scroll arrows (▲)
|
||||
expect(lastFrame()).toContain('▲');
|
||||
expect(lastFrame()).toContain('▼');
|
||||
});
|
||||
|
||||
it('does NOT truncate the question when in alternate buffer mode even with small height', () => {
|
||||
const longQuestion =
|
||||
'This is a very long question ' + 'with many words '.repeat(10);
|
||||
const questions: Question[] = [
|
||||
{
|
||||
question: longQuestion,
|
||||
header: 'Alternate Buffer Test',
|
||||
options: [{ label: 'Option 1', description: 'Desc 1' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const mockUIState = {
|
||||
availableTerminalHeight: 5,
|
||||
} as UIState;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<UIStateContext.Provider value={mockUIState}>
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={40} // Small width to force wrapping
|
||||
/>
|
||||
</UIStateContext.Provider>,
|
||||
{ useAlternateBuffer: true },
|
||||
);
|
||||
|
||||
// Should NOT contain the truncation message
|
||||
expect(lastFrame()).not.toContain('hidden ...');
|
||||
// Should contain the full long question (or at least its parts)
|
||||
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',
|
||||
options: [
|
||||
{ label: 'TypeScript', description: '' },
|
||||
{ label: 'JavaScript', description: '' },
|
||||
],
|
||||
placeholder: 'Type another language...',
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={80}
|
||||
/>,
|
||||
{ width: 80 },
|
||||
);
|
||||
|
||||
// Navigate to the "Other" option
|
||||
writeKey(stdin, '\x1b[B'); // Down
|
||||
writeKey(stdin, '\x1b[B'); // Down to Other
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it('uses default placeholder when not provided', async () => {
|
||||
const questions: Question[] = [
|
||||
{
|
||||
question: 'Select your preferred language:',
|
||||
header: 'Language',
|
||||
options: [
|
||||
{ label: 'TypeScript', description: '' },
|
||||
{ label: 'JavaScript', description: '' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={80}
|
||||
/>,
|
||||
{ width: 80 },
|
||||
);
|
||||
|
||||
// Navigate to the "Other" option
|
||||
writeKey(stdin, '\x1b[B'); // Down
|
||||
writeKey(stdin, '\x1b[B'); // Down to Other
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,13 +21,15 @@ import type { SelectionListItem } from '../hooks/useSelectionList.js';
|
||||
import { TabHeader, type Tab } from './shared/TabHeader.js';
|
||||
import { useKeypress, type Key } from '../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
import { checkExhaustive } from '../../utils/checks.js';
|
||||
import { checkExhaustive } from '@google/gemini-cli-core';
|
||||
import { TextInput } from './shared/TextInput.js';
|
||||
import { useTextBuffer } from './shared/text-buffer.js';
|
||||
import { UIStateContext } from '../contexts/UIStateContext.js';
|
||||
import { getCachedStringWidth } from '../utils/textUtils.js';
|
||||
import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
|
||||
import { DialogFooter } from './shared/DialogFooter.js';
|
||||
import { MaxSizedBox } from './shared/MaxSizedBox.js';
|
||||
import { UIStateContext } from '../contexts/UIStateContext.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
|
||||
interface AskUserDialogState {
|
||||
answers: { [key: string]: string };
|
||||
@@ -121,6 +123,14 @@ interface AskUserDialogProps {
|
||||
* Useful for managing global keypress handlers.
|
||||
*/
|
||||
onActiveTextInputChange?: (active: boolean) => void;
|
||||
/**
|
||||
* Width of the dialog.
|
||||
*/
|
||||
width: number;
|
||||
/**
|
||||
* Height constraint for scrollable content.
|
||||
*/
|
||||
availableHeight?: number;
|
||||
}
|
||||
|
||||
interface ReviewViewProps {
|
||||
@@ -152,12 +162,7 @@ const ReviewView: React.FC<ReviewViewProps> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
paddingX={1}
|
||||
borderColor={theme.border.default}
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
{progressHeader}
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
@@ -174,15 +179,19 @@ const ReviewView: React.FC<ReviewViewProps> = ({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{questions.map((q, i) => (
|
||||
<Box key={i} marginBottom={0}>
|
||||
<Text color={theme.text.secondary}>{q.header}</Text>
|
||||
<Text color={theme.text.secondary}> → </Text>
|
||||
<Text color={answers[i] ? theme.text.primary : theme.status.warning}>
|
||||
{answers[i] || '(not answered)'}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Box flexDirection="column">
|
||||
{questions.map((q, i) => (
|
||||
<Box key={i} marginBottom={0}>
|
||||
<Text color={theme.text.secondary}>{q.header}</Text>
|
||||
<Text color={theme.text.secondary}> → </Text>
|
||||
<Text
|
||||
color={answers[i] ? theme.text.primary : theme.status.warning}
|
||||
>
|
||||
{answers[i] || '(not answered)'}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<DialogFooter
|
||||
primaryAction="Enter to submit"
|
||||
navigationActions="Tab/Shift+Tab to edit answers"
|
||||
@@ -199,6 +208,7 @@ interface TextQuestionViewProps {
|
||||
onSelectionChange?: (answer: string) => void;
|
||||
onEditingCustomOption?: (editing: boolean) => void;
|
||||
availableWidth: number;
|
||||
availableHeight?: number;
|
||||
initialAnswer?: string;
|
||||
progressHeader?: React.ReactNode;
|
||||
keyboardHints?: React.ReactNode;
|
||||
@@ -210,12 +220,14 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
onSelectionChange,
|
||||
onEditingCustomOption,
|
||||
availableWidth,
|
||||
availableHeight,
|
||||
initialAnswer,
|
||||
progressHeader,
|
||||
keyboardHints,
|
||||
}) => {
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const prefix = '> ';
|
||||
const horizontalPadding = 4 + 1; // Padding from Box (2) and border (2) + 1 for cursor
|
||||
const horizontalPadding = 1; // 1 for cursor
|
||||
const bufferWidth =
|
||||
availableWidth - getCachedStringWidth(prefix) - horizontalPadding;
|
||||
|
||||
@@ -241,12 +253,15 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
const handleExtraKeys = useCallback(
|
||||
(key: Key) => {
|
||||
if (keyMatchers[Command.QUIT](key)) {
|
||||
if (textValue === '') {
|
||||
return false;
|
||||
}
|
||||
buffer.setText('');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[buffer],
|
||||
[buffer, textValue],
|
||||
);
|
||||
|
||||
useKeypress(handleExtraKeys, { isActive: true, priority: true });
|
||||
@@ -270,18 +285,28 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
|
||||
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 (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
paddingX={1}
|
||||
borderColor={theme.border.default}
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
{progressHeader}
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{question.question}
|
||||
</Text>
|
||||
<MaxSizedBox
|
||||
maxHeight={questionHeight}
|
||||
maxWidth={availableWidth}
|
||||
overflowDirection="bottom"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{question.question}
|
||||
</Text>
|
||||
</MaxSizedBox>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
@@ -381,6 +406,7 @@ interface ChoiceQuestionViewProps {
|
||||
onSelectionChange?: (answer: string) => void;
|
||||
onEditingCustomOption?: (editing: boolean) => void;
|
||||
availableWidth: number;
|
||||
availableHeight?: number;
|
||||
initialAnswer?: string;
|
||||
progressHeader?: React.ReactNode;
|
||||
keyboardHints?: React.ReactNode;
|
||||
@@ -391,14 +417,13 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
onAnswer,
|
||||
onSelectionChange,
|
||||
onEditingCustomOption,
|
||||
availableWidth,
|
||||
availableHeight,
|
||||
initialAnswer,
|
||||
progressHeader,
|
||||
keyboardHints,
|
||||
}) => {
|
||||
const uiState = useContext(UIStateContext);
|
||||
const terminalWidth = uiState?.terminalWidth ?? 80;
|
||||
const availableWidth = terminalWidth;
|
||||
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const numOptions =
|
||||
(question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0);
|
||||
const numLen = String(numOptions).length;
|
||||
@@ -407,15 +432,9 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
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 outerBoxPadding = 4; // border (2) + paddingX (2)
|
||||
|
||||
const horizontalPadding =
|
||||
outerBoxPadding +
|
||||
radioWidth +
|
||||
numberWidth +
|
||||
checkboxWidth +
|
||||
checkmarkWidth +
|
||||
cursorPadding;
|
||||
radioWidth + numberWidth + checkboxWidth + checkmarkWidth + cursorPadding;
|
||||
|
||||
const bufferWidth = availableWidth - horizontalPadding;
|
||||
|
||||
@@ -544,6 +563,9 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
(key: Key) => {
|
||||
// If focusing custom option, handle Ctrl+C
|
||||
if (isCustomOptionFocused && keyMatchers[Command.QUIT](key)) {
|
||||
if (customOptionText === '') {
|
||||
return false;
|
||||
}
|
||||
customBuffer.setText('');
|
||||
return true;
|
||||
}
|
||||
@@ -586,7 +608,12 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[isCustomOptionFocused, customBuffer, onEditingCustomOption],
|
||||
[
|
||||
isCustomOptionFocused,
|
||||
customBuffer,
|
||||
onEditingCustomOption,
|
||||
customOptionText,
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleExtraKeys, { isActive: true, priority: true });
|
||||
@@ -698,31 +725,50 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
}
|
||||
}, [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;
|
||||
const questionHeight =
|
||||
listHeight && !isAlternateBuffer
|
||||
? Math.min(15, Math.max(1, listHeight - 4))
|
||||
: undefined;
|
||||
const maxItemsToShow =
|
||||
listHeight && questionHeight
|
||||
? Math.max(1, Math.floor((listHeight - questionHeight) / 2))
|
||||
: selectionItems.length;
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
paddingX={1}
|
||||
borderColor={theme.border.default}
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
{progressHeader}
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{question.question}
|
||||
</Text>
|
||||
<Box marginBottom={TITLE_MARGIN}>
|
||||
<MaxSizedBox
|
||||
maxHeight={questionHeight}
|
||||
maxWidth={availableWidth}
|
||||
overflowDirection="bottom"
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{question.question}
|
||||
{question.multiSelect && (
|
||||
<Text color={theme.text.secondary} italic>
|
||||
{' '}
|
||||
(Select all that apply)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</MaxSizedBox>
|
||||
</Box>
|
||||
{question.multiSelect && (
|
||||
<Text color={theme.text.secondary} italic>
|
||||
{' '}
|
||||
(Select all that apply)
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<BaseSelectionList<OptionItem>
|
||||
items={selectionItems}
|
||||
onSelect={handleSelect}
|
||||
onHighlight={handleHighlight}
|
||||
focusKey={isCustomOptionFocused ? 'other' : undefined}
|
||||
maxItemsToShow={maxItemsToShow}
|
||||
showScrollArrows={true}
|
||||
renderItem={(item, context) => {
|
||||
const optionItem = item.value;
|
||||
const isChecked =
|
||||
@@ -734,7 +780,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
|
||||
// Render inline text input for custom option
|
||||
if (optionItem.type === 'other') {
|
||||
const placeholder = 'Enter a custom value';
|
||||
const placeholder = question.placeholder || 'Enter a custom value';
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
{showCheck && (
|
||||
@@ -804,14 +850,19 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onActiveTextInputChange,
|
||||
width,
|
||||
availableHeight: availableHeightProp,
|
||||
}) => {
|
||||
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 uiState = useContext(UIStateContext);
|
||||
const terminalWidth = uiState?.terminalWidth ?? 80;
|
||||
const availableWidth = terminalWidth;
|
||||
|
||||
const reviewTabIndex = questions.length;
|
||||
const tabCount =
|
||||
questions.length > 1 ? questions.length + 1 : questions.length;
|
||||
@@ -842,9 +893,12 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
onCancel();
|
||||
return true;
|
||||
} else if (keyMatchers[Command.QUIT](key) && !isEditingCustomOption) {
|
||||
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;
|
||||
},
|
||||
@@ -1021,7 +1075,8 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
onAnswer={handleAnswer}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onEditingCustomOption={handleEditingCustomOption}
|
||||
availableWidth={availableWidth}
|
||||
availableWidth={width}
|
||||
availableHeight={availableHeight}
|
||||
initialAnswer={answers[currentQuestionIndex]}
|
||||
progressHeader={progressHeader}
|
||||
keyboardHints={keyboardHints}
|
||||
@@ -1033,7 +1088,8 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
onAnswer={handleAnswer}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onEditingCustomOption={handleEditingCustomOption}
|
||||
availableWidth={availableWidth}
|
||||
availableWidth={width}
|
||||
availableHeight={availableHeight}
|
||||
initialAnswer={answers[currentQuestionIndex]}
|
||||
progressHeader={progressHeader}
|
||||
keyboardHints={keyboardHints}
|
||||
@@ -1043,7 +1099,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={availableWidth}
|
||||
width={width}
|
||||
aria-label={`Question ${currentQuestionIndex + 1} of ${questions.length}: ${currentQuestion.question}`}
|
||||
>
|
||||
{questionView}
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { BackgroundShellDisplay } from './BackgroundShellDisplay.js';
|
||||
import { type BackgroundShell } from '../hooks/shellCommandProcessor.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';
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Mock dependencies
|
||||
const mockDismissBackgroundShell = vi.fn();
|
||||
const mockSetActiveBackgroundShellPid = vi.fn();
|
||||
const mockSetIsBackgroundShellListOpen = vi.fn();
|
||||
const mockHandleWarning = vi.fn();
|
||||
const mockSetEmbeddedShellFocused = vi.fn();
|
||||
|
||||
vi.mock('../contexts/UIActionsContext.js', () => ({
|
||||
useUIActions: () => ({
|
||||
dismissBackgroundShell: mockDismissBackgroundShell,
|
||||
setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen,
|
||||
handleWarning: mockHandleWarning,
|
||||
setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
ShellExecutionService: {
|
||||
resizePty: vi.fn(),
|
||||
subscribe: vi.fn(() => vi.fn()),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// 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<Key>) => {
|
||||
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: BackgroundShell[];
|
||||
renderItem: (props: {
|
||||
item: BackgroundShell;
|
||||
index: number;
|
||||
}) => React.ReactNode;
|
||||
}) => (
|
||||
<Box flexDirection="column">
|
||||
{data.map((item: BackgroundShell, index: number) => (
|
||||
<Box key={index}>{renderItem({ item, index })}</Box>
|
||||
))}
|
||||
</Box>
|
||||
),
|
||||
),
|
||||
}));
|
||||
|
||||
const createMockKey = (overrides: Partial<Key>): Key => ({
|
||||
name: '',
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
cmd: false,
|
||||
shift: false,
|
||||
insertable: false,
|
||||
sequence: '',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('<BackgroundShellDisplay />', () => {
|
||||
const mockShells = new Map<number, BackgroundShell>();
|
||||
const shell1: BackgroundShell = {
|
||||
pid: 1001,
|
||||
command: 'npm start',
|
||||
output: 'Starting server...',
|
||||
isBinary: false,
|
||||
binaryBytesReceived: 0,
|
||||
status: 'running',
|
||||
};
|
||||
const shell2: BackgroundShell = {
|
||||
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 { lastFrame } = render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={false}
|
||||
isListOpenProp={false}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders tabs for multiple shells', async () => {
|
||||
const { lastFrame } = render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={100}
|
||||
height={24}
|
||||
isFocused={false}
|
||||
isListOpenProp={false}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('highlights the focused state', async () => {
|
||||
const { lastFrame } = render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={true} // Focused
|
||||
isListOpenProp={false}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('resizes the PTY on mount and when dimensions change', async () => {
|
||||
const { rerender } = render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={false}
|
||||
isListOpenProp={false}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
|
||||
shell1.pid,
|
||||
76,
|
||||
21,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={100}
|
||||
height={30}
|
||||
isFocused={false}
|
||||
isListOpenProp={false}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
|
||||
shell1.pid,
|
||||
96,
|
||||
27,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the process list when isListOpenProp is true', async () => {
|
||||
const { lastFrame } = render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={true}
|
||||
isListOpenProp={true}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('selects the current process and closes the list when Ctrl+L is pressed in list view', async () => {
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={true}
|
||||
isListOpenProp={true}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
// Simulate down arrow to select the second process (handled by RadioButtonSelect)
|
||||
act(() => {
|
||||
simulateKey({ name: 'down' });
|
||||
});
|
||||
|
||||
// Simulate Ctrl+L (handled by BackgroundShellDisplay)
|
||||
act(() => {
|
||||
simulateKey({ name: 'l', ctrl: true });
|
||||
});
|
||||
|
||||
expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid);
|
||||
expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('kills the highlighted process when Ctrl+K is pressed in list view', async () => {
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={true}
|
||||
isListOpenProp={true}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
// Initial state: shell1 (active) is highlighted
|
||||
|
||||
// Move to shell2
|
||||
act(() => {
|
||||
simulateKey({ name: 'down' });
|
||||
});
|
||||
|
||||
// Press Ctrl+K
|
||||
act(() => {
|
||||
simulateKey({ name: 'k', ctrl: true });
|
||||
});
|
||||
|
||||
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid);
|
||||
});
|
||||
|
||||
it('kills the active process when Ctrl+K is pressed in output view', async () => {
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={true}
|
||||
isListOpenProp={false}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
simulateKey({ name: 'k', ctrl: true });
|
||||
});
|
||||
|
||||
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid);
|
||||
});
|
||||
|
||||
it('scrolls to active shell when list opens', async () => {
|
||||
// shell2 is active
|
||||
const { lastFrame } = render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell2.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={true}
|
||||
isListOpenProp={true}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('keeps exit code status color even when selected', async () => {
|
||||
const exitedShell: BackgroundShell = {
|
||||
pid: 1003,
|
||||
command: 'exit 0',
|
||||
output: '',
|
||||
isBinary: false,
|
||||
binaryBytesReceived: 0,
|
||||
status: 'exited',
|
||||
exitCode: 0,
|
||||
};
|
||||
mockShells.set(exitedShell.pid, exitedShell);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={exitedShell.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={true}
|
||||
isListOpenProp={true}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('unfocuses the shell when Shift+Tab is pressed', async () => {
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={true}
|
||||
isListOpenProp={false}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
simulateKey({ name: 'tab', shift: true });
|
||||
});
|
||||
|
||||
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('shows a warning when Tab is pressed', async () => {
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={true}
|
||||
isListOpenProp={false}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
simulateKey({ name: 'tab' });
|
||||
});
|
||||
|
||||
expect(mockHandleWarning).toHaveBeenCalledWith(
|
||||
'Press Shift+Tab to focus out.',
|
||||
);
|
||||
expect(mockSetEmbeddedShellFocused).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* @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,
|
||||
type AnsiOutput,
|
||||
type AnsiLine,
|
||||
type AnsiToken,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
|
||||
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
||||
import { Command, keyMatchers } from '../keyMatchers.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { commandDescriptions } from '../../config/keyBindings.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';
|
||||
|
||||
interface BackgroundShellDisplayProps {
|
||||
shells: Map<number, BackgroundShell>;
|
||||
activePid: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isFocused: boolean;
|
||||
isListOpenProp: boolean;
|
||||
}
|
||||
|
||||
const CONTENT_PADDING_X = 1;
|
||||
const BORDER_WIDTH = 2; // Left and Right border
|
||||
const HEADER_HEIGHT = 3; // 2 for border, 1 for header
|
||||
const TAB_DISPLAY_HORIZONTAL_PADDING = 4;
|
||||
|
||||
const formatShellCommandForDisplay = (command: string, maxWidth: number) => {
|
||||
const commandFirstLine = command.split('\n')[0];
|
||||
return cpLen(commandFirstLine) > maxWidth
|
||||
? `${cpSlice(commandFirstLine, 0, maxWidth - 3)}...`
|
||||
: commandFirstLine;
|
||||
};
|
||||
|
||||
export const BackgroundShellDisplay = ({
|
||||
shells,
|
||||
activePid,
|
||||
width,
|
||||
height,
|
||||
isFocused,
|
||||
isListOpenProp,
|
||||
}: BackgroundShellDisplayProps) => {
|
||||
const {
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
} = useUIActions();
|
||||
const activeShell = shells.get(activePid);
|
||||
const [output, setOutput] = useState<string | AnsiOutput>(
|
||||
activeShell?.output || '',
|
||||
);
|
||||
const [highlightedPid, setHighlightedPid] = useState<number | null>(
|
||||
activePid,
|
||||
);
|
||||
const outputRef = useRef<ScrollableListRef<AnsiLine | string>>(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 - HEADER_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;
|
||||
|
||||
// Handle Shift+Tab or Tab (in list) to focus out
|
||||
if (
|
||||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL](key) ||
|
||||
(isListOpenProp &&
|
||||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key))
|
||||
) {
|
||||
setEmbeddedShellFocused(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle Tab to warn but propagate
|
||||
if (
|
||||
!isListOpenProp &&
|
||||
keyMatchers[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING](key)
|
||||
) {
|
||||
handleWarning(
|
||||
`Press ${commandDescriptions[Command.UNFOCUS_BACKGROUND_SHELL]} to focus out.`,
|
||||
);
|
||||
// Fall through to allow Tab to be sent to the shell
|
||||
}
|
||||
|
||||
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)) {
|
||||
setIsBackgroundShellListOpen(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
|
||||
if (highlightedPid) {
|
||||
dismissBackgroundShell(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) {
|
||||
setActiveBackgroundShellPid(highlightedPid);
|
||||
}
|
||||
setIsBackgroundShellListOpen(false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
|
||||
dismissBackgroundShell(activeShell.pid);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
|
||||
setIsBackgroundShellListOpen(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 helpText = `${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL]} Hide | ${commandDescriptions[Command.KILL_BACKGROUND_SHELL]} Kill | ${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]} List`;
|
||||
|
||||
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(helpText) -
|
||||
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(
|
||||
<Text
|
||||
key={shell.pid}
|
||||
color={isActive ? theme.text.primary : theme.text.secondary}
|
||||
bold={isActive}
|
||||
>
|
||||
{label}
|
||||
</Text>,
|
||||
);
|
||||
currentWidth += labelWidth;
|
||||
}
|
||||
|
||||
if (shellList.length > tabs.length && !isListOpenProp) {
|
||||
const overflowLabel = ` ... (${commandDescriptions[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(
|
||||
<Text key="overflow" color={theme.status.warning} bold>
|
||||
{overflowLabel}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return tabs;
|
||||
};
|
||||
|
||||
const renderProcessList = () => {
|
||||
const maxCommandLength = Math.max(
|
||||
0,
|
||||
width - BORDER_WIDTH - CONTENT_PADDING_X * 2 - 10,
|
||||
);
|
||||
|
||||
const items: Array<RadioSelectItem<number>> = 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 (
|
||||
<Box flexDirection="column" height="100%" width="100%">
|
||||
<Box flexShrink={0} marginBottom={1} paddingTop={1}>
|
||||
<Text bold>
|
||||
{`Select Process (${commandDescriptions[Command.BACKGROUND_SHELL_SELECT]} to select, ${commandDescriptions[Command.BACKGROUND_SHELL_ESCAPE]} to cancel):`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width="100%">
|
||||
<RadioButtonSelect
|
||||
items={items}
|
||||
initialIndex={initialIndex >= 0 ? initialIndex : 0}
|
||||
onSelect={(pid) => {
|
||||
setActiveBackgroundShellPid(pid);
|
||||
setIsBackgroundShellListOpen(false);
|
||||
}}
|
||||
onHighlight={(pid) => setHighlightedPid(pid)}
|
||||
isFocused={isFocused}
|
||||
maxItemsToShow={Math.max(1, height - HEADER_HEIGHT - 3)} // Adjust for header
|
||||
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 <Text>{item.label}</Text>;
|
||||
|
||||
const truncatedCommand = formatShellCommandForDisplay(
|
||||
shell.command,
|
||||
maxCommandLength,
|
||||
);
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{truncatedCommand} (PID: {shell.pid})
|
||||
{shell.status === 'exited' ? (
|
||||
<Text
|
||||
color={
|
||||
shell.exitCode === 0
|
||||
? theme.status.success
|
||||
: theme.status.error
|
||||
}
|
||||
>
|
||||
{' '}
|
||||
(Exit Code: {shell.exitCode})
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const renderOutput = () => {
|
||||
const lines = typeof output === 'string' ? output.split('\n') : output;
|
||||
|
||||
return (
|
||||
<ScrollableList
|
||||
ref={outputRef}
|
||||
data={lines}
|
||||
renderItem={({ item: line, index }) => {
|
||||
if (typeof line === 'string') {
|
||||
return <Text key={index}>{line}</Text>;
|
||||
}
|
||||
return (
|
||||
<Text key={index} wrap="truncate">
|
||||
{line.length > 0
|
||||
? line.map((token: AnsiToken, tokenIndex: number) => (
|
||||
<Text
|
||||
key={tokenIndex}
|
||||
color={token.fg}
|
||||
backgroundColor={token.bg}
|
||||
inverse={token.inverse}
|
||||
dimColor={token.dim}
|
||||
bold={token.bold}
|
||||
italic={token.italic}
|
||||
underline={token.underline}
|
||||
>
|
||||
{token.text}
|
||||
</Text>
|
||||
))
|
||||
: null}
|
||||
</Text>
|
||||
);
|
||||
}}
|
||||
estimatedItemHeight={() => 1}
|
||||
keyExtractor={(_, index) => index.toString()}
|
||||
hasFocus={isFocused}
|
||||
initialScrollIndex={SCROLL_TO_ITEM_END}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
height="100%"
|
||||
width="100%"
|
||||
borderStyle="single"
|
||||
borderColor={isFocused ? theme.border.focused : undefined}
|
||||
>
|
||||
<Box
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
borderStyle="single"
|
||||
borderBottom={false}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderTop={false}
|
||||
paddingX={1}
|
||||
borderColor={isFocused ? theme.border.focused : undefined}
|
||||
>
|
||||
<Box flexDirection="row">
|
||||
{renderTabs()}
|
||||
<Text bold>
|
||||
{' '}
|
||||
(PID: {activeShell?.pid}) {isFocused ? '(Focused)' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.accent}>{helpText}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} overflow="hidden" paddingX={CONTENT_PADDING_X}>
|
||||
{isListOpenProp ? renderProcessList() : renderOutput()}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -34,6 +34,8 @@ describe('Key Bubbling Regression', () => {
|
||||
questions={choiceQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { checkExhaustive } from '../../utils/checks.js';
|
||||
import { checkExhaustive } from '@google/gemini-cli-core';
|
||||
|
||||
export type ChecklistStatus =
|
||||
| 'pending'
|
||||
|
||||
@@ -133,6 +133,8 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
nightly: false,
|
||||
isTrustedFolder: true,
|
||||
activeHooks: [],
|
||||
isBackgroundShellVisible: false,
|
||||
embeddedShellFocused: false,
|
||||
...overrides,
|
||||
}) as UIState;
|
||||
|
||||
@@ -310,6 +312,32 @@ describe('Composer', () => {
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
expect(output).not.toContain('Should not show during confirmation');
|
||||
});
|
||||
|
||||
it('renders LoadingIndicator when embedded shell is focused but background shell is visible', () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
embeddedShellFocused: true,
|
||||
isBackgroundShellVisible: true,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
});
|
||||
|
||||
it('does NOT render LoadingIndicator when embedded shell is focused and background shell is NOT visible', () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
embeddedShellFocused: true,
|
||||
isBackgroundShellVisible: false,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('LoadingIndicator');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Queue Display', () => {
|
||||
|
||||
@@ -54,7 +54,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
flexGrow={0}
|
||||
flexShrink={0}
|
||||
>
|
||||
{!uiState.embeddedShellFocused && (
|
||||
{(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && (
|
||||
<LoadingIndicator
|
||||
thought={
|
||||
uiState.streamingState === StreamingState.WaitingForConfirmation ||
|
||||
|
||||
@@ -18,6 +18,7 @@ interface ContextSummaryDisplayProps {
|
||||
blockedMcpServers?: Array<{ name: string; extensionName: string }>;
|
||||
ideContext?: IdeContext;
|
||||
skillCount: number;
|
||||
backgroundProcessCount?: number;
|
||||
}
|
||||
|
||||
export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
@@ -27,6 +28,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
blockedMcpServers,
|
||||
ideContext,
|
||||
skillCount,
|
||||
backgroundProcessCount = 0,
|
||||
}) => {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
@@ -39,7 +41,8 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
mcpServerCount === 0 &&
|
||||
blockedMcpServerCount === 0 &&
|
||||
openFileCount === 0 &&
|
||||
skillCount === 0
|
||||
skillCount === 0 &&
|
||||
backgroundProcessCount === 0
|
||||
) {
|
||||
return <Text> </Text>; // Render an empty space to reserve height
|
||||
}
|
||||
@@ -93,9 +96,22 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
return `${skillCount} skill${skillCount > 1 ? 's' : ''}`;
|
||||
})();
|
||||
|
||||
const summaryParts = [openFilesText, geminiMdText, mcpText, skillText].filter(
|
||||
Boolean,
|
||||
);
|
||||
const backgroundText = (() => {
|
||||
if (backgroundProcessCount === 0) {
|
||||
return '';
|
||||
}
|
||||
return `${backgroundProcessCount} Background process${
|
||||
backgroundProcessCount > 1 ? 'es' : ''
|
||||
}`;
|
||||
})();
|
||||
|
||||
const summaryParts = [
|
||||
openFilesText,
|
||||
geminiMdText,
|
||||
mcpText,
|
||||
skillText,
|
||||
backgroundText,
|
||||
].filter(Boolean);
|
||||
|
||||
if (isNarrow) {
|
||||
return (
|
||||
|
||||
@@ -80,6 +80,7 @@ describe('DialogManager', () => {
|
||||
isFolderTrustDialogOpen: false,
|
||||
loopDetectionConfirmationRequest: null,
|
||||
confirmationRequest: null,
|
||||
consentRequest: null,
|
||||
isThemeDialogOpen: false,
|
||||
isSettingsDialogOpen: false,
|
||||
isModelDialogOpen: false,
|
||||
@@ -137,7 +138,11 @@ describe('DialogManager', () => {
|
||||
'LoopDetectionConfirmation',
|
||||
],
|
||||
[
|
||||
{ confirmationRequest: { prompt: 'foo', onConfirm: vi.fn() } },
|
||||
{ commandConfirmationRequest: { prompt: 'foo', onConfirm: vi.fn() } },
|
||||
'ConsentPrompt',
|
||||
],
|
||||
[
|
||||
{ authConsentRequest: { prompt: 'bar', onConfirm: vi.fn() } },
|
||||
'ConsentPrompt',
|
||||
],
|
||||
[
|
||||
|
||||
@@ -32,8 +32,6 @@ import process from 'node:process';
|
||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||
import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js';
|
||||
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
|
||||
import { AskUserDialog } from './AskUserDialog.js';
|
||||
import { useAskUserActions } from '../contexts/AskUserActionsContext.js';
|
||||
import { NewAgentsNotification } from './NewAgentsNotification.js';
|
||||
import { AgentConfigDialog } from './AgentConfigDialog.js';
|
||||
|
||||
@@ -59,22 +57,6 @@ export const DialogManager = ({
|
||||
terminalWidth: uiTerminalWidth,
|
||||
} = uiState;
|
||||
|
||||
const {
|
||||
request: askUserRequest,
|
||||
submit: askUserSubmit,
|
||||
cancel: askUserCancel,
|
||||
} = useAskUserActions();
|
||||
|
||||
if (askUserRequest) {
|
||||
return (
|
||||
<AskUserDialog
|
||||
questions={askUserRequest.questions}
|
||||
onSubmit={askUserSubmit}
|
||||
onCancel={askUserCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.adminSettingsChanged) {
|
||||
return <AdminSettingsChangedDialog />;
|
||||
}
|
||||
@@ -134,11 +116,24 @@ export const DialogManager = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.confirmationRequest) {
|
||||
|
||||
// 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 (
|
||||
<ConsentPrompt
|
||||
prompt={uiState.confirmationRequest.prompt}
|
||||
onConfirm={uiState.confirmationRequest.onConfirm}
|
||||
prompt={uiState.commandConfirmationRequest.prompt}
|
||||
onConfirm={uiState.commandConfirmationRequest.onConfirm}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.authConsentRequest) {
|
||||
return (
|
||||
<ConsentPrompt
|
||||
prompt={uiState.authConsentRequest.prompt}
|
||||
onConfirm={uiState.authConsentRequest.onConfirm}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
/**
|
||||
* @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 { waitFor } from '../../test-utils/async.js';
|
||||
import { ExitPlanModeDialog } from './ExitPlanModeDialog.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
import {
|
||||
ApprovalMode,
|
||||
validatePlanContent,
|
||||
processSingleFileContent,
|
||||
type FileSystemService,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
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<typeof fs>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn(),
|
||||
realpathSync: vi.fn((p) => p),
|
||||
promises: {
|
||||
...actual.promises,
|
||||
readFile: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
|
||||
act(() => {
|
||||
stdin.write(key);
|
||||
});
|
||||
};
|
||||
|
||||
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<typeof vi.fn>;
|
||||
let onFeedback: ReturnType<typeof vi.fn>;
|
||||
let onCancel: ReturnType<typeof vi.fn>;
|
||||
|
||||
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 = (options?: { useAlternateBuffer?: boolean }) =>
|
||||
renderWithProviders(
|
||||
<ExitPlanModeDialog
|
||||
planPath={mockPlanFullPath}
|
||||
onApprove={onApprove}
|
||||
onFeedback={onFeedback}
|
||||
onCancel={onCancel}
|
||||
width={80}
|
||||
availableHeight={24}
|
||||
/>,
|
||||
{
|
||||
...options,
|
||||
config: {
|
||||
getTargetDir: () => mockTargetDir,
|
||||
getIdeMode: () => false,
|
||||
isTrustedFolder: () => true,
|
||||
storage: {
|
||||
getProjectTempPlansDir: () => mockPlansDir,
|
||||
},
|
||||
getFileSystemService: (): FileSystemService => ({
|
||||
readTextFile: vi.fn(),
|
||||
writeTextFile: vi.fn(),
|
||||
}),
|
||||
} as unknown as import('@google/gemini-cli-core').Config,
|
||||
},
|
||||
);
|
||||
|
||||
describe.each([{ useAlternateBuffer: true }, { useAlternateBuffer: false }])(
|
||||
'useAlternateBuffer: $useAlternateBuffer',
|
||||
({ useAlternateBuffer }) => {
|
||||
it('renders correctly with plan content', async () => {
|
||||
const { lastFrame } = 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 } = 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 } = 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 } = 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
|
||||
writeKey(stdin, '\r'); // Select to focus input
|
||||
|
||||
// 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 } = 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 } = 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 } = 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 } = 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 } = 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 } = 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;
|
||||
}) => {
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (keyMatchers[Command.QUIT](key)) {
|
||||
onBubbledQuit();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<BubbleListener>
|
||||
<ExitPlanModeDialog
|
||||
planPath={mockPlanFullPath}
|
||||
onApprove={onApprove}
|
||||
onFeedback={onFeedback}
|
||||
onCancel={onCancel}
|
||||
width={80}
|
||||
availableHeight={24}
|
||||
/>
|
||||
</BubbleListener>,
|
||||
{
|
||||
useAlternateBuffer,
|
||||
config: {
|
||||
getTargetDir: () => mockTargetDir,
|
||||
getIdeMode: () => false,
|
||||
isTrustedFolder: () => true,
|
||||
storage: {
|
||||
getProjectTempPlansDir: () => mockPlansDir,
|
||||
},
|
||||
getFileSystemService: (): FileSystemService => ({
|
||||
readTextFile: vi.fn(),
|
||||
writeTextFile: vi.fn(),
|
||||
}),
|
||||
} as unknown as import('@google/gemini-cli-core').Config,
|
||||
},
|
||||
);
|
||||
|
||||
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 } = 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 } = 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') {
|
||||
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();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
ApprovalMode,
|
||||
validatePlanPath,
|
||||
validatePlanContent,
|
||||
QuestionType,
|
||||
type Config,
|
||||
processSingleFileContent,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { AskUserDialog } from './AskUserDialog.js';
|
||||
|
||||
export interface ExitPlanModeDialogProps {
|
||||
planPath: string;
|
||||
onApprove: (approvalMode: ApprovalMode) => void;
|
||||
onFeedback: (feedback: string) => void;
|
||||
onCancel: () => void;
|
||||
width: number;
|
||||
availableHeight?: number;
|
||||
}
|
||||
|
||||
enum PlanStatus {
|
||||
Loading = 'loading',
|
||||
Loaded = 'loaded',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
interface PlanContentState {
|
||||
status: PlanStatus;
|
||||
content?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
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 }) => <Box paddingX={1}>{children}</Box>;
|
||||
|
||||
function usePlanContent(planPath: string, config: Config): PlanContentState {
|
||||
const [state, setState] = useState<PlanContentState>({
|
||||
status: PlanStatus.Loading,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
setState({ status: PlanStatus.Loading });
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const pathError = await validatePlanPath(
|
||||
planPath,
|
||||
config.storage.getProjectTempPlansDir(),
|
||||
config.getTargetDir(),
|
||||
);
|
||||
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.getProjectTempPlansDir(),
|
||||
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]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
|
||||
planPath,
|
||||
onApprove,
|
||||
onFeedback,
|
||||
onCancel,
|
||||
width,
|
||||
availableHeight,
|
||||
}) => {
|
||||
const config = useConfig();
|
||||
const planState = usePlanContent(planPath, config);
|
||||
const [showLoading, setShowLoading] = useState(false);
|
||||
|
||||
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 (
|
||||
<StatusMessage>
|
||||
<Text color={theme.text.secondary} italic>
|
||||
Loading plan...
|
||||
</Text>
|
||||
</StatusMessage>
|
||||
);
|
||||
}
|
||||
|
||||
if (planState.status === PlanStatus.Error) {
|
||||
return (
|
||||
<StatusMessage>
|
||||
<Text color={theme.status.error}>
|
||||
Error reading plan: {planState.error}
|
||||
</Text>
|
||||
</StatusMessage>
|
||||
);
|
||||
}
|
||||
|
||||
const planContent = planState.content?.trim();
|
||||
if (!planContent) {
|
||||
return (
|
||||
<StatusMessage>
|
||||
<Text color={theme.status.error}>Error: Plan content is empty.</Text>
|
||||
</StatusMessage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<AskUserDialog
|
||||
questions={[
|
||||
{
|
||||
type: QuestionType.CHOICE,
|
||||
header: 'Approval',
|
||||
question: planContent,
|
||||
options: [
|
||||
{
|
||||
label: ApprovalOption.Auto,
|
||||
description:
|
||||
'Approves plan and allows tools to run automatically',
|
||||
},
|
||||
{
|
||||
label: ApprovalOption.Manual,
|
||||
description:
|
||||
'Approves plan but requires confirmation for each tool',
|
||||
},
|
||||
],
|
||||
placeholder: 'Type your feedback...',
|
||||
multiSelect: false,
|
||||
},
|
||||
]}
|
||||
onSubmit={(answers) => {
|
||||
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}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -45,6 +45,8 @@ import { StreamingState } from '../types.js';
|
||||
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
||||
import type { UIState } from '../contexts/UIStateContext.js';
|
||||
import { isLowColorDepth } from '../utils/terminalUtils.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
import type { Key } from '../hooks/useKeypress.js';
|
||||
|
||||
vi.mock('../hooks/useShellHistory.js');
|
||||
vi.mock('../hooks/useCommandCompletion.js');
|
||||
@@ -169,7 +171,16 @@ describe('InputPrompt', () => {
|
||||
allVisualLines: [''],
|
||||
visualCursor: [0, 0],
|
||||
visualScrollRow: 0,
|
||||
handleInput: vi.fn(),
|
||||
handleInput: vi.fn((key: Key) => {
|
||||
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||
if (mockBuffer.text.length > 0) {
|
||||
mockBuffer.setText('');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
move: vi.fn(),
|
||||
moveToOffset: vi.fn((offset: number) => {
|
||||
mockBuffer.cursor = [0, offset];
|
||||
@@ -499,6 +510,23 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should clear the buffer and reset completion on Ctrl+C', async () => {
|
||||
mockBuffer.text = 'some text';
|
||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
|
||||
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);
|
||||
@@ -2747,6 +2775,23 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('ensures Ctrl+R search results are prioritized newest-to-oldest by reversing userMessages', async () => {
|
||||
props.shellModeActive = false;
|
||||
props.userMessages = ['oldest', 'middle', 'newest'];
|
||||
|
||||
renderWithProviders(<InputPrompt {...props} />);
|
||||
|
||||
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 focus toggle', () => {
|
||||
|
||||
@@ -152,8 +152,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const kittyProtocol = useKittyKeyboardProtocol();
|
||||
const isShellFocused = useShellFocusState();
|
||||
const { setEmbeddedShellFocused } = useUIActions();
|
||||
const { terminalWidth, activePtyId, history, terminalBackgroundColor } =
|
||||
useUIState();
|
||||
const {
|
||||
terminalWidth,
|
||||
activePtyId,
|
||||
history,
|
||||
terminalBackgroundColor,
|
||||
backgroundShells,
|
||||
backgroundShellHeight,
|
||||
} = useUIState();
|
||||
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
||||
const escPressCount = useRef(0);
|
||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||
@@ -191,9 +197,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
reverseSearchActive,
|
||||
);
|
||||
|
||||
const reversedUserMessages = useMemo(
|
||||
() => [...userMessages].reverse(),
|
||||
[userMessages],
|
||||
);
|
||||
|
||||
const commandSearchCompletion = useReverseSearchCompletion(
|
||||
buffer,
|
||||
userMessages,
|
||||
reversedUserMessages,
|
||||
commandSearchActive,
|
||||
);
|
||||
|
||||
@@ -476,6 +487,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
key.name === 'escape' &&
|
||||
(streamingState === StreamingState.Responding ||
|
||||
streamingState === StreamingState.WaitingForConfirmation)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (key.name === 'paste') {
|
||||
// Record paste time to prevent accidental auto-submission
|
||||
if (!isTerminalPasteTrusted(kittyProtocol.enabled)) {
|
||||
@@ -598,6 +617,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
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);
|
||||
@@ -605,12 +630,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
|
||||
setBannerVisible(false);
|
||||
onClearScreen();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (reverseSearchActive || commandSearchActive) {
|
||||
const isCommandSearch = commandSearchActive;
|
||||
|
||||
@@ -875,14 +894,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
buffer.move('end');
|
||||
return true;
|
||||
}
|
||||
// Ctrl+C (Clear input)
|
||||
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||
if (buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
resetCompletionState();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Kill line commands
|
||||
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
|
||||
@@ -915,7 +926,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
|
||||
// If we got here, Autocomplete didn't handle the key (e.g. no suggestions).
|
||||
if (activePtyId) {
|
||||
if (
|
||||
activePtyId ||
|
||||
(backgroundShells.size > 0 && backgroundShellHeight > 0)
|
||||
) {
|
||||
setEmbeddedShellFocused(true);
|
||||
}
|
||||
return true;
|
||||
@@ -924,17 +938,23 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
// Fall back to the text buffer's default input handling for all other keys
|
||||
const handled = buffer.handleInput(key);
|
||||
|
||||
// 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);
|
||||
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;
|
||||
},
|
||||
@@ -967,11 +987,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
onSubmit,
|
||||
activePtyId,
|
||||
setEmbeddedShellFocused,
|
||||
backgroundShells.size,
|
||||
backgroundShellHeight,
|
||||
history,
|
||||
streamingState,
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleInput, { isActive: !isEmbeddedShellFocused });
|
||||
useKeypress(handleInput, {
|
||||
isActive: !isEmbeddedShellFocused,
|
||||
priority: true,
|
||||
});
|
||||
|
||||
const linesToRender = buffer.viewportVisualLines;
|
||||
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
|
||||
|
||||
@@ -254,6 +254,7 @@ describe('RewindViewer', () => {
|
||||
{
|
||||
description: 'removes reference markers',
|
||||
prompt: `some command @file\n--- Content from referenced files ---\nContent from file:\nblah blah\n--- End of content ---`,
|
||||
expected: 'some command @file',
|
||||
},
|
||||
{
|
||||
description: 'strips expanded MCP resource content',
|
||||
@@ -263,10 +264,23 @@ describe('RewindViewer', () => {
|
||||
'\nContent from @server3:mcp://demo-resource:\n' +
|
||||
'This is the content of the demo resource.\n' +
|
||||
`--- End of content ---`,
|
||||
expected: 'read @server3:mcp://demo-resource hello',
|
||||
},
|
||||
])('$description', async ({ prompt }) => {
|
||||
{
|
||||
description: 'uses displayContent if present and does not strip',
|
||||
prompt: `raw content with markers\n--- Content from referenced files ---\nblah\n--- End of content ---`,
|
||||
displayContent: 'clean display content',
|
||||
expected: 'clean display content',
|
||||
},
|
||||
])('$description', async ({ prompt, displayContent, expected }) => {
|
||||
const conversation = createConversation([
|
||||
{ type: 'user', content: prompt, id: '1', timestamp: '1' },
|
||||
{
|
||||
type: 'user',
|
||||
content: prompt,
|
||||
displayContent,
|
||||
id: '1',
|
||||
timestamp: '1',
|
||||
},
|
||||
]);
|
||||
const onRewind = vi.fn();
|
||||
const { lastFrame, stdin } = renderWithProviders(
|
||||
@@ -289,6 +303,15 @@ describe('RewindViewer', () => {
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Confirm Rewind');
|
||||
});
|
||||
|
||||
// Confirm
|
||||
act(() => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRewind).toHaveBeenCalledWith('1', expected, expect.anything());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -35,6 +35,14 @@ interface RewindViewerProps {
|
||||
|
||||
const MAX_LINES_PER_BOX = 2;
|
||||
|
||||
const getCleanedRewindText = (userPrompt: MessageRecord): string => {
|
||||
const contentToUse = userPrompt.displayContent || userPrompt.content;
|
||||
const originalUserText = contentToUse ? partToString(contentToUse) : '';
|
||||
return userPrompt.displayContent
|
||||
? originalUserText
|
||||
: stripReferenceContent(originalUserText);
|
||||
};
|
||||
|
||||
export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
conversation,
|
||||
onExit,
|
||||
@@ -162,10 +170,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
(m) => m.id === selectedMessageId,
|
||||
);
|
||||
if (userPrompt) {
|
||||
const originalUserText = userPrompt.content
|
||||
? partToString(userPrompt.content)
|
||||
: '';
|
||||
const cleanedText = stripReferenceContent(originalUserText);
|
||||
const cleanedText = getCleanedRewindText(userPrompt);
|
||||
setIsRewinding(true);
|
||||
await onRewind(selectedMessageId, cleanedText, outcome);
|
||||
}
|
||||
@@ -224,7 +229,9 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
isSelected ? theme.status.success : theme.text.primary
|
||||
}
|
||||
>
|
||||
{partToString(userPrompt.content)}
|
||||
{partToString(
|
||||
userPrompt.displayContent || userPrompt.content,
|
||||
)}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Cancel rewind and stay here
|
||||
@@ -235,10 +242,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
|
||||
const stats = getStats(userPrompt);
|
||||
const firstFileName = stats?.details?.at(0)?.fileName;
|
||||
const originalUserText = userPrompt.content
|
||||
? partToString(userPrompt.content)
|
||||
: '';
|
||||
const cleanedText = stripReferenceContent(originalUserText);
|
||||
const cleanedText = getCleanedRewindText(userPrompt);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
|
||||
@@ -1391,7 +1391,6 @@ describe('SettingsDialog', () => {
|
||||
},
|
||||
tools: {
|
||||
enableInteractiveShell: true,
|
||||
autoAccept: true,
|
||||
useRipgrep: true,
|
||||
},
|
||||
security: {
|
||||
@@ -1484,7 +1483,6 @@ describe('SettingsDialog', () => {
|
||||
userSettings: {
|
||||
tools: {
|
||||
enableInteractiveShell: true,
|
||||
autoAccept: false,
|
||||
useRipgrep: true,
|
||||
truncateToolOutputThreshold: 25000,
|
||||
truncateToolOutputLines: 500,
|
||||
@@ -1537,7 +1535,6 @@ describe('SettingsDialog', () => {
|
||||
},
|
||||
tools: {
|
||||
enableInteractiveShell: false,
|
||||
autoAccept: false,
|
||||
useRipgrep: false,
|
||||
},
|
||||
security: {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type React from 'react';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { ShellExecutionService } from '@google/gemini-cli-core';
|
||||
import { keyToAnsi, type Key } from '../hooks/keyToAnsi.js';
|
||||
import { Command, keyMatchers } from '../keyMatchers.js';
|
||||
|
||||
export interface ShellInputPromptProps {
|
||||
activeShellPtyId: number | null;
|
||||
@@ -31,22 +32,31 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
|
||||
const handleInput = useCallback(
|
||||
(key: Key) => {
|
||||
if (!focus || !activeShellPtyId) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow background shell toggle to bubble up
|
||||
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (key.ctrl && key.shift && key.name === 'up') {
|
||||
ShellExecutionService.scrollPty(activeShellPtyId, -1);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key.ctrl && key.shift && key.name === 'down') {
|
||||
ShellExecutionService.scrollPty(activeShellPtyId, 1);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const ansiSequence = keyToAnsi(key);
|
||||
if (ansiSequence) {
|
||||
handleShellInputSubmit(ansiSequence);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[focus, handleShellInputSubmit, activeShellPtyId],
|
||||
);
|
||||
|
||||
@@ -15,8 +15,14 @@ import type { TextBuffer } from './shared/text-buffer.js';
|
||||
|
||||
// Mock child components to simplify testing
|
||||
vi.mock('./ContextSummaryDisplay.js', () => ({
|
||||
ContextSummaryDisplay: (props: { skillCount: number }) => (
|
||||
<Text>Mock Context Summary Display (Skills: {props.skillCount})</Text>
|
||||
ContextSummaryDisplay: (props: {
|
||||
skillCount: number;
|
||||
backgroundProcessCount: number;
|
||||
}) => (
|
||||
<Text>
|
||||
Mock Context Summary Display (Skills: {props.skillCount}, Shells:{' '}
|
||||
{props.backgroundProcessCount})
|
||||
</Text>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -41,6 +47,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
|
||||
ideContextState: null,
|
||||
geminiMdFileCount: 0,
|
||||
contextFileNames: [],
|
||||
backgroundShellCount: 0,
|
||||
buffer: { text: '' },
|
||||
history: [{ id: 1, type: 'user', text: 'test' }],
|
||||
...overrides,
|
||||
@@ -227,4 +234,15 @@ describe('StatusDisplay', () => {
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
|
||||
it('passes backgroundShellCount to ContextSummaryDisplay', () => {
|
||||
const uiState = createMockUIState({
|
||||
backgroundShellCount: 3,
|
||||
});
|
||||
const { lastFrame } = renderStatusDisplay(
|
||||
{ hideContextSummary: false },
|
||||
uiState,
|
||||
);
|
||||
expect(lastFrame()).toContain('Shells: 3');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,6 +81,7 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
|
||||
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
|
||||
}
|
||||
skillCount={config.getSkillManager().getDisplayableSkills().length}
|
||||
backgroundProcessCount={uiState.backgroundShellCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,22 @@ import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||
import { ShowMoreLines } from './ShowMoreLines.js';
|
||||
import { StickyHeader } from './StickyHeader.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import type { SerializableConfirmationDetails } from '@google/gemini-cli-core';
|
||||
|
||||
function getConfirmationHeader(
|
||||
details: SerializableConfirmationDetails | undefined,
|
||||
): string {
|
||||
const headers: Partial<
|
||||
Record<SerializableConfirmationDetails['type'], string>
|
||||
> = {
|
||||
ask_user: 'Answer Questions',
|
||||
exit_plan_mode: 'Ready to start implementation?',
|
||||
};
|
||||
if (!details?.type) {
|
||||
return 'Action Required';
|
||||
}
|
||||
return headers[details.type] ?? 'Action Required';
|
||||
}
|
||||
|
||||
interface ToolConfirmationQueueProps {
|
||||
confirmingTool: ConfirmingToolState;
|
||||
@@ -55,6 +71,9 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
: undefined;
|
||||
|
||||
const borderColor = theme.status.warning;
|
||||
const hideToolIdentity =
|
||||
tool.confirmationDetails?.type === 'ask_user' ||
|
||||
tool.confirmationDetails?.type === 'exit_plan_mode';
|
||||
|
||||
return (
|
||||
<OverflowProvider>
|
||||
@@ -67,25 +86,31 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
>
|
||||
<Box flexDirection="column" width={mainAreaWidth - 4}>
|
||||
{/* Header */}
|
||||
<Box marginBottom={1} justifyContent="space-between">
|
||||
<Box
|
||||
marginBottom={hideToolIdentity ? 0 : 1}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Text color={theme.status.warning} bold>
|
||||
Action Required
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{index} of {total}
|
||||
{getConfirmationHeader(tool.confirmationDetails)}
|
||||
</Text>
|
||||
{total > 1 && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{index} of {total}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Tool Identity (Context) */}
|
||||
<Box>
|
||||
<ToolStatusIndicator status={tool.status} name={tool.name} />
|
||||
<ToolInfo
|
||||
name={tool.name}
|
||||
status={tool.status}
|
||||
description={tool.description}
|
||||
emphasis="high"
|
||||
/>
|
||||
</Box>
|
||||
{!hideToolIdentity && (
|
||||
<Box>
|
||||
<ToolStatusIndicator status={tool.status} name={tool.name} />
|
||||
<ToolInfo
|
||||
name={tool.name}
|
||||
status={tool.status}
|
||||
description={tool.description}
|
||||
emphasis="high"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</StickyHeader>
|
||||
|
||||
|
||||
@@ -1,138 +1,189 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`AskUserDialog > Choice question placeholder > uses default placeholder when not provided 1`] = `
|
||||
"Select your preferred language:
|
||||
|
||||
1. TypeScript
|
||||
2. JavaScript
|
||||
● 3. Enter a custom value
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 1`] = `
|
||||
"Select your preferred language:
|
||||
|
||||
1. TypeScript
|
||||
2. JavaScript
|
||||
● 3. Type another language...
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 1`] = `
|
||||
"Choose an option
|
||||
|
||||
▲
|
||||
● 1. Option 1
|
||||
Description 1
|
||||
2. Option 2
|
||||
Description 2
|
||||
▼
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 1`] = `
|
||||
"Choose an option
|
||||
|
||||
● 1. Option 1
|
||||
Description 1
|
||||
2. Option 2
|
||||
Description 2
|
||||
3. Option 3
|
||||
Description 3
|
||||
4. Option 4
|
||||
Description 4
|
||||
5. Option 5
|
||||
Description 5
|
||||
6. Option 6
|
||||
Description 6
|
||||
7. Option 7
|
||||
Description 7
|
||||
8. Option 8
|
||||
Description 8
|
||||
9. Option 9
|
||||
Description 9
|
||||
10. Option 10
|
||||
Description 10
|
||||
11. Option 11
|
||||
Description 11
|
||||
12. Option 12
|
||||
Description 12
|
||||
13. Option 13
|
||||
Description 13
|
||||
14. Option 14
|
||||
Description 14
|
||||
15. Option 15
|
||||
Description 15
|
||||
16. Enter a custom value
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ What should we name this component? │
|
||||
│ │
|
||||
│ > e.g., UserProfileCard │
|
||||
│ │
|
||||
│ │
|
||||
│ Enter to submit · Esc to cancel │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"What should we name this component?
|
||||
|
||||
> e.g., UserProfileCard
|
||||
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Text type questions > shows correct keyboard hints for text type 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Enter the variable name: │
|
||||
│ │
|
||||
│ > Enter your response │
|
||||
│ │
|
||||
│ │
|
||||
│ Enter to submit · Esc to cancel │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"Enter the variable name:
|
||||
|
||||
> Enter your response
|
||||
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Text type questions > shows default placeholder when none provided 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Enter the database connection string: │
|
||||
│ │
|
||||
│ > Enter your response │
|
||||
│ │
|
||||
│ │
|
||||
│ Enter to submit · Esc to cancel │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"Enter the database connection string:
|
||||
|
||||
> Enter your response
|
||||
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > allows navigating to Review tab and back 1`] = `
|
||||
"╭─────────────────────────────────────────────────────────────────╮
|
||||
│ ← □ Tests │ □ Docs │ ≡ Review → │
|
||||
│ │
|
||||
│ Review your answers: │
|
||||
│ │
|
||||
│ ⚠ You have 2 unanswered questions │
|
||||
│ │
|
||||
│ Tests → (not answered) │
|
||||
│ Docs → (not answered) │
|
||||
│ │
|
||||
│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel │
|
||||
╰─────────────────────────────────────────────────────────────────╯"
|
||||
"← □ Tests │ □ Docs │ ≡ Review →
|
||||
|
||||
Review your answers:
|
||||
|
||||
⚠ You have 2 unanswered questions
|
||||
|
||||
Tests → (not answered)
|
||||
Docs → (not answered)
|
||||
|
||||
Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > hides progress header for single question 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Which authentication method should we use? │
|
||||
│ │
|
||||
│ ● 1. OAuth 2.0 │
|
||||
│ Industry standard, supports SSO │
|
||||
│ 2. JWT tokens │
|
||||
│ Stateless, good for APIs │
|
||||
│ 3. Enter a custom value │
|
||||
│ │
|
||||
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"Which authentication method should we use?
|
||||
|
||||
● 1. OAuth 2.0
|
||||
Industry standard, supports SSO
|
||||
2. JWT tokens
|
||||
Stateless, good for APIs
|
||||
3. Enter a custom value
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > renders question and options 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Which authentication method should we use? │
|
||||
│ │
|
||||
│ ● 1. OAuth 2.0 │
|
||||
│ Industry standard, supports SSO │
|
||||
│ 2. JWT tokens │
|
||||
│ Stateless, good for APIs │
|
||||
│ 3. Enter a custom value │
|
||||
│ │
|
||||
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"Which authentication method should we use?
|
||||
|
||||
● 1. OAuth 2.0
|
||||
Industry standard, supports SSO
|
||||
2. JWT tokens
|
||||
Stateless, good for APIs
|
||||
3. Enter a custom value
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > shows Review tab in progress header for multiple questions 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ← □ Framework │ □ Styling │ ≡ Review → │
|
||||
│ │
|
||||
│ Which framework? │
|
||||
│ │
|
||||
│ ● 1. React │
|
||||
│ Component library │
|
||||
│ 2. Vue │
|
||||
│ Progressive framework │
|
||||
│ 3. Enter a custom value │
|
||||
│ │
|
||||
│ Enter to select · ←/→ to switch questions · Esc to cancel │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"← □ Framework │ □ Styling │ ≡ Review →
|
||||
|
||||
Which framework?
|
||||
|
||||
● 1. React
|
||||
Component library
|
||||
2. Vue
|
||||
Progressive framework
|
||||
3. Enter a custom value
|
||||
|
||||
Enter to select · ←/→ to switch questions · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > shows keyboard hints 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Which authentication method should we use? │
|
||||
│ │
|
||||
│ ● 1. OAuth 2.0 │
|
||||
│ Industry standard, supports SSO │
|
||||
│ 2. JWT tokens │
|
||||
│ Stateless, good for APIs │
|
||||
│ 3. Enter a custom value │
|
||||
│ │
|
||||
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"Which authentication method should we use?
|
||||
|
||||
● 1. OAuth 2.0
|
||||
Industry standard, supports SSO
|
||||
2. JWT tokens
|
||||
Stateless, good for APIs
|
||||
3. Enter a custom value
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > shows progress header for multiple questions 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ← □ Database │ □ ORM │ ≡ Review → │
|
||||
│ │
|
||||
│ Which database should we use? │
|
||||
│ │
|
||||
│ ● 1. PostgreSQL │
|
||||
│ Relational database │
|
||||
│ 2. MongoDB │
|
||||
│ Document database │
|
||||
│ 3. Enter a custom value │
|
||||
│ │
|
||||
│ Enter to select · ←/→ to switch questions · Esc to cancel │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"← □ Database │ □ ORM │ ≡ Review →
|
||||
|
||||
Which database should we use?
|
||||
|
||||
● 1. PostgreSQL
|
||||
Relational database
|
||||
2. MongoDB
|
||||
Document database
|
||||
3. Enter a custom value
|
||||
|
||||
Enter to select · ←/→ to switch questions · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > shows warning for unanswered questions on Review tab 1`] = `
|
||||
"╭─────────────────────────────────────────────────────────────────╮
|
||||
│ ← □ License │ □ README │ ≡ Review → │
|
||||
│ │
|
||||
│ Review your answers: │
|
||||
│ │
|
||||
│ ⚠ You have 2 unanswered questions │
|
||||
│ │
|
||||
│ License → (not answered) │
|
||||
│ README → (not answered) │
|
||||
│ │
|
||||
│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel │
|
||||
╰─────────────────────────────────────────────────────────────────╯"
|
||||
"← □ License │ □ README │ ≡ Review →
|
||||
|
||||
Review your answers:
|
||||
|
||||
⚠ You have 2 unanswered questions
|
||||
|
||||
License → (not answered)
|
||||
README → (not answered)
|
||||
|
||||
Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel"
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm star... (PID: 1001) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
|
||||
│ Starting server... │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > keeps exit code status color even when selected 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm star... (PID: 1003) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
|
||||
│ │
|
||||
│ Select Process (Enter to select, Esc to cancel): │
|
||||
│ │
|
||||
│ 1. npm start (PID: 1001) │
|
||||
│ 2. tail -f log.txt (PID: 1002) │
|
||||
│ ● 3. exit 0 (PID: 1003) (Exit Code: 0) │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm start 2: tail -f log.txt (PID: 1001) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
|
||||
│ Starting server... │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm ... 2: tail... (PID: 1001) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
|
||||
│ Starting server... │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenProp is true 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm star... (PID: 1001) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
|
||||
│ │
|
||||
│ Select Process (Enter to select, Esc to cancel): │
|
||||
│ │
|
||||
│ ● 1. npm start (PID: 1001) │
|
||||
│ 2. tail -f log.txt (PID: 1002) │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm star... (PID: 1002) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
|
||||
│ │
|
||||
│ Select Process (Enter to select, Esc to cancel): │
|
||||
│ │
|
||||
│ 1. npm start (PID: 1001) │
|
||||
│ ● 2. tail -f log.txt (PID: 1002) │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
@@ -0,0 +1,234 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > bubbles up Ctrl+C when feedback is empty while editing 1`] = `
|
||||
"## 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
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Type your feedback... ✓
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 1`] = `
|
||||
"## 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
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Add tests ✓
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > displays error state when file read fails 1`] = `" Error reading plan: File not found"`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > handles long plan content appropriately 1`] = `
|
||||
"## 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\`
|
||||
... last 22 lines hidden ...
|
||||
|
||||
● 1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
3. Type your feedback...
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: false > renders correctly with plan content 1`] = `
|
||||
"## 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
|
||||
|
||||
● 1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
3. Type your feedback...
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > bubbles up Ctrl+C when feedback is empty while editing 1`] = `
|
||||
"## 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
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Type your feedback... ✓
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 1`] = `
|
||||
"## 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
|
||||
|
||||
1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Add tests ✓
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > displays error state when file read fails 1`] = `" Error reading plan: File not found"`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > handles long plan content appropriately 1`] = `
|
||||
"## 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
|
||||
|
||||
● 1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
3. Type your feedback...
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`ExitPlanModeDialog > useAlternateBuffer: true > renders correctly with plan content 1`] = `
|
||||
"## 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
|
||||
|
||||
● 1. Yes, automatically accept edits
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
3. Type your feedback...
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
@@ -34,6 +34,23 @@ exports[`RewindViewer > Content Filtering > 'strips expanded MCP resource conten
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`RewindViewer > Content Filtering > 'uses displayContent if present and do…' 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ > Rewind │
|
||||
│ │
|
||||
│ clean display content │
|
||||
│ No files have been changed │
|
||||
│ │
|
||||
│ ● Stay at current position │
|
||||
│ Cancel rewind and stay here │
|
||||
│ │
|
||||
│ │
|
||||
│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`RewindViewer > Interaction Selection > 'cancels on Escape' > confirmation-dialog 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
|
||||
@@ -31,8 +31,8 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ Hide the window title bar │
|
||||
│ Auto Theme Switching true │
|
||||
│ Automatically switch between default light and dark themes based on terminal backgro… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
@@ -77,8 +77,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ Hide the window title bar │
|
||||
│ Auto Theme Switching true │
|
||||
│ Automatically switch between default light and dark themes based on terminal backgro… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
@@ -123,8 +123,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false* │
|
||||
│ Hide the window title bar │
|
||||
│ Auto Theme Switching true │
|
||||
│ Automatically switch between default light and dark themes based on terminal backgro… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
@@ -169,8 +169,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ Hide the window title bar │
|
||||
│ Auto Theme Switching true │
|
||||
│ Automatically switch between default light and dark themes based on terminal backgro… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
@@ -215,8 +215,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ Hide the window title bar │
|
||||
│ Auto Theme Switching true │
|
||||
│ Automatically switch between default light and dark themes based on terminal backgro… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
@@ -261,8 +261,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ Hide the window title bar │
|
||||
│ Auto Theme Switching true │
|
||||
│ Automatically switch between default light and dark themes based on terminal backgro… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
@@ -307,8 +307,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false* │
|
||||
│ Hide the window title bar │
|
||||
│ Auto Theme Switching true │
|
||||
│ Automatically switch between default light and dark themes based on terminal backgro… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
@@ -353,8 +353,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ Hide the window title bar │
|
||||
│ Auto Theme Switching true │
|
||||
│ Automatically switch between default light and dark themes based on terminal backgro… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
@@ -399,8 +399,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title true* │
|
||||
│ Hide the window title bar │
|
||||
│ Auto Theme Switching true │
|
||||
│ Automatically switch between default light and dark themes based on terminal backgro… │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`StatusDisplay > does NOT render HookStatusDisplay if notifications are disabled in settings 1`] = `"Mock Context Summary Display (Skills: 2)"`;
|
||||
exports[`StatusDisplay > does NOT render HookStatusDisplay if notifications are disabled in settings 1`] = `"Mock Context Summary Display (Skills: 2, Shells: 0)"`;
|
||||
|
||||
exports[`StatusDisplay > prioritizes Ctrl+C prompt over everything else (except system md) 1`] = `"Press Ctrl+C again to exit."`;
|
||||
|
||||
exports[`StatusDisplay > prioritizes warning over Ctrl+D 1`] = `"Warning"`;
|
||||
|
||||
exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock Context Summary Display (Skills: 2)"`;
|
||||
exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock Context Summary Display (Skills: 2, Shells: 0)"`;
|
||||
|
||||
exports[`StatusDisplay > renders Ctrl+D prompt 1`] = `"Press Ctrl+D again to exit."`;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
exports[`ToolConfirmationQueue > calculates availableContentHeight based on availableTerminalHeight from UI state 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Action Required 1 of 1 │
|
||||
│ Action Required │
|
||||
│ │
|
||||
│ ? replace edit file │
|
||||
│ │
|
||||
@@ -22,7 +22,7 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai
|
||||
|
||||
exports[`ToolConfirmationQueue > does not render expansion hint when constrainHeight is false 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Action Required 1 of 1 │
|
||||
│ Action Required │
|
||||
│ │
|
||||
│ ? replace edit file │
|
||||
│ │
|
||||
@@ -44,7 +44,7 @@ exports[`ToolConfirmationQueue > does not render expansion hint when constrainHe
|
||||
|
||||
exports[`ToolConfirmationQueue > renders expansion hint when content is long and constrained 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Action Required 1 of 1 │
|
||||
│ Action Required │
|
||||
│ │
|
||||
│ ? replace edit file │
|
||||
│ │
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type SerializableConfirmationDetails,
|
||||
type ToolCallConfirmationDetails,
|
||||
type Config,
|
||||
type ToolConfirmationPayload,
|
||||
ToolConfirmationOutcome,
|
||||
hasRedirection,
|
||||
debugLogger,
|
||||
@@ -25,12 +26,15 @@ import { sanitizeForDisplay } from '../../utils/textUtils.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||
import {
|
||||
REDIRECTION_WARNING_NOTE_LABEL,
|
||||
REDIRECTION_WARNING_NOTE_TEXT,
|
||||
REDIRECTION_WARNING_TIP_LABEL,
|
||||
REDIRECTION_WARNING_TIP_TEXT,
|
||||
} from '../../textConstants.js';
|
||||
import { AskUserDialog } from '../AskUserDialog.js';
|
||||
import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js';
|
||||
|
||||
export interface ToolConfirmationMessageProps {
|
||||
callId: string;
|
||||
@@ -59,9 +63,14 @@ export const ToolConfirmationMessage: React.FC<
|
||||
const allowPermanentApproval =
|
||||
settings.merged.security.enablePermanentToolApproval;
|
||||
|
||||
const handlesOwnUI =
|
||||
confirmationDetails.type === 'ask_user' ||
|
||||
confirmationDetails.type === 'exit_plan_mode';
|
||||
const isTrustedFolder = config.isTrustedFolder();
|
||||
|
||||
const handleConfirm = useCallback(
|
||||
(outcome: ToolConfirmationOutcome) => {
|
||||
void confirm(callId, outcome).catch((error: unknown) => {
|
||||
(outcome: ToolConfirmationOutcome, payload?: ToolConfirmationPayload) => {
|
||||
void confirm(callId, outcome, payload).catch((error: unknown) => {
|
||||
debugLogger.error(
|
||||
`Failed to handle tool confirmation for ${callId}:`,
|
||||
error,
|
||||
@@ -71,15 +80,18 @@ export const ToolConfirmationMessage: React.FC<
|
||||
[confirm, callId],
|
||||
);
|
||||
|
||||
const isTrustedFolder = config.isTrustedFolder();
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (!isFocused) return false;
|
||||
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
handleConfirm(ToolConfirmationOutcome.Cancel);
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.QUIT](key)) {
|
||||
// Return false to let ctrl-C bubble up to AppContainer for exit flow.
|
||||
// AppContainer will call cancelOngoingRequest which will cancel the tool.
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: isFocused },
|
||||
@@ -180,7 +192,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
} else {
|
||||
} else if (confirmationDetails.type === 'mcp') {
|
||||
// mcp tool confirmation
|
||||
options.push({
|
||||
label: 'Allow once',
|
||||
@@ -251,6 +263,49 @@ export const ToolConfirmationMessage: React.FC<
|
||||
let question = '';
|
||||
const options = getOptions();
|
||||
|
||||
if (confirmationDetails.type === 'ask_user') {
|
||||
bodyContent = (
|
||||
<AskUserDialog
|
||||
questions={confirmationDetails.questions}
|
||||
onSubmit={(answers) => {
|
||||
handleConfirm(ToolConfirmationOutcome.ProceedOnce, { answers });
|
||||
}}
|
||||
onCancel={() => {
|
||||
handleConfirm(ToolConfirmationOutcome.Cancel);
|
||||
}}
|
||||
width={terminalWidth}
|
||||
availableHeight={availableBodyContentHeight()}
|
||||
/>
|
||||
);
|
||||
return { question: '', bodyContent, options: [] };
|
||||
}
|
||||
|
||||
if (confirmationDetails.type === 'exit_plan_mode') {
|
||||
bodyContent = (
|
||||
<ExitPlanModeDialog
|
||||
planPath={confirmationDetails.planPath}
|
||||
onApprove={(approvalMode) => {
|
||||
handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
|
||||
approved: true,
|
||||
approvalMode,
|
||||
});
|
||||
}}
|
||||
onFeedback={(feedback) => {
|
||||
handleConfirm(ToolConfirmationOutcome.ProceedOnce, {
|
||||
approved: false,
|
||||
feedback,
|
||||
});
|
||||
}}
|
||||
onCancel={() => {
|
||||
handleConfirm(ToolConfirmationOutcome.Cancel);
|
||||
}}
|
||||
width={terminalWidth}
|
||||
availableHeight={availableBodyContentHeight()}
|
||||
/>
|
||||
);
|
||||
return { question: '', bodyContent, options: [] };
|
||||
}
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
if (!confirmationDetails.isModifying) {
|
||||
question = `Apply this change?`;
|
||||
@@ -265,7 +320,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
}
|
||||
} else if (confirmationDetails.type === 'info') {
|
||||
question = `Do you want to proceed?`;
|
||||
} else {
|
||||
} else if (confirmationDetails.type === 'mcp') {
|
||||
// mcp tool confirmation
|
||||
const mcpProps = confirmationDetails;
|
||||
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
|
||||
@@ -387,7 +442,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
} else {
|
||||
} else if (confirmationDetails.type === 'mcp') {
|
||||
// mcp tool confirmation
|
||||
const mcpProps = confirmationDetails;
|
||||
|
||||
@@ -405,6 +460,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
getOptions,
|
||||
availableBodyContentHeight,
|
||||
terminalWidth,
|
||||
handleConfirm,
|
||||
]);
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
@@ -429,32 +485,38 @@ export const ToolConfirmationMessage: React.FC<
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingTop={0} paddingBottom={1}>
|
||||
{/* Body Content (Diff Renderer or Command Info) */}
|
||||
{/* No separate context display here anymore for edits */}
|
||||
<Box flexGrow={1} flexShrink={1} overflow="hidden">
|
||||
<MaxSizedBox
|
||||
maxHeight={availableBodyContentHeight()}
|
||||
maxWidth={terminalWidth}
|
||||
overflowDirection="top"
|
||||
>
|
||||
{bodyContent}
|
||||
</MaxSizedBox>
|
||||
</Box>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingTop={0}
|
||||
paddingBottom={handlesOwnUI ? 0 : 1}
|
||||
>
|
||||
{handlesOwnUI ? (
|
||||
bodyContent
|
||||
) : (
|
||||
<>
|
||||
<Box flexGrow={1} flexShrink={1} overflow="hidden">
|
||||
<MaxSizedBox
|
||||
maxHeight={availableBodyContentHeight()}
|
||||
maxWidth={terminalWidth}
|
||||
overflowDirection="top"
|
||||
>
|
||||
{bodyContent}
|
||||
</MaxSizedBox>
|
||||
</Box>
|
||||
|
||||
{/* Confirmation Question */}
|
||||
<Box marginBottom={1} flexShrink={0}>
|
||||
<Text color={theme.text.primary}>{question}</Text>
|
||||
</Box>
|
||||
<Box marginBottom={1} flexShrink={0}>
|
||||
<Text color={theme.text.primary}>{question}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Select Input for Options */}
|
||||
<Box flexShrink={0}>
|
||||
<RadioButtonSelect
|
||||
items={options}
|
||||
onSelect={handleSelect}
|
||||
isFocused={isFocused}
|
||||
/>
|
||||
</Box>
|
||||
<Box flexShrink={0}>
|
||||
<RadioButtonSelect
|
||||
items={options}
|
||||
onSelect={handleSelect}
|
||||
isFocused={isFocused}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -475,14 +475,7 @@ describe('BaseSelectionList', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const output = lastFrame();
|
||||
// At the top, should show first 3 items
|
||||
expect(output).toContain('Item 1');
|
||||
expect(output).toContain('Item 3');
|
||||
expect(output).not.toContain('Item 4');
|
||||
// Both arrows should be visible
|
||||
expect(output).toContain('▲');
|
||||
expect(output).toContain('▼');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -493,15 +486,7 @@ describe('BaseSelectionList', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const output = lastFrame();
|
||||
// After scrolling to middle, should see items around index 5
|
||||
expect(output).toContain('Item 4');
|
||||
expect(output).toContain('Item 6');
|
||||
expect(output).not.toContain('Item 3');
|
||||
expect(output).not.toContain('Item 7');
|
||||
// Both scroll arrows should be visible
|
||||
expect(output).toContain('▲');
|
||||
expect(output).toContain('▼');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -512,32 +497,18 @@ describe('BaseSelectionList', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const output = lastFrame();
|
||||
// At the end, should show last 3 items
|
||||
expect(output).toContain('Item 8');
|
||||
expect(output).toContain('Item 10');
|
||||
expect(output).not.toContain('Item 7');
|
||||
// Both arrows should be visible
|
||||
expect(output).toContain('▲');
|
||||
expect(output).toContain('▼');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show both arrows dimmed when list fits entirely', () => {
|
||||
it('should not show arrows when list fits entirely', () => {
|
||||
const { lastFrame } = renderComponent({
|
||||
items,
|
||||
maxItemsToShow: 5,
|
||||
showScrollArrows: true,
|
||||
});
|
||||
|
||||
const output = lastFrame();
|
||||
// Should show all items since maxItemsToShow > items.length
|
||||
expect(output).toContain('Item A');
|
||||
expect(output).toContain('Item B');
|
||||
expect(output).toContain('Item C');
|
||||
// Both arrows should be visible but dimmed (this test doesn't need waitFor since no scrolling occurs)
|
||||
expect(output).toContain('▲');
|
||||
expect(output).toContain('▼');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,7 +100,7 @@ export function BaseSelectionList<
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Use conditional coloring instead of conditional rendering */}
|
||||
{showScrollArrows && (
|
||||
{showScrollArrows && items.length > maxItemsToShow && (
|
||||
<Text
|
||||
color={scrollOffset > 0 ? theme.text.primary : theme.text.secondary}
|
||||
>
|
||||
@@ -172,7 +172,7 @@ export function BaseSelectionList<
|
||||
);
|
||||
})}
|
||||
|
||||
{showScrollArrows && (
|
||||
{showScrollArrows && items.length > maxItemsToShow && (
|
||||
<Text
|
||||
color={
|
||||
scrollOffset + maxItemsToShow < items.length
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { ExpandableText, MAX_WIDTH } from './ExpandableText.js';
|
||||
@@ -76,6 +77,7 @@ describe('ExpandableText', () => {
|
||||
100,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(lastFrame()).toContain(chalk.inverse(userInput));
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -119,11 +119,7 @@ const _ExpandableText: React.FC<ExpandableTextProps> = ({
|
||||
{before}
|
||||
{match
|
||||
? match.split(/(\s+)/).map((part, index) => (
|
||||
<Text
|
||||
key={`match-${index}`}
|
||||
color={theme.background.primary}
|
||||
backgroundColor={theme.text.primary}
|
||||
>
|
||||
<Text key={`match-${index}`} inverse color={textColor}>
|
||||
{part}
|
||||
</Text>
|
||||
))
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { render, renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { OverflowProvider } from '../../contexts/OverflowContext.js';
|
||||
import { MaxSizedBox } from './MaxSizedBox.js';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { Box, Text } from 'ink';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
@@ -226,4 +227,31 @@ describe('<MaxSizedBox />', () => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not leak content after hidden indicator with bottom overflow', async () => {
|
||||
const markdownContent = Array.from(
|
||||
{ length: 20 },
|
||||
(_, i) => `- Step ${i + 1}: Do something important`,
|
||||
).join('\n');
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<MaxSizedBox maxWidth={80} maxHeight={5} overflowDirection="bottom">
|
||||
<MarkdownDisplay
|
||||
text={`## Plan\n\n${markdownContent}`}
|
||||
isPending={false}
|
||||
terminalWidth={76}
|
||||
/>
|
||||
</MaxSizedBox>,
|
||||
{ width: 80 },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(lastFrame()).toContain('... last'));
|
||||
|
||||
const frame = lastFrame()!;
|
||||
const lines = frame.split('\n');
|
||||
const lastLine = lines[lines.length - 1];
|
||||
|
||||
// The last line should only contain the hidden indicator, no leaked content
|
||||
expect(lastLine).toMatch(/^\.\.\. last \d+ lines? hidden \.\.\.$/);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,7 +120,12 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
|
||||
hidden ...
|
||||
</Text>
|
||||
)}
|
||||
<Box flexDirection="column" overflow="hidden" flexGrow={1}>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
overflow="hidden"
|
||||
flexGrow={0}
|
||||
maxHeight={isOverflowing ? visibleContentHeight : undefined}
|
||||
>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
ref={onRefChange}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should not show arrows when list fits entirely 1`] = `
|
||||
"● 1. Item A
|
||||
2. Item B
|
||||
3. Item C"
|
||||
`;
|
||||
|
||||
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the end 1`] = `
|
||||
"▲
|
||||
8. Item 8
|
||||
9. Item 9
|
||||
● 10. Item 10
|
||||
▼"
|
||||
`;
|
||||
|
||||
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the middle 1`] = `
|
||||
"▲
|
||||
4. Item 4
|
||||
5. Item 5
|
||||
● 6. Item 6
|
||||
▼"
|
||||
`;
|
||||
|
||||
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows with correct colors when enabled (at the top) 1`] = `
|
||||
"▲
|
||||
● 1. Item 1
|
||||
2. Item 2
|
||||
3. Item 3
|
||||
▼"
|
||||
`;
|
||||
+2
-4
@@ -1,14 +1,12 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`DescriptiveRadioButtonSelect > should render correctly with custom props 1`] = `
|
||||
"▲
|
||||
1. Foo Title
|
||||
" 1. Foo Title
|
||||
This is Foo.
|
||||
● 2. Bar Title
|
||||
This is Bar.
|
||||
3. Baz Title
|
||||
This is Baz.
|
||||
▼"
|
||||
This is Baz."
|
||||
`;
|
||||
|
||||
exports[`DescriptiveRadioButtonSelect > should render correctly with default props 1`] = `
|
||||
|
||||
@@ -31,6 +31,14 @@ Line 29
|
||||
Line 30"
|
||||
`;
|
||||
|
||||
exports[`<MaxSizedBox /> > does not leak content after hidden indicator with bottom overflow 1`] = `
|
||||
"Plan
|
||||
|
||||
- Step 1: Do something important
|
||||
- Step 2: Do something important
|
||||
... last 18 lines hidden ..."
|
||||
`;
|
||||
|
||||
exports[`<MaxSizedBox /> > does not truncate when maxHeight is undefined 1`] = `
|
||||
"Line 1
|
||||
Line 2"
|
||||
|
||||
@@ -1384,6 +1384,61 @@ describe('useTextBuffer', () => {
|
||||
expect(state.visualCursor).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('move: up/down should work on wrapped lines (regression test)', () => {
|
||||
// Line that wraps into two visual lines
|
||||
// Viewport width 10. "0123456789ABCDE" (15 chars)
|
||||
// Visual Line 0: "0123456789"
|
||||
// Visual Line 1: "ABCDE"
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({
|
||||
viewport: { width: 10, height: 5 },
|
||||
isValidPath: () => false,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setText('0123456789ABCDE');
|
||||
});
|
||||
|
||||
// Cursor should be at the end: logical [0, 15], visual [1, 5]
|
||||
expect(getBufferState(result).cursor).toEqual([0, 15]);
|
||||
expect(getBufferState(result).visualCursor).toEqual([1, 5]);
|
||||
|
||||
// Press Up arrow - should move to first visual line
|
||||
// This currently fails because handleInput returns false if cursorRow === 0
|
||||
let handledUp = false;
|
||||
act(() => {
|
||||
handledUp = result.current.handleInput({
|
||||
name: 'up',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x1b[A',
|
||||
});
|
||||
});
|
||||
expect(handledUp).toBe(true);
|
||||
expect(getBufferState(result).visualCursor[0]).toBe(0);
|
||||
|
||||
// Press Down arrow - should move back to second visual line
|
||||
// This would also fail if cursorRow is the last logical row
|
||||
let handledDown = false;
|
||||
act(() => {
|
||||
handledDown = result.current.handleInput({
|
||||
name: 'down',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x1b[B',
|
||||
});
|
||||
});
|
||||
expect(handledDown).toBe(true);
|
||||
expect(getBufferState(result).visualCursor[0]).toBe(1);
|
||||
});
|
||||
|
||||
it('moveToVisualPosition: should correctly handle wide characters (Chinese)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({
|
||||
@@ -1515,6 +1570,50 @@ describe('useTextBuffer', () => {
|
||||
expect(getBufferState(result).text).toBe('');
|
||||
});
|
||||
|
||||
it('should handle CLEAR_INPUT (Ctrl+C)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({
|
||||
initialText: 'hello',
|
||||
viewport,
|
||||
isValidPath: () => false,
|
||||
}),
|
||||
);
|
||||
expect(getBufferState(result).text).toBe('hello');
|
||||
let handled = false;
|
||||
act(() => {
|
||||
handled = result.current.handleInput({
|
||||
name: 'c',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\u0003',
|
||||
});
|
||||
});
|
||||
expect(handled).toBe(true);
|
||||
expect(getBufferState(result).text).toBe('');
|
||||
});
|
||||
|
||||
it('should NOT handle CLEAR_INPUT if buffer is empty', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
let handled = true;
|
||||
act(() => {
|
||||
handled = result.current.handleInput({
|
||||
name: 'c',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\u0003',
|
||||
});
|
||||
});
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle "Backspace" key', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({
|
||||
|
||||
@@ -2905,12 +2905,12 @@ export function useTextBuffer({
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.MOVE_UP](key)) {
|
||||
if (cursorRow === 0) return false;
|
||||
if (visualCursor[0] === 0) return false;
|
||||
move('up');
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.MOVE_DOWN](key)) {
|
||||
if (cursorRow === lines.length - 1) return false;
|
||||
if (visualCursor[0] === visualLines.length - 1) return false;
|
||||
move('down');
|
||||
return true;
|
||||
}
|
||||
@@ -2930,6 +2930,13 @@ export function useTextBuffer({
|
||||
move('end');
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||
if (text.length > 0) {
|
||||
setText('');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
|
||||
deleteWordLeft();
|
||||
return true;
|
||||
@@ -2943,6 +2950,13 @@ export function useTextBuffer({
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.DELETE_CHAR_RIGHT](key)) {
|
||||
const lastLineIdx = lines.length - 1;
|
||||
if (
|
||||
cursorRow === lastLineIdx &&
|
||||
cursorCol === cpLen(lines[lastLineIdx] ?? '')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
del();
|
||||
return true;
|
||||
}
|
||||
@@ -2974,6 +2988,10 @@ export function useTextBuffer({
|
||||
cursorCol,
|
||||
lines,
|
||||
singleLine,
|
||||
setText,
|
||||
text,
|
||||
visualCursor,
|
||||
visualLines,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
findWordEndInLine,
|
||||
} from './text-buffer.js';
|
||||
import { cpLen, toCodePoints } from '../../utils/textUtils.js';
|
||||
import { assumeExhaustive } from '../../../utils/checks.js';
|
||||
import { assumeExhaustive } from '@google/gemini-cli-core';
|
||||
|
||||
// Check if we're at the end of a base word (on the last base character)
|
||||
// Returns true if current position has a base character followed only by combining marks until non-word
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user