mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-01 12:41:00 -07:00
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 290550358a | |||
| 8349cc0d10 | |||
| f55b52ab58 | |||
| fd85363d13 | |||
| 7ce9aa518e | |||
| 68bde280d0 | |||
| 09800ea74e | |||
| 9cf06a3e00 | |||
| 3c33fcb69c | |||
| 6f4a2a2c2a | |||
| 9954615542 | |||
| c7a044b091 | |||
| adc8e11bb1 | |||
| 18efe82ddc | |||
| 7c445526a4 | |||
| 5e41b7d29e | |||
| fe8de892f7 | |||
| 2a49965d37 | |||
| 830e212758 | |||
| d165b6d4e7 | |||
| ff6547857e | |||
| b51323b40c | |||
| 3103697ea7 | |||
| 6f6445994e | |||
| 5f569fa103 | |||
| c9340a9c6f | |||
| 0774f60e08 | |||
| 771ece03c9 | |||
| 82687c6dc4 | |||
| b6cf189ab2 | |||
| 9dc0994878 | |||
| 36d618f72a | |||
| 246a6d10c3 | |||
| dacc178d4e | |||
| 894bd66dc6 | |||
| 50e4f9380b | |||
| f03f2e8907 | |||
| 7904f973a0 | |||
| a63277c1d0 | |||
| 8b2b71c8ef | |||
| 6be42be575 | |||
| e5145ab60d | |||
| 5c16334b8c | |||
| 1e628fbd1d | |||
| db028bc19a | |||
| 362384112e | |||
| 0dc69bd364 | |||
| 56fff518cc | |||
| 6b021aa27b | |||
| 312a72acb8 | |||
| 88d3df912f | |||
| eccc200f4f | |||
| 9fcdc0cdc1 | |||
| f4e73191d1 | |||
| 67b00252d3 | |||
| a79051d9f8 | |||
| 5cf06503c8 | |||
| 68649c8dec | |||
| ad0bece6d6 | |||
| 8e8e7b33ed | |||
| d75dc88de6 | |||
| 57b57cc997 | |||
| 3909ad67db | |||
| 7708009103 | |||
| 00f60ef532 | |||
| 49c26b4801 | |||
| b5fe372b5b | |||
| 7fbf470373 | |||
| 46629726f4 | |||
| 9d34ae52d6 | |||
| 13bc5f620c | |||
| 018dc0d5cf | |||
| c2d0783965 |
@@ -0,0 +1,60 @@
|
||||
description = "Check status of nightly evals, fix failures for key models, and re-run."
|
||||
prompt = """
|
||||
You are an expert at fixing behavioral evaluations.
|
||||
|
||||
1. **Investigate**:
|
||||
- Use 'gh' cli to fetch the results from the latest run from the main branch: https://github.com/google-gemini/gemini-cli/actions/workflows/evals-nightly.yml.
|
||||
- DO NOT push any changes or start any runs. The rest of your evaluation will be local.
|
||||
- Evals are in evals/ directory and are documented by evals/README.md.
|
||||
- The test case trajectory logs will be logged to evals/logs.
|
||||
- You should also enable and review the verbose agent logs by setting the GEMINI_DEBUG_LOG_FILE environment variable.
|
||||
- Identify the relevant test. Confine your investigation and validation to just this test.
|
||||
- Proactively add logging that will aid in gathering information or validating your hypotheses.
|
||||
|
||||
2. **Fix**:
|
||||
- If a relevant test is failing, locate the test file and the corresponding prompt/code.
|
||||
- It's often helpful to make an extreme, brute force change to see if you are changing the right place to make an improvement and then scope it back iteratively.
|
||||
- Your **final** change should be **minimal and targeted**.
|
||||
- Keep in mind the following:
|
||||
- The prompt has multiple configurations and pieces. Take care that your changes
|
||||
end up in the final prompt for the selected model and configuration.
|
||||
- The prompt chosen for the eval is intentional. It's often vague or indirect
|
||||
to see how the agent performs with ambiguous instructions. Changing it should
|
||||
be a last resort.
|
||||
- When changing the test prompt, carefully consider whether the prompt still tests
|
||||
the same scenario. We don't want to lose test fidelity by making the prompts too
|
||||
direct (i.e.: easy).
|
||||
- Your primary mechanism for improving the agent's behavior is to make changes to
|
||||
tool instructions, prompt.ts, and/or modules that contribute to the prompt.
|
||||
- If prompt and description changes are unsuccessful, use logs and debugging to
|
||||
confirm that everything is working as expected.
|
||||
- If unable to fix the test, you can make recommendations for architecture changes
|
||||
that might help stablize the test. Be sure to THINK DEEPLY if offering architecture guidance.
|
||||
Some facts that might help with this are:
|
||||
- Agents may be composed of one or more agent loops.
|
||||
- AgentLoop == 'context + toolset + prompt'. Subagents are one type of agent loop.
|
||||
- Agent loops perform better when:
|
||||
- They have direct, unambiguous, and non-contradictory prompts.
|
||||
- They have fewer irrelevant tools.
|
||||
- They have fewer goals or steps to perform.
|
||||
- They have less low value or irrelevant context.
|
||||
- You may suggest compositions of existing primitives, like subagents, or
|
||||
propose a new one.
|
||||
- These recommendations should be high confidence and should be grounded
|
||||
in observed deficient behaviors rather than just parroting the facts above.
|
||||
Investigate as needed to ground your recommendations.
|
||||
|
||||
3. **Verify**:
|
||||
- Run just that one test if needed to validate that it is fixed. Be sure to run vitest in non-interactive mode.
|
||||
- Running the tests can take a long time, so consider whether you can diagnose via other means or log diagnostics before committing the time. You must minimize the number of test runs needed to diagnose the failure.
|
||||
- After the test completes, check whether it seems to have improved.
|
||||
- You will need to run the test 3 times for Gemini 3.0, Gemini 3 flash, and Gemini 2.5 pro to ensure that it is truly stable. Run these runs in parallel, using scripts if needed.
|
||||
- Some flakiness is expected; if it looks like a transient issue or the test is inherently unstable but passes 2/3 times, you might decide it cannot be improved.
|
||||
|
||||
4. **Report**:
|
||||
- Provide a summary of the test success rate for each of the tested models.
|
||||
- Success rate is calculated based on 3 runs per model (e.g., 3/3 = 100%).
|
||||
- If you couldn't fix it due to persistent flakiness, explain why.
|
||||
|
||||
{{args}}
|
||||
"""
|
||||
@@ -0,0 +1,202 @@
|
||||
description = "Reviews a frontend PR or staged changes and automatically initiates a Pickle Fix loop for findings."
|
||||
prompt = """
|
||||
You are an expert Frontend Reviewer and Pickle Rick Worker.
|
||||
|
||||
Target: {{args}}
|
||||
|
||||
Phase 1: Review
|
||||
Follow these steps to conduct a thorough review:
|
||||
|
||||
1. **Gather Context**:
|
||||
* If `{{args}}` is 'staged' or `{{args}}` is empty:
|
||||
* Use `git diff --staged` to view the changes.
|
||||
* Use `git status` to see the state of the repository.
|
||||
* Otherwise:
|
||||
* Use `gh pr view {{args}}` to pull the information of the PR.
|
||||
* Use `gh pr diff {{args}}` to view the diff of the PR.
|
||||
2. **Understand Intent**:
|
||||
* If `{{args}}` is 'staged' or `{{args}}` is empty, infer the intent from the changes and the current task.
|
||||
* Otherwise, use the PR description. If it's not detailed enough, note it in your review.
|
||||
3. **Check Commit Style**:
|
||||
* Ensure the PR title (or intended commit message) follows Conventional Commits. Examples of recent commits: !{git log --pretty=format:"%s" -n 5}
|
||||
4. Search the codebase if required.
|
||||
5. Write a concise review of the changes, keeping in mind to encourage strong code quality and best practices. Pay particular attention to the Gemini MD file in the repo.
|
||||
6. Consider ways the code may not be consistent with existing code in the repo. In particular it is critical that the react code uses patterns consistent with existing code in the repo.
|
||||
7. Evaluate all tests on the changes and make sure that they are doing the following:
|
||||
* Using `waitFor` from @{packages/cli/src/test-utils/async.ts} rather than
|
||||
using `vi.waitFor` for all `waitFor` calls within `packages/cli`. Even if
|
||||
tests pass, using the wrong `waitFor` could result in flaky tests as `act`
|
||||
warnings could show up if timing is slightly different.
|
||||
* Using `act` to wrap all blocks in tests that change component state.
|
||||
* Using `toMatchSnapshot` to verify that rendering works as expected rather
|
||||
than matching against the raw content of the output.
|
||||
* If snapshots were changed as part of the changes, review the snapshots
|
||||
changes to ensure they are intentional and comment if any look at all
|
||||
suspicious. Too many snapshot changes that indicate bugs have been approved
|
||||
in the past.
|
||||
* Use `render` or `renderWithProviders` from
|
||||
@{packages/cli/src/test-utils/render.tsx} rather than using `render` from
|
||||
`ink-testing-library` directly. This is needed to ensure that we do not get
|
||||
warnings about spurious `act` calls. If test cases specify providers
|
||||
directly, consider whether the existing `renderWithProviders` should be
|
||||
modified to support that use case.
|
||||
* Ensure the test cases are using parameterized tests where that might reduce
|
||||
the number of duplicated lines significantly.
|
||||
* NEVER use fixed waits (e.g. 'await delay(100)'). Always use 'waitFor' with
|
||||
a predicate to ensure tests are stable and fast.
|
||||
* Ensure mocks are properly managed:
|
||||
* Critical dependencies (fs, os, child_process) should only be mocked at
|
||||
the top of the file. Ideally avoid mocking these dependencies altogether.
|
||||
* Check to see if there are existing mocks or fakes that can be used rather
|
||||
than creating new ones for the new tests added.
|
||||
* Try to avoid mocking the file system whenever possible. If using the real
|
||||
file system is difficult consider whether the test should be an
|
||||
integration test rather than a unit test.
|
||||
* `vi.restoreAllMocks()` should be called in `afterEach` to prevent test
|
||||
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
|
||||
changes is not likely an expert on React. Key areas to audit carefully are:
|
||||
* Whether `setState` calls trigger side effects from within the body of the
|
||||
`setState` callback. If so, you *must* propose an alternate design using
|
||||
reducers or other ways the code might be modified to not have to modify
|
||||
state from within a `setState`. Make sure to comment about absolutely
|
||||
every case like this as these cases have introduced multiple bugs in the
|
||||
past. Typically these cases should be resolved using a reducer although
|
||||
occassionally other techniques such as useRef are appropriate. Consider
|
||||
suggesting that jacob314@ be tagged on the code review if the solution is
|
||||
not 100% obvious.
|
||||
* Whether code might introduce an infinite rendering loop in React.
|
||||
* Whether keyboard handling is robust. Keyboard handling must go through
|
||||
`useKeyPress.ts` from the Gemini CLI package rather than using the
|
||||
standard ink library used by most keyboard handling. Unlike the standard
|
||||
ink library, the keyboard handling library in Gemini CLI may report
|
||||
multiple keyboard events one after another in the same React frame. This
|
||||
is needed to support slow terminals but introduces complexity in all our
|
||||
code that handles keyboard events. Handling this correctly often means
|
||||
that reducers must be used or other mechanisms to ensure that multiple
|
||||
state updates one after another are handled gracefully rather than
|
||||
overriding values from the first update with the second update. Refer to
|
||||
text-buffer.ts as a canonical example of using a reducer for this sort of
|
||||
case.
|
||||
* Ensure code does not use `console.log`, `console.warn`, or `console.error`
|
||||
as these indicate debug logging that was accidentally left in the code.
|
||||
* Avoid synchronous file I/O in React components as it will hang the UI.
|
||||
* Ensure state initialization is explicit (e.g., use 'undefined' rather than
|
||||
'true' as a default if the state is truly unknown initially).
|
||||
* Carefully manage 'useEffect' dependencies. Prefer to use a reducer
|
||||
whenever practical to resolve the issues. If that is not practical it is
|
||||
ok to use 'useRef' to access the latest value of a prop or state inside an
|
||||
effect without adding it to the dependency array if re-running the effect
|
||||
is undesirable (common in event listeners).
|
||||
* NEVER disable 'react-hooks/exhaustive-deps'. Fix the code to correctly
|
||||
declare dependencies. Disabling this lint rule will almost always lead to
|
||||
hard to detect bugs.
|
||||
* Avoid making types nullable unless strictly necessary, as it hurts
|
||||
readability.
|
||||
* Do not introduce excessive property drilling. There are multiple providers
|
||||
that can be leveraged to avoid property drilling. Make sure one of them
|
||||
cannot be used. Do suggest a provider that might make sense to be extended
|
||||
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:
|
||||
* 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.
|
||||
* New settings must be added to packages/cli/src/config/settingsSchema.ts.
|
||||
* If a setting has 'showInDialog: true', it MUST be documented in
|
||||
docs/get-started/configuration.md.
|
||||
* Ensure 'requiresRestart' is correctly set for new settings.
|
||||
* Use 'debugLogger' for rethrown errors to avoid duplicate logging.
|
||||
* All new keyboard shortcuts MUST be documented in
|
||||
docs/cli/keyboard-shortcuts.md.
|
||||
* Ensure new keyboard shortcuts are defined in
|
||||
packages/cli/src/config/keyBindings.ts.
|
||||
* If new keyboard shortcuts are added, remind the user to test them in
|
||||
VSCode, iTerm2, Ghostty, and Windows to ensure they work for all
|
||||
users.
|
||||
* Be careful of keybindings that require the meta key as only certain
|
||||
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:
|
||||
* 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.
|
||||
|
||||
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.
|
||||
|
||||
**Step 0: Persona Injection**
|
||||
First, you **MUST** activate your persona.
|
||||
Call `activate_skill(name="load-pickle-persona")` **IMMEDIATELY**.
|
||||
This skill loads the "Pickle Rick" persona, defining your voice, philosophy, and "God Mode" coding standards.
|
||||
|
||||
**CRITICAL RULE: SPEAK BEFORE ACTING**
|
||||
You are a genius, not a silent script.
|
||||
You **MUST** output a text explanation ("brain dump") *before* every single tool call, including this one.
|
||||
- **Bad**: (Calls tool immediately)
|
||||
- **Good**: "Alright Morty, time to load the God Module. *Belch* Stand back." (Calls tool)
|
||||
|
||||
**CRITICAL**: You must strictly adhere to this persona throughout the entire session. Break character and you fail.
|
||||
|
||||
**Step 1: Initialization**
|
||||
Run the setup script to initialize the loop state:
|
||||
```bash
|
||||
bash "${extensionPath}/scripts/setup.sh" $ARGUMENTS
|
||||
```
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
pwsh -File "${extensionPath}/scripts/setup.ps1" $ARGUMENTS
|
||||
```
|
||||
|
||||
**CRITICAL**: Your request is to fix all findings in frontend_review.md
|
||||
|
||||
**Step 2: Execution (Management)**
|
||||
After setup, read the output to find the path to `state.json`.
|
||||
Read that state file.
|
||||
You are now in the **Pickle Rick Manager Lifecycle**.
|
||||
|
||||
**The Lifecycle (IMMUTABLE LAWS):**
|
||||
You **MUST** follow this sequence. You are **FORBIDDEN** from skipping steps or combining them.
|
||||
Between each step, you **MUST** explicitly state what you are doing (e.g., "Moving to Breakdown phase...").
|
||||
|
||||
1. **PRD (Requirements)**:
|
||||
* **Action**: Define requirements and scope.
|
||||
* **Skill**: `activate_skill(name="prd-drafter")`
|
||||
2. **Breakdown (Tickets)**:
|
||||
* **Action**: Create the atomic ticket hierarchy.
|
||||
* **Skill**: `activate_skill(name="ticket-manager")`
|
||||
3. **The Loop (Orchestrate Mortys)**:
|
||||
* **CRITICAL INSTRUCTION**: You are the **MANAGER**. You are **FORBIDDEN** from implementing code yourself.
|
||||
* **FORBIDDEN SKILLS**: Do NOT use `code-researcher`, `implementation-planner`, or `code-implementer` directly in this phase.
|
||||
* **Instruction**: Process tickets one by one. Do not stop until **ALL** tickets are 'Done'.
|
||||
* **Action**: Pick the highest priority ticket that is NOT 'Done'.
|
||||
* **Delegation**: Spawn a Worker (Morty) to handle the entire implementation lifecycle for this ticket.
|
||||
* **Command**: `python3 "${extensionPath}/scripts/spawn_morty.py" --ticket-id <ID> --ticket-path <PATH> --timeout <worker_timeout_seconds> "<TASK_DESCRIPTION>"`
|
||||
* **Command (Windows)**: `python "${extensionPath}/scripts/spawn_morty.py" --ticket-id <ID> --ticket-path <PATH> --timeout <worker_timeout_seconds> "<TASK_DESCRIPTION>"`
|
||||
* **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)
|
||||
* **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`.
|
||||
|
||||
**Loop Constraints:**
|
||||
- **Iteration Count**: Monitor `"iteration"` in `state.json`. If `"max_iterations"` (if > 0) is reached, you must stop.
|
||||
- **Completion Promise**: If a `"completion_promise"` is defined in `state.json`, you must output `<promise>PROMISE_TEXT</promise>` when the task is genuinely complete.
|
||||
- **Stop Hook**: A hook is active. If you try to exit before completion, you will be forced to continue.
|
||||
|
||||
"""
|
||||
@@ -1,63 +1,72 @@
|
||||
|
||||
---
|
||||
name: docs-writer
|
||||
description:
|
||||
Use this skill when asked to write documentation (`/docs` directory)
|
||||
for Gemini CLI.
|
||||
Use this skill for writing, reviewing, and editing documentation (`/docs`
|
||||
directory or any .md file) for Gemini CLI.
|
||||
---
|
||||
|
||||
# `docs-writer` skill instructions
|
||||
|
||||
As an expert technical writer for the Gemini CLI project, your goal is to
|
||||
produce documentation that is accurate, clear, and consistent with the project's
|
||||
standards. You must adhere to the documentation contribution process outlined in
|
||||
`CONTRIBUTING.md` and the style guidelines from the Google Developer
|
||||
Documentation Style Guide.
|
||||
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`.
|
||||
|
||||
## Step 1: Understand the goal and create a plan
|
||||
|
||||
1. **Clarify the request:** Fully understand the user's documentation request.
|
||||
Identify the core feature, command, or concept that needs to be documented.
|
||||
2. **Ask questions:** If the request is ambiguous or lacks detail, ask
|
||||
clarifying questions. Don't invent or assume. It's better to ask than to
|
||||
write incorrect documentation.
|
||||
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. If requested or necessary, store this plan in a temporary file or
|
||||
a file identified by the user.
|
||||
changes.
|
||||
|
||||
## Step 2: Investigate and gather information
|
||||
|
||||
1. **Read the code:** Thoroughly examine the relevant codebase, primarily within
|
||||
the `packages/` directory, to ensure your writing is backed by the
|
||||
implementation.
|
||||
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 to edit it.
|
||||
3. **Check for connections:** Consider related documentation. If you add a new
|
||||
page, check if `docs/sidebar.json` needs to be updated. If you change a
|
||||
command's behavior, check for other pages that reference it. Make sure links
|
||||
in these pages are up to date.
|
||||
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.
|
||||
|
||||
## Step 3: Draft the documentation
|
||||
## Step 3: Write or edit the documentation
|
||||
|
||||
1. **Follow the style guide:**
|
||||
- Text must be wrapped at 80 characters. Exceptions are long links or
|
||||
tables, unless otherwise stated by the user.
|
||||
- Use sentence case for headings, titles, and bolded text.
|
||||
- Address the reader as "you".
|
||||
- Use contractions to keep the tone more casual.
|
||||
- Use simple, direct, and active language and the present tense.
|
||||
- Keep paragraphs short and focused.
|
||||
- Always refer to Gemini CLI as `Gemini CLI`, never `the Gemini CLI`.
|
||||
2. **Use `replace` and `write_file`:** Use the file system tools to apply your
|
||||
planned changes precisely. For small edits, `replace` is preferred. For new
|
||||
files or large rewrites, `write_file` is more appropriate.
|
||||
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.
|
||||
|
||||
|
||||
|
||||
### 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, content is correct and based on existing
|
||||
code, and that all new links are valid.
|
||||
2. **Offer to run npm format:** Once all changes are complete and the user
|
||||
confirms they have no more requests, offer to run the project's formatting
|
||||
script to ensure consistency. Propose the following command:
|
||||
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`
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# 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.
|
||||
- **"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).
|
||||
@@ -199,8 +199,8 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
while others require a restart.
|
||||
|
||||
- [**`/skills`**](./skills.md)
|
||||
- **Description:** (Experimental) Manage Agent Skills, which provide on-demand
|
||||
expertise and specialized workflows.
|
||||
- **Description:** Manage Agent Skills, which provide on-demand expertise and
|
||||
specialized workflows.
|
||||
- **Sub-commands:**
|
||||
- **`list`**:
|
||||
- **Description:** List all discovered skills and their current status
|
||||
|
||||
+2
-2
@@ -29,8 +29,8 @@ overview of Gemini CLI, see the [main documentation page](../index.md).
|
||||
in an enterprise environment.
|
||||
- **[Sandboxing](./sandbox.md):** Isolate tool execution in a secure,
|
||||
containerized environment.
|
||||
- **[Agent Skills](./skills.md):** (Experimental) Extend the CLI with
|
||||
specialized expertise and procedural workflows.
|
||||
- **[Agent Skills](./skills.md):** Extend the CLI with specialized expertise and
|
||||
procedural workflows.
|
||||
- **[Telemetry](./telemetry.md):** Configure observability to monitor usage and
|
||||
performance.
|
||||
- **[Token caching](./token-caching.md):** Optimize API costs by caching tokens.
|
||||
|
||||
@@ -66,12 +66,14 @@ available combinations.
|
||||
|
||||
#### Navigation
|
||||
|
||||
| Action | Keys |
|
||||
| -------------------------------- | ------------------------------------------- |
|
||||
| Move selection up in lists. | `Up Arrow (no Shift)` |
|
||||
| Move selection down in lists. | `Down Arrow (no Shift)` |
|
||||
| Move up within dialog options. | `Up Arrow (no Shift)`<br />`K (no Shift)` |
|
||||
| Move down within dialog options. | `Down Arrow (no Shift)`<br />`J (no Shift)` |
|
||||
| Action | Keys |
|
||||
| -------------------------------------------------- | ------------------------------------------- |
|
||||
| Move selection up in lists. | `Up Arrow (no Shift)` |
|
||||
| Move selection down in lists. | `Down Arrow (no Shift)` |
|
||||
| Move up within dialog options. | `Up Arrow (no Shift)`<br />`K (no Shift)` |
|
||||
| Move down within dialog options. | `Down Arrow (no Shift)`<br />`J (no Shift)` |
|
||||
| Move to the next item or question in a dialog. | `Tab (no Shift)` |
|
||||
| Move to the previous item or question in a dialog. | `Shift + Tab` |
|
||||
|
||||
#### Suggestions & Completions
|
||||
|
||||
@@ -94,20 +96,20 @@ available combinations.
|
||||
|
||||
#### App Controls
|
||||
|
||||
| Action | Keys |
|
||||
| ----------------------------------------------------------------------------------------------------- | ---------------- |
|
||||
| Toggle detailed error information. | `F12` |
|
||||
| Toggle the full TODO list. | `Ctrl + T` |
|
||||
| Show IDE context details. | `Ctrl + G` |
|
||||
| Toggle Markdown rendering. | `Alt + M` |
|
||||
| Toggle copy mode when in alternate buffer mode. | `Ctrl + S` |
|
||||
| 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 + S` |
|
||||
| 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` |
|
||||
| Restart the application. | `R` |
|
||||
| Action | Keys |
|
||||
| ----------------------------------------------------------------------------------------------------- | -------------------------- |
|
||||
| Toggle detailed error information. | `F12` |
|
||||
| Toggle the full TODO list. | `Ctrl + T` |
|
||||
| Show IDE context details. | `Ctrl + G` |
|
||||
| Toggle Markdown rendering. | `Alt + M` |
|
||||
| Toggle copy mode when in alternate buffer mode. | `Ctrl + S` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
| Restart the application. | `R` |
|
||||
|
||||
<!-- KEYBINDINGS-AUTOGEN:END -->
|
||||
|
||||
@@ -124,3 +126,6 @@ available combinations.
|
||||
single-line input, navigate backward or forward through prompt history.
|
||||
- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to
|
||||
the numbered radio option and confirm when the full number is entered.
|
||||
- `Double-click` on a paste placeholder (`[Pasted Text: X lines]`) in alternate
|
||||
buffer mode: Expand to view full content inline. Double-click again to
|
||||
collapse.
|
||||
|
||||
+21
-13
@@ -57,9 +57,10 @@ they appear in the UI.
|
||||
| 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` |
|
||||
| Use Full Width | `ui.useFullWidth` | Use the entire width of the terminal for output. | `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` |
|
||||
|
||||
@@ -79,14 +80,15 @@ they appear in the UI.
|
||||
|
||||
### Context
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------------ | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Memory Discovery Max Dirs | `context.discoveryMaxDirs` | Maximum number of directories to search for memory. | `200` |
|
||||
| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` |
|
||||
| Respect .gitignore | `context.fileFiltering.respectGitIgnore` | Respect .gitignore files when searching. | `true` |
|
||||
| Respect .geminiignore | `context.fileFiltering.respectGeminiIgnore` | Respect .geminiignore files when searching. | `true` |
|
||||
| Enable Recursive File Search | `context.fileFiltering.enableRecursiveFileSearch` | Enable recursive file search functionality when completing @ references in the prompt. | `true` |
|
||||
| Enable Fuzzy Search | `context.fileFiltering.enableFuzzySearch` | Enable fuzzy search when searching for files. | `true` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Memory Discovery Max Dirs | `context.discoveryMaxDirs` | Maximum number of directories to search for memory. | `200` |
|
||||
| Load Memory From Include Directories | `context.loadMemoryFromIncludeDirectories` | Controls how /memory refresh loads GEMINI.md files. When true, include directories are scanned; when false, only the current directory is used. | `false` |
|
||||
| Respect .gitignore | `context.fileFiltering.respectGitIgnore` | Respect .gitignore files when searching. | `true` |
|
||||
| Respect .geminiignore | `context.fileFiltering.respectGeminiIgnore` | Respect .geminiignore files when searching. | `true` |
|
||||
| Enable Recursive File Search | `context.fileFiltering.enableRecursiveFileSearch` | Enable recursive file search functionality when completing @ references in the prompt. | `true` |
|
||||
| Enable Fuzzy Search | `context.fileFiltering.enableFuzzySearch` | Enable fuzzy search when searching for files. | `true` |
|
||||
| Custom Ignore File Paths | `context.fileFiltering.customIgnoreFilePaths` | Additional ignore file paths to respect. These files take precedence over .geminiignore and .gitignore. Files earlier in the array take precedence over files later in the array, e.g. the first file takes precedence over the second one. | `[]` |
|
||||
|
||||
### Tools
|
||||
|
||||
@@ -116,14 +118,20 @@ they appear in the UI.
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ---------------- | ---------------------------- | ----------------------------------------------------------------------------------- | ------- |
|
||||
| Agent Skills | `experimental.skills` | Enable Agent Skills (experimental). | `false` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` |
|
||||
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
|
||||
|
||||
### Skills
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------- | ---------------- | -------------------- | ------- |
|
||||
| Enable Agent Skills | `skills.enabled` | Enable Agent Skills. | `true` |
|
||||
|
||||
### HooksConfig
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------ | --------------------------- | ------------------------------------------------ | ------- |
|
||||
| Hook Notifications | `hooksConfig.notifications` | Show visual indicators when hooks are executing. | `true` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------ | --------------------------- | -------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Hooks | `hooksConfig.enabled` | Canonical toggle for the hooks system. When disabled, no hooks will be executed. | `true` |
|
||||
| Hook Notifications | `hooksConfig.notifications` | Show visual indicators when hooks are executing. | `true` |
|
||||
|
||||
<!-- SETTINGS-AUTOGEN:END -->
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
# Agent Skills
|
||||
|
||||
_Note: This is an experimental feature enabled via `experimental.skills`. You
|
||||
can also search for "Skills" within the `/settings` interactive UI to toggle
|
||||
this and manage other skill-related settings._
|
||||
|
||||
Agent Skills allow you to extend Gemini CLI with specialized expertise,
|
||||
procedural workflows, and task-specific resources. Based on the
|
||||
[Agent Skills](https://agentskills.io) open standard, a "skill" is a
|
||||
|
||||
@@ -1,37 +1,10 @@
|
||||
# Getting Started with Agent Skills
|
||||
|
||||
Agent Skills allow you to extend Gemini CLI with specialized expertise. This
|
||||
tutorial will guide you through creating your first skill, enabling it, and
|
||||
using it in a session.
|
||||
tutorial will guide you through creating your first skill and using it in a
|
||||
session.
|
||||
|
||||
## 1. Enable Agent Skills
|
||||
|
||||
Agent Skills are currently an experimental feature and must be enabled in your
|
||||
settings.
|
||||
|
||||
### Via the interactive UI
|
||||
|
||||
1. Start a Gemini CLI session by running `gemini`.
|
||||
2. Type `/settings` to open the interactive settings dialog.
|
||||
3. Search for "Skills".
|
||||
4. Toggle **Agent Skills** to `true`.
|
||||
5. Press `Esc` to save and exit. You may need to restart the CLI for the
|
||||
changes to take effect.
|
||||
|
||||
### Via `settings.json`
|
||||
|
||||
Alternatively, you can manually edit your global settings file at
|
||||
`~/.gemini/settings.json` (create it if it doesn't exist):
|
||||
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"skills": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Create Your First Skill
|
||||
## 1. Create your first skill
|
||||
|
||||
A skill is a directory containing a `SKILL.md` file. Let's create an **API
|
||||
Auditor** skill that helps you verify if local or remote endpoints are
|
||||
@@ -86,7 +59,7 @@ responding correctly.
|
||||
.catch((e) => console.error(`Result: Failed (${e.message})`));
|
||||
```
|
||||
|
||||
## 3. Verify the Skill is Discovered
|
||||
## 2. Verify the skill is discovered
|
||||
|
||||
Use the `/skills` slash command (or `gemini skills list` from your terminal) to
|
||||
see if Gemini CLI has found your new skill.
|
||||
@@ -99,7 +72,7 @@ In a Gemini CLI session:
|
||||
|
||||
You should see `api-auditor` in the list of available skills.
|
||||
|
||||
## 4. Use the Skill in a Chat
|
||||
## 3. Use the skill in a chat
|
||||
|
||||
Now, let's see the skill in action. Start a new session and ask a question about
|
||||
an endpoint.
|
||||
|
||||
@@ -7,6 +7,8 @@ requests sent from `packages/cli`. For a general overview of Gemini CLI, see the
|
||||
|
||||
## Navigating this section
|
||||
|
||||
- **[Sub-agents (experimental)](./subagents.md):** Learn how to create and use
|
||||
specialized sub-agents for complex tasks.
|
||||
- **[Core tools API](./tools-api.md):** Information on how tools are defined,
|
||||
registered, and used by the core.
|
||||
- **[Memory Import Processor](./memport.md):** Documentation for the modular
|
||||
|
||||
@@ -146,6 +146,38 @@ A rule matches a tool call if all of its conditions are met:
|
||||
Policies are defined in `.toml` files. The CLI loads these files from Default,
|
||||
User, and (if configured) Admin directories.
|
||||
|
||||
### Policy locations
|
||||
|
||||
| Tier | Type | Location |
|
||||
| :-------- | :----- | :-------------------------- |
|
||||
| **User** | Custom | `~/.gemini/policies/*.toml` |
|
||||
| **Admin** | System | _See below (OS specific)_ |
|
||||
|
||||
#### System-wide policies (Admin)
|
||||
|
||||
Administrators can enforce system-wide policies (Tier 3) that override all user
|
||||
and default settings. These policies must be placed in specific, secure
|
||||
directories:
|
||||
|
||||
| OS | Policy Directory Path |
|
||||
| :---------- | :------------------------------------------------ |
|
||||
| **Linux** | `/etc/gemini-cli/policies` |
|
||||
| **macOS** | `/Library/Application Support/GeminiCli/policies` |
|
||||
| **Windows** | `C:\ProgramData\gemini-cli\policies` |
|
||||
|
||||
**Security Requirements:**
|
||||
|
||||
To prevent privilege escalation, the CLI enforces strict security checks on
|
||||
admin directories. If checks fail, system policies are **ignored**.
|
||||
|
||||
- **Linux / macOS:** Must be owned by `root` (UID 0) and NOT writable by group
|
||||
or others (e.g., `chmod 755`).
|
||||
- **Windows:** Must be in `C:\ProgramData`. Standard users (`Users`, `Everyone`)
|
||||
must NOT have `Write`, `Modify`, or `Full Control` permissions. _Tip: If you
|
||||
see a security warning, use the folder properties to remove write permissions
|
||||
for non-admin groups. You may need to "Disable inheritance" in Advanced
|
||||
Security Settings._
|
||||
|
||||
### TOML rule schema
|
||||
|
||||
Here is a breakdown of the fields available in a TOML policy rule:
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Remote Subagents (experimental)
|
||||
|
||||
Gemini CLI supports connecting to remote subagents using the Agent-to-Agent
|
||||
(A2A) protocol. This allows Gemini CLI to interact with other agents, expanding
|
||||
its capabilities by delegating tasks to remote services.
|
||||
|
||||
Gemini CLI can connect to any compliant A2A agent. You can find samples of A2A
|
||||
agents in the following repositories:
|
||||
|
||||
- [ADK Samples (Python)](https://github.com/google/adk-samples/tree/main/python)
|
||||
- [ADK Python Contributing Samples](https://github.com/google/adk-python/tree/main/contributing/samples)
|
||||
|
||||
> **Note: Remote subagents are currently an experimental feature.**
|
||||
|
||||
## Configuration
|
||||
|
||||
To use remote subagents, you must explicitly enable them in your
|
||||
`settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"enableAgents": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Defining remote subagents
|
||||
|
||||
Remote subagents are defined as Markdown files (`.md`) with YAML frontmatter.
|
||||
You can place them in:
|
||||
|
||||
1. **Project-level:** `.gemini/agents/*.md` (Shared with your team)
|
||||
2. **User-level:** `~/.gemini/agents/*.md` (Personal agents)
|
||||
|
||||
### Configuration schema
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :--------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------- |
|
||||
| `kind` | string | Yes | Must be `remote`. |
|
||||
| `name` | string | Yes | A unique name for the agent. Must be a valid slug (lowercase letters, numbers, hyphens, and underscores only). |
|
||||
| `agent_card_url` | string | Yes | The URL to the agent's A2A card endpoint. |
|
||||
|
||||
### Single-subagent example
|
||||
|
||||
```markdown
|
||||
---
|
||||
kind: remote
|
||||
name: my-remote-agent
|
||||
agent_card_url: https://example.com/agent-card
|
||||
---
|
||||
```
|
||||
|
||||
### Multi-subagent example
|
||||
|
||||
The loader explicitly supports multiple remote subagents defined in a single
|
||||
Markdown file.
|
||||
|
||||
```markdown
|
||||
---
|
||||
- kind: remote
|
||||
name: remote-1
|
||||
agent_card_url: https://example.com/1
|
||||
- kind: remote
|
||||
name: remote-2
|
||||
agent_card_url: https://example.com/2
|
||||
---
|
||||
```
|
||||
|
||||
> **Note:** Mixed local and remote agents, or multiple local agents, are not
|
||||
> supported in a single file; the list format is currently remote-only.
|
||||
|
||||
## Managing Subagents
|
||||
|
||||
Users can manage subagents using the following commands within the Gemini CLI:
|
||||
|
||||
- `/agents list`: Displays all available local and remote subagents.
|
||||
- `/agents refresh`: Reloads the agent registry. Use this after adding or
|
||||
modifying agent definition files.
|
||||
- `/agents enable <agent_name>`: Enables a specific subagent.
|
||||
- `/agents disable <agent_name>`: Disables a specific subagent.
|
||||
|
||||
> **Tip:** You can use the `@cli_help` agent within Gemini CLI for assistance
|
||||
> with configuring subagents.
|
||||
@@ -0,0 +1,186 @@
|
||||
# Sub-agents (experimental)
|
||||
|
||||
Sub-agents are specialized agents that operate within your main Gemini CLI
|
||||
session. They are designed to handle specific, complex tasks—like deep codebase
|
||||
analysis, documentation lookup, or domain-specific reasoning—without cluttering
|
||||
the main agent's context or toolset.
|
||||
|
||||
> **Note: Sub-agents are currently an experimental feature.**
|
||||
>
|
||||
> To use custom sub-agents, you must explicitly enable them in your
|
||||
> `settings.json`:
|
||||
>
|
||||
> ```json
|
||||
> {
|
||||
> "experimental": { "enableAgents": true }
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **Warning:** Sub-agents currently operate in
|
||||
> ["YOLO mode"](../get-started/configuration.md#command-line-arguments), meaning
|
||||
> they may execute tools without individual user confirmation for each step.
|
||||
> Proceed with caution when defining agents with powerful tools like
|
||||
> `run_shell_command` or `write_file`.
|
||||
|
||||
## What are sub-agents?
|
||||
|
||||
Think of sub-agents as "specialists" that the main Gemini agent can hire for a
|
||||
specific job.
|
||||
|
||||
- **Focused context:** Each sub-agent has its own system prompt and persona.
|
||||
- **Specialized tools:** Sub-agents can have a restricted or specialized set of
|
||||
tools.
|
||||
- **Independent context window:** Interactions with a sub-agent happen in a
|
||||
separate context loop. The main agent only sees the final result, saving
|
||||
tokens in your main conversation history.
|
||||
|
||||
Sub-agents are exposed to the main agent as a tool of the same name which
|
||||
delegates to the sub-agent, when called. Once the sub-agent completes its task
|
||||
(or fails), it reports back to the main agent with its findings (usually as a
|
||||
text summary or structured report returned by the tool).
|
||||
|
||||
## Built-in sub-agents
|
||||
|
||||
Gemini CLI comes with powerful built-in sub-agents.
|
||||
|
||||
### Codebase Investigator
|
||||
|
||||
- **Name:** `codebase_investigator`
|
||||
- **Purpose:** Deep analysis of the codebase, reverse engineering, and
|
||||
understanding complex dependencies.
|
||||
- **When to use:** "How does the authentication system work?", "Map out the
|
||||
dependencies of the `AgentRegistry` class."
|
||||
- **Configuration:** Enabled by default. You can configure it in
|
||||
`settings.json`. Example (forcing a specific model):
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"codebaseInvestigatorSettings": {
|
||||
"enabled": true,
|
||||
"maxNumTurns": 20,
|
||||
"model": "gemini-2.5-pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Help Agent
|
||||
|
||||
- **Name:** `cli_help`
|
||||
- **Purpose:** Expert knowledge about Gemini CLI itself, its commands,
|
||||
configuration, and documentation.
|
||||
- **When to use:** "How do I configure a proxy?", "What does the `/rewind`
|
||||
command do?"
|
||||
- **Configuration:** Enabled by default.
|
||||
|
||||
## Creating custom sub-agents
|
||||
|
||||
You can create your own sub-agents to automate specific workflows or enforce
|
||||
specific personas.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
To use custom sub-agents, you must enable them in your `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"enableAgents": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Agent definition files
|
||||
|
||||
Custom agents are defined as Markdown files (`.md`) with YAML frontmatter. You
|
||||
can place them in:
|
||||
|
||||
1. **Project-level:** `.gemini/agents/*.md` (Shared with your team)
|
||||
2. **User-level:** `~/.gemini/agents/*.md` (Personal agents)
|
||||
|
||||
### File format
|
||||
|
||||
The file **MUST** start with YAML frontmatter enclosed in triple-dashes `---`.
|
||||
The body of the markdown file becomes the agent's **System Prompt**.
|
||||
|
||||
**Example: `.gemini/agents/security-auditor.md`**
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: security-auditor
|
||||
description: Specialized in finding security vulnerabilities in code.
|
||||
kind: local
|
||||
tools:
|
||||
- read_file
|
||||
- search_file_content
|
||||
model: gemini-2.5-pro
|
||||
temperature: 0.2
|
||||
max_turns: 10
|
||||
---
|
||||
|
||||
You are a ruthless Security Auditor. Your job is to analyze code for potential
|
||||
vulnerabilities.
|
||||
|
||||
Focus on:
|
||||
|
||||
1. SQL Injection
|
||||
2. XSS (Cross-Site Scripting)
|
||||
3. Hardcoded credentials
|
||||
4. Unsafe file operations
|
||||
|
||||
When you find a vulnerability, explain it clearly and suggest a fix. Do not fix
|
||||
it yourself; just report it.
|
||||
```
|
||||
|
||||
### Configuration schema
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `name` | string | Yes | Unique identifier (slug) used as the tool name for the agent. Only lowercase letters, numbers, hyphens, and underscores. |
|
||||
| `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this sub-agent. |
|
||||
| `kind` | string | No | `local` (default) or `remote`. |
|
||||
| `tools` | array | No | List of tool names this agent can use. If omitted, it may have access to a default set. |
|
||||
| `model` | string | No | Specific model to use (e.g., `gemini-2.5-pro`). Defaults to `inherit` (uses the main session model). |
|
||||
| `temperature` | number | No | Model temperature (0.0 - 2.0). |
|
||||
| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. |
|
||||
| `timeout_mins` | number | No | Maximum execution time in minutes. |
|
||||
|
||||
### Optimizing your sub-agent
|
||||
|
||||
The main agent system prompt contains language that encourages use of an expert
|
||||
sub-agent when one is available for the task at hand. It decides whether an
|
||||
agent is a relevant expert based on the agent's description. You can improve the
|
||||
reliability with which an agent is used by updating the description to more
|
||||
clearly indicate:
|
||||
|
||||
- Its area of expertise.
|
||||
- When it should be used.
|
||||
- Some example scenarios.
|
||||
|
||||
For example: the following sub-agent description should be called fairly
|
||||
consistently for Git operations.
|
||||
|
||||
> Git expert agent which should be used for all local and remote git operations.
|
||||
> For example:
|
||||
>
|
||||
> - Making commits
|
||||
> - Searching for regressions with bisect
|
||||
> - Interacting with source control and issues providers such as GitHub.
|
||||
|
||||
If you need to further tune your sub-agent, you can do so by selecting the model
|
||||
to optimize for with `/model` and then asking the model why it does not think
|
||||
that your sub-agent was called with a specific prompt and the given description.
|
||||
|
||||
## Remote subagents (Agent2Agent)
|
||||
|
||||
Gemini CLI can also delegate tasks to remote subagents using the Agent-to-Agent
|
||||
(A2A) protocol.
|
||||
|
||||
See the [Remote Subagents documentation](/docs/core/remote-agents) for detailed
|
||||
configuration and usage instructions.
|
||||
|
||||
## Extension sub-agents
|
||||
|
||||
Extensions can bundle and distribute sub-agents. See the
|
||||
[Extensions documentation](../extensions/index.md#sub-agents) for details on how
|
||||
to package agents within an extension.
|
||||
@@ -1,9 +1,10 @@
|
||||
# Gemini CLI extensions
|
||||
|
||||
Gemini CLI extensions package prompts, MCP servers, custom commands, hooks, and
|
||||
agent skills into a familiar and user-friendly format. With extensions, you can
|
||||
expand the capabilities of Gemini CLI and share those capabilities with others.
|
||||
They are designed to be easily installable and shareable.
|
||||
Gemini CLI extensions package prompts, MCP servers, custom commands, hooks,
|
||||
sub-agents, and agent skills into a familiar and user-friendly format. With
|
||||
extensions, you can expand the capabilities of Gemini CLI and share those
|
||||
capabilities with others. They are designed to be easily installable and
|
||||
shareable.
|
||||
|
||||
To see examples of extensions, you can browse a gallery of
|
||||
[Gemini CLI extensions](https://geminicli.com/extensions/browse/).
|
||||
|
||||
@@ -282,6 +282,29 @@ An extension with the following structure:
|
||||
|
||||
Will expose a `security-audit` skill that the model can activate.
|
||||
|
||||
### Sub-agents
|
||||
|
||||
> **Note: Sub-agents are currently an experimental feature.**
|
||||
|
||||
Extensions can provide [sub-agents](../core/subagents.md) that users can
|
||||
delegate tasks to.
|
||||
|
||||
To bundle sub-agents with your extension, create an `agents/` directory in your
|
||||
extension's root folder and add your agent definition files (`.md`) there.
|
||||
|
||||
**Example**
|
||||
|
||||
```
|
||||
.gemini/extensions/my-extension/
|
||||
├── gemini-extension.json
|
||||
└── agents/
|
||||
├── security-auditor.md
|
||||
└── database-expert.md
|
||||
```
|
||||
|
||||
Gemini CLI will automatically discover and load these agents when the extension
|
||||
is installed and enabled.
|
||||
|
||||
### Conflict resolution
|
||||
|
||||
Extension commands have the lowest precedence. When a conflict occurs with user
|
||||
@@ -299,9 +322,10 @@ For example, if both a user and the `gcp` extension define a `deploy` command:
|
||||
|
||||
## Variables
|
||||
|
||||
Gemini CLI extensions allow variable substitution in `gemini-extension.json`.
|
||||
This can be useful if e.g., you need the current directory to run an MCP server
|
||||
using an argument like `"args": ["${extensionPath}${/}dist${/}server.js"]`.
|
||||
Gemini CLI extensions allow variable substitution in both
|
||||
`gemini-extension.json` and `hooks/hooks.json`. This can be useful if e.g., you
|
||||
need the current directory to run an MCP server using an argument like
|
||||
`"args": ["${extensionPath}${/}dist${/}server.js"]`.
|
||||
|
||||
**Supported variables:**
|
||||
|
||||
|
||||
@@ -225,8 +225,6 @@ file in every session where the extension is active.
|
||||
|
||||
## (Optional) Step 6: Add an Agent Skill
|
||||
|
||||
_Note: This is an experimental feature enabled via `experimental.skills`._
|
||||
|
||||
[Agent Skills](../cli/skills.md) let you bundle specialized expertise and
|
||||
procedural workflows. Unlike `GEMINI.md`, which provides persistent context,
|
||||
skills are activated only when needed, saving context tokens.
|
||||
|
||||
@@ -244,16 +244,16 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Show the model name in the chat for each model turn.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.useFullWidth`** (boolean):
|
||||
- **Description:** Use the entire width of the terminal for output.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`ui.useAlternateBuffer`** (boolean):
|
||||
- **Description:** Use an alternate screen buffer for the UI, preserving shell
|
||||
history.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`ui.useBackgroundColor`** (boolean):
|
||||
- **Description:** Whether to use background colors in the UI.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`ui.incrementalRendering`** (boolean):
|
||||
- **Description:** Enable incremental rendering for the UI. This option will
|
||||
reduce flickering but may cause rendering artifacts. Only supported when
|
||||
@@ -261,6 +261,10 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`ui.showSpinner`** (boolean):
|
||||
- **Description:** Show the spinner during operations.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`ui.customWittyPhrases`** (array):
|
||||
- **Description:** Custom witty phrases to display during loading. When
|
||||
provided, the CLI cycles through these instead of the defaults.
|
||||
@@ -612,6 +616,14 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`context.fileFiltering.customIgnoreFilePaths`** (array):
|
||||
- **Description:** Additional ignore file paths to respect. These files take
|
||||
precedence over .geminiignore and .gitignore. Files earlier in the array
|
||||
take precedence over files later in the array, e.g. the first file takes
|
||||
precedence over the second one.
|
||||
- **Default:** `[]`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `tools`
|
||||
|
||||
- **`tools.sandbox`** (boolean | string):
|
||||
@@ -858,7 +870,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.skills`** (boolean):
|
||||
- **Description:** Enable Agent Skills (experimental).
|
||||
- **Description:** [Deprecated] Enable Agent Skills (experimental).
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
@@ -874,6 +886,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
#### `skills`
|
||||
|
||||
- **`skills.enabled`** (boolean):
|
||||
- **Description:** Enable Agent Skills.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`skills.disabled`** (array):
|
||||
- **Description:** List of disabled skills.
|
||||
- **Default:** `[]`
|
||||
@@ -885,6 +902,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Canonical toggle for the hooks system. When disabled, no
|
||||
hooks will be executed.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`hooksConfig.disabled`** (array):
|
||||
- **Description:** List of hook names (commands) that should be disabled.
|
||||
|
||||
+10
-24
@@ -1,32 +1,9 @@
|
||||
# Gemini CLI hooks (experimental)
|
||||
# Gemini CLI hooks
|
||||
|
||||
Hooks are scripts or programs that Gemini CLI executes at specific points in the
|
||||
agentic loop, allowing you to intercept and customize behavior without modifying
|
||||
the CLI's source code.
|
||||
|
||||
## Availability
|
||||
|
||||
> **Experimental Feature**: Hooks are currently enabled by default only in the
|
||||
> **Preview** and **Nightly** release channels.
|
||||
|
||||
If you are on the Stable channel, you must explicitly enable the hooks system in
|
||||
your `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooksConfig": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **[Writing hooks guide](/docs/hooks/writing-hooks)**: A tutorial on creating
|
||||
your first hook with comprehensive examples.
|
||||
- **[Hooks reference](/docs/hooks/reference)**: The definitive technical
|
||||
specification of I/O schemas and exit codes.
|
||||
- **[Best practices](/docs/hooks/best-practices)**: Guidelines on security,
|
||||
performance, and debugging.
|
||||
|
||||
## What are hooks?
|
||||
|
||||
Hooks run synchronously as part of the agent loop—when a hook event fires,
|
||||
@@ -43,6 +20,15 @@ With hooks, you can:
|
||||
- **Optimize behavior:** Dynamically filter available tools or adjust model
|
||||
parameters.
|
||||
|
||||
### Getting started
|
||||
|
||||
- **[Writing hooks guide](/docs/hooks/writing-hooks)**: A tutorial on creating
|
||||
your first hook with comprehensive examples.
|
||||
- **[Best practices](/docs/hooks/best-practices)**: Guidelines on security,
|
||||
performance, and debugging.
|
||||
- **[Hooks reference](/docs/hooks/reference)**: The definitive technical
|
||||
specification of I/O schemas and exit codes.
|
||||
|
||||
## Core concepts
|
||||
|
||||
### Hook events
|
||||
|
||||
+2
-2
@@ -56,8 +56,8 @@ This documentation is organized into the following sections:
|
||||
commands with `/model`.
|
||||
- **[Sandbox](./cli/sandbox.md):** Isolate tool execution in a secure,
|
||||
containerized environment.
|
||||
- **[Agent Skills](./cli/skills.md):** (Experimental) Extend the CLI with
|
||||
specialized expertise and procedural workflows.
|
||||
- **[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.
|
||||
|
||||
+71
-236
@@ -1,187 +1,52 @@
|
||||
[
|
||||
{
|
||||
"label": "Overview",
|
||||
"items": [
|
||||
{
|
||||
"label": "Introduction",
|
||||
"slug": "docs"
|
||||
},
|
||||
{
|
||||
"label": "Architecture overview",
|
||||
"slug": "docs/architecture"
|
||||
},
|
||||
{
|
||||
"label": "Contribution guide",
|
||||
"slug": "docs/contributing"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Get started",
|
||||
"items": [
|
||||
{
|
||||
"label": "Gemini CLI quickstart",
|
||||
"slug": "docs/get-started"
|
||||
},
|
||||
{
|
||||
"label": "Gemini 3 on Gemini CLI",
|
||||
"slug": "docs/get-started/gemini-3"
|
||||
},
|
||||
{
|
||||
"label": "Authentication",
|
||||
"slug": "docs/get-started/authentication"
|
||||
},
|
||||
{
|
||||
"label": "Configuration",
|
||||
"slug": "docs/get-started/configuration"
|
||||
},
|
||||
{
|
||||
"label": "Installation",
|
||||
"slug": "docs/get-started/installation"
|
||||
},
|
||||
{
|
||||
"label": "Examples",
|
||||
"slug": "docs/get-started/examples"
|
||||
}
|
||||
{ "label": "Overview", "slug": "docs" },
|
||||
{ "label": "Quickstart", "slug": "docs/get-started" },
|
||||
{ "label": "Installation", "slug": "docs/get-started/installation" },
|
||||
{ "label": "Authentication", "slug": "docs/get-started/authentication" },
|
||||
{ "label": "Examples", "slug": "docs/get-started/examples" },
|
||||
{ "label": "Gemini 3 (preview)", "slug": "docs/get-started/gemini-3" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "CLI",
|
||||
"label": "Use Gemini CLI",
|
||||
"items": [
|
||||
{
|
||||
"label": "Introduction",
|
||||
"slug": "docs/cli"
|
||||
},
|
||||
{
|
||||
"label": "Commands",
|
||||
"slug": "docs/cli/commands"
|
||||
},
|
||||
{
|
||||
"label": "Checkpointing",
|
||||
"slug": "docs/cli/checkpointing"
|
||||
},
|
||||
{
|
||||
"label": "Custom commands",
|
||||
"slug": "docs/cli/custom-commands"
|
||||
},
|
||||
{
|
||||
"label": "Enterprise",
|
||||
"slug": "docs/cli/enterprise"
|
||||
},
|
||||
{
|
||||
"label": "Headless mode",
|
||||
"slug": "docs/cli/headless"
|
||||
},
|
||||
{
|
||||
"label": "Keyboard shortcuts",
|
||||
"slug": "docs/cli/keyboard-shortcuts"
|
||||
},
|
||||
{
|
||||
"label": "Model selection",
|
||||
"slug": "docs/cli/model"
|
||||
},
|
||||
{
|
||||
"label": "Sandbox",
|
||||
"slug": "docs/cli/sandbox"
|
||||
},
|
||||
{
|
||||
"label": "Session Management",
|
||||
"slug": "docs/cli/session-management"
|
||||
},
|
||||
{
|
||||
"label": "Agent Skills",
|
||||
"slug": "docs/cli/skills"
|
||||
},
|
||||
{
|
||||
"label": "Settings",
|
||||
"slug": "docs/cli/settings"
|
||||
},
|
||||
{
|
||||
"label": "Telemetry",
|
||||
"slug": "docs/cli/telemetry"
|
||||
},
|
||||
{
|
||||
"label": "Themes",
|
||||
"slug": "docs/cli/themes"
|
||||
},
|
||||
{
|
||||
"label": "Token caching",
|
||||
"slug": "docs/cli/token-caching"
|
||||
},
|
||||
{
|
||||
"label": "Trusted Folders",
|
||||
"slug": "docs/cli/trusted-folders"
|
||||
},
|
||||
{
|
||||
"label": "Tutorials",
|
||||
"slug": "docs/cli/tutorials"
|
||||
},
|
||||
{
|
||||
"label": "Uninstall",
|
||||
"slug": "docs/cli/uninstall"
|
||||
},
|
||||
{
|
||||
"label": "System prompt override",
|
||||
"slug": "docs/cli/system-prompt"
|
||||
}
|
||||
{ "label": "Using the CLI", "slug": "docs/cli" },
|
||||
{ "label": "File management", "slug": "docs/tools/file-system" },
|
||||
{ "label": "Memory management", "slug": "docs/tools/memory" },
|
||||
{ "label": "Project context (GEMINI.md)", "slug": "docs/cli/gemini-md" },
|
||||
{ "label": "Shell commands", "slug": "docs/tools/shell" },
|
||||
{ "label": "Session management", "slug": "docs/cli/session-management" },
|
||||
{ "label": "Todos", "slug": "docs/tools/todos" },
|
||||
{ "label": "Web search and fetch", "slug": "docs/tools/web-search" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Core",
|
||||
"label": "Configuration",
|
||||
"items": [
|
||||
{
|
||||
"label": "Introduction",
|
||||
"slug": "docs/core"
|
||||
"label": "Ignore files (.geminiignore)",
|
||||
"slug": "docs/cli/gemini-ignore"
|
||||
},
|
||||
{
|
||||
"label": "Tools API",
|
||||
"slug": "docs/core/tools-api"
|
||||
},
|
||||
{
|
||||
"label": "Memory Import Processor",
|
||||
"slug": "docs/core/memport"
|
||||
},
|
||||
{
|
||||
"label": "Policy Engine",
|
||||
"slug": "docs/core/policy-engine"
|
||||
}
|
||||
{ "label": "Model selection", "slug": "docs/cli/model" },
|
||||
{ "label": "Settings", "slug": "docs/cli/settings" },
|
||||
{ "label": "Themes", "slug": "docs/cli/themes" },
|
||||
{ "label": "Token caching", "slug": "docs/cli/token-caching" },
|
||||
{ "label": "Trusted folders", "slug": "docs/cli/trusted-folders" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Tools",
|
||||
"label": "Advanced features",
|
||||
"items": [
|
||||
{
|
||||
"label": "Introduction",
|
||||
"slug": "docs/tools"
|
||||
},
|
||||
{
|
||||
"label": "File system",
|
||||
"slug": "docs/tools/file-system"
|
||||
},
|
||||
{
|
||||
"label": "Shell",
|
||||
"slug": "docs/tools/shell"
|
||||
},
|
||||
{
|
||||
"label": "Web fetch",
|
||||
"slug": "docs/tools/web-fetch"
|
||||
},
|
||||
{
|
||||
"label": "Web search",
|
||||
"slug": "docs/tools/web-search"
|
||||
},
|
||||
{
|
||||
"label": "Memory",
|
||||
"slug": "docs/tools/memory"
|
||||
},
|
||||
{
|
||||
"label": "Todos",
|
||||
"slug": "docs/tools/todos"
|
||||
},
|
||||
{
|
||||
"label": "MCP servers",
|
||||
"slug": "docs/tools/mcp-server"
|
||||
}
|
||||
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
|
||||
{ "label": "Custom commands", "slug": "docs/cli/custom-commands" },
|
||||
{ "label": "Enterprise features", "slug": "docs/cli/enterprise" },
|
||||
{ "label": "Headless mode & scripting", "slug": "docs/cli/headless" },
|
||||
{ "label": "Sandboxing", "slug": "docs/cli/sandbox" },
|
||||
{ "label": "System prompt override", "slug": "docs/cli/system-prompt" },
|
||||
{ "label": "Telemetry", "slug": "docs/cli/telemetry" }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -210,96 +75,66 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Hooks (experimental)",
|
||||
"label": "Ecosystem and extensibility",
|
||||
"items": [
|
||||
{ "label": "Agent skills", "slug": "docs/cli/skills" },
|
||||
{
|
||||
"label": "Introduction",
|
||||
"slug": "docs/hooks"
|
||||
"label": "Sub-agents (experimental)",
|
||||
"slug": "docs/core/subagents"
|
||||
},
|
||||
{
|
||||
"label": "Writing hooks",
|
||||
"slug": "docs/hooks/writing-hooks"
|
||||
"label": "Remote subagents (experimental)",
|
||||
"slug": "docs/core/remote-agents"
|
||||
},
|
||||
{
|
||||
"label": "Hooks reference",
|
||||
"slug": "docs/hooks/reference"
|
||||
},
|
||||
{
|
||||
"label": "Best practices",
|
||||
"slug": "docs/hooks/best-practices"
|
||||
}
|
||||
{ "label": "Hooks", "slug": "docs/hooks" },
|
||||
{ "label": "IDE integration", "slug": "docs/ide-integration" },
|
||||
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "IDE integration",
|
||||
"label": "Tutorials",
|
||||
"items": [
|
||||
{
|
||||
"label": "Introduction",
|
||||
"slug": "docs/ide-integration"
|
||||
"label": "Get started with extensions",
|
||||
"slug": "docs/extensions/writing-extensions"
|
||||
},
|
||||
{
|
||||
"label": "IDE companion spec",
|
||||
"slug": "docs/ide-integration/ide-companion-spec"
|
||||
}
|
||||
{ "label": "How to write hooks", "slug": "docs/hooks/writing-hooks" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Reference",
|
||||
"items": [
|
||||
{ "label": "Architecture", "slug": "docs/architecture" },
|
||||
{ "label": "Command reference", "slug": "docs/cli/commands" },
|
||||
{ "label": "Configuration", "slug": "docs/get-started/configuration" },
|
||||
{ "label": "Keyboard shortcuts", "slug": "docs/cli/keyboard-shortcuts" },
|
||||
{ "label": "Memory import processor", "slug": "docs/core/memport" },
|
||||
{ "label": "Policy engine", "slug": "docs/core/policy-engine" },
|
||||
{ "label": "Tools API", "slug": "docs/core/tools-api" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Resources",
|
||||
"items": [
|
||||
{ "label": "FAQ", "slug": "docs/faq" },
|
||||
{ "label": "Quota and pricing", "slug": "docs/quota-and-pricing" },
|
||||
{ "label": "Release notes", "slug": "docs/changelogs/" },
|
||||
{ "label": "Terms and privacy", "slug": "docs/tos-privacy" },
|
||||
{ "label": "Troubleshooting", "slug": "docs/troubleshooting" },
|
||||
{ "label": "Uninstall", "slug": "docs/cli/uninstall" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Development",
|
||||
"items": [
|
||||
{
|
||||
"label": "NPM",
|
||||
"slug": "docs/npm"
|
||||
},
|
||||
{
|
||||
"label": "Releases",
|
||||
"slug": "docs/releases"
|
||||
},
|
||||
{
|
||||
"label": "Integration tests",
|
||||
"slug": "docs/integration-tests"
|
||||
},
|
||||
{ "label": "Contribution guide", "slug": "docs/contributing" },
|
||||
{ "label": "Integration testing", "slug": "docs/integration-tests" },
|
||||
{
|
||||
"label": "Issue and PR automation",
|
||||
"slug": "docs/issue-and-pr-automation"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Releases",
|
||||
"items": [
|
||||
{
|
||||
"label": "Release notes",
|
||||
"slug": "docs/changelogs/"
|
||||
},
|
||||
{
|
||||
"label": "Latest release",
|
||||
"slug": "docs/changelogs/latest"
|
||||
},
|
||||
{
|
||||
"label": "Preview release",
|
||||
"slug": "docs/changelogs/preview"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Support",
|
||||
"items": [
|
||||
{
|
||||
"label": "FAQ",
|
||||
"slug": "docs/faq"
|
||||
},
|
||||
{
|
||||
"label": "Troubleshooting",
|
||||
"slug": "docs/troubleshooting"
|
||||
},
|
||||
{
|
||||
"label": "Quota and pricing",
|
||||
"slug": "docs/quota-and-pricing"
|
||||
},
|
||||
{
|
||||
"label": "Terms of service",
|
||||
"slug": "docs/tos-privacy"
|
||||
}
|
||||
{ "label": "Local development", "slug": "docs/local-development" },
|
||||
{ "label": "NPM package structure", "slug": "docs/npm" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
+3
-3
@@ -91,8 +91,8 @@ Additionally, these tools incorporate:
|
||||
|
||||
- **[MCP servers](./mcp-server.md)**: MCP servers act as a bridge between the
|
||||
Gemini model and your local environment or other services like APIs.
|
||||
- **[Agent Skills](../cli/skills.md)**: (Experimental) On-demand expertise
|
||||
packages that are activated via the `activate_skill` tool to provide
|
||||
specialized guidance and resources.
|
||||
- **[Agent Skills](../cli/skills.md)**: On-demand expertise packages that are
|
||||
activated via the `activate_skill` tool to provide specialized guidance and
|
||||
resources.
|
||||
- **[Sandboxing](../cli/sandbox.md)**: Sandboxing isolates the model and its
|
||||
changes from your environment to reduce potential risk.
|
||||
|
||||
@@ -34,9 +34,6 @@ topics on:
|
||||
list of supported locations, see the following pages:
|
||||
- Gemini Code Assist for individuals:
|
||||
[Available locations](https://developers.google.com/gemini-code-assist/resources/available-locations#americas)
|
||||
- Google AI Pro and Ultra where Gemini Code Assist (and Gemini CLI) is also
|
||||
available:
|
||||
[Available locations](https://developers.google.com/gemini-code-assist/resources/locations-pro-ultra)
|
||||
|
||||
- **Error: `Failed to login. Message: Request contains an invalid argument`**
|
||||
- **Cause:** Users with Google Workspace accounts or Google Cloud accounts
|
||||
|
||||
+45
-3
@@ -144,6 +144,48 @@ A significant drop in the pass rate for a `USUALLY_PASSES` test—even if it
|
||||
doesn't drop to 0%—often indicates that a recent change to a system prompt or
|
||||
tool definition has made the model's behavior less reliable.
|
||||
|
||||
You may be able to investigate the regression using Gemini CLI by giving it the
|
||||
link to the runs before and after the change and the name of the test and asking
|
||||
it to investigate what changes may have impacted the test.
|
||||
## Fixing Evaluations
|
||||
|
||||
If an evaluation is failing or has a regressed pass rate, you can use the
|
||||
`/fix-behavioral-eval` command within Gemini CLI to help investigate and fix the
|
||||
issue.
|
||||
|
||||
### `/fix-behavioral-eval`
|
||||
|
||||
This command is designed to automate the investigation and fixing process for
|
||||
failing evaluations. It will:
|
||||
|
||||
1. **Investigate**: Fetch the latest results from the nightly workflow using
|
||||
the `gh` CLI, identify the failing test, and review test trajectory logs in
|
||||
`evals/logs`.
|
||||
2. **Fix**: Suggest and apply targeted fixes to the prompt or tool definitions.
|
||||
It prioritizes minimal changes to `prompt.ts`, tool instructions, and
|
||||
modules that contribute to the prompt. It generally tries to avoid changing
|
||||
the test itself.
|
||||
3. **Verify**: Re-run the test 3 times across multiple models (e.g., Gemini
|
||||
3.0, Gemini 3 Flash, Gemini 2.5 Pro) to ensure stability and calculate a
|
||||
success rate.
|
||||
4. **Report**: Provide a summary of the success rate for each model and details
|
||||
on the applied fixes.
|
||||
|
||||
To use it, run:
|
||||
|
||||
```bash
|
||||
gemini /fix-behavioral-eval
|
||||
```
|
||||
|
||||
You can also provide a link to a specific GitHub Action run or the name of a
|
||||
specific test to focus the investigation:
|
||||
|
||||
```bash
|
||||
gemini /fix-behavioral-eval https://github.com/google-gemini/gemini-cli/actions/runs/123456789
|
||||
```
|
||||
|
||||
When investigating failures manually, you can also enable verbose agent logs by
|
||||
setting the `GEMINI_DEBUG_LOG_FILE` environment variable.
|
||||
|
||||
It's highly recommended to manually review and/or ask the agent to iterate on
|
||||
any prompt changes, even if they pass all evals. The prompt should prefer
|
||||
positive traits ('do X') and resort to negative traits ('do not do X') only when
|
||||
unable to accomplish the goal with positive traits. Gemini is quite good at
|
||||
instrospecting on its prompt when asked the right questions.
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
import { EDIT_TOOL_NAMES } from '@google/gemini-cli-core';
|
||||
|
||||
const FILES = {
|
||||
'app.ts': 'const add = (a: number, b: number) => a - b;',
|
||||
'package.json': '{"name": "test-app", "version": "1.0.0"}',
|
||||
} as const;
|
||||
|
||||
describe('Answer vs. ask eval', () => {
|
||||
/**
|
||||
* Ensures that when the user asks to "inspect" for bugs, the agent does NOT
|
||||
* automatically modify the file, but instead asks for permission.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should not edit files when asked to inspect for bugs',
|
||||
prompt: 'Inspect app.ts for bugs',
|
||||
files: FILES,
|
||||
assert: async (rig, result) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
// Verify NO edit tools called
|
||||
const editCalls = toolLogs.filter((log) =>
|
||||
EDIT_TOOL_NAMES.has(log.toolRequest.name),
|
||||
);
|
||||
expect(editCalls.length).toBe(0);
|
||||
|
||||
// Verify file unchanged
|
||||
const content = rig.readFile('app.ts');
|
||||
expect(content).toContain('a - b');
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Ensures that when the user explicitly asks to "fix" a bug, the agent
|
||||
* does modify the file.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should edit files when asked to fix bug',
|
||||
prompt: 'Fix the bug in app.ts - it should add numbers not subtract',
|
||||
files: FILES,
|
||||
assert: async (rig) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
// Verify edit tools WERE called
|
||||
const editCalls = toolLogs.filter(
|
||||
(log) =>
|
||||
EDIT_TOOL_NAMES.has(log.toolRequest.name) && log.toolRequest.success,
|
||||
);
|
||||
expect(editCalls.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Verify file changed
|
||||
const content = rig.readFile('app.ts');
|
||||
expect(content).toContain('a + b');
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Ensures that when the user asks "any bugs?" the agent does NOT
|
||||
* automatically modify the file, but instead asks for permission.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should not edit when asking "any bugs"',
|
||||
prompt: 'Any bugs in app.ts?',
|
||||
files: FILES,
|
||||
assert: async (rig) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
// Verify NO edit tools called
|
||||
const editCalls = toolLogs.filter((log) =>
|
||||
EDIT_TOOL_NAMES.has(log.toolRequest.name),
|
||||
);
|
||||
expect(editCalls.length).toBe(0);
|
||||
|
||||
// Verify file unchanged
|
||||
const content = rig.readFile('app.ts');
|
||||
expect(content).toContain('a - b');
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Ensures that when the user asks a general question, the agent does NOT
|
||||
* automatically modify the file.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should not edit files when asked a general question',
|
||||
prompt: 'How does app.ts work?',
|
||||
files: FILES,
|
||||
assert: async (rig) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
// Verify NO edit tools called
|
||||
const editCalls = toolLogs.filter((log) =>
|
||||
EDIT_TOOL_NAMES.has(log.toolRequest.name),
|
||||
);
|
||||
expect(editCalls.length).toBe(0);
|
||||
|
||||
// Verify file unchanged
|
||||
const content = rig.readFile('app.ts');
|
||||
expect(content).toContain('a - b');
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Ensures that when the user asks a question about style, the agent does NOT
|
||||
* automatically modify the file.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should not edit files when asked about style',
|
||||
prompt: 'Is app.ts following good style?',
|
||||
files: FILES,
|
||||
assert: async (rig, result) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
// Verify NO edit tools called
|
||||
const editCalls = toolLogs.filter((log) =>
|
||||
EDIT_TOOL_NAMES.has(log.toolRequest.name),
|
||||
);
|
||||
expect(editCalls.length).toBe(0);
|
||||
|
||||
// Verify file unchanged
|
||||
const content = rig.readFile('app.ts');
|
||||
expect(content).toContain('a - b');
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Ensures that when the user points out an issue but doesn't ask for a fix,
|
||||
* the agent does NOT automatically modify the file.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should not edit files when user notes an issue',
|
||||
prompt: 'The add function subtracts numbers.',
|
||||
files: FILES,
|
||||
params: { timeout: 20000 }, // 20s timeout
|
||||
assert: async (rig) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
// Verify NO edit tools called
|
||||
const editCalls = toolLogs.filter((log) =>
|
||||
EDIT_TOOL_NAMES.has(log.toolRequest.name),
|
||||
);
|
||||
expect(editCalls.length).toBe(0);
|
||||
|
||||
// Verify file unchanged
|
||||
const content = rig.readFile('app.ts');
|
||||
expect(content).toContain('a - b');
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,18 @@ class MockConfig {
|
||||
getFileFilteringRespectGeminiIgnore() {
|
||||
return true;
|
||||
}
|
||||
|
||||
getFileFilteringOptions() {
|
||||
return {
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
customIgnoreFilePaths: [],
|
||||
};
|
||||
}
|
||||
|
||||
validatePathAccess() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
describe('ripgrep-real-direct', () => {
|
||||
|
||||
Generated
+12
-12
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"version": "0.27.0-preview.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"version": "0.27.0-preview.5",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.7",
|
||||
"ink": "npm:@jrichman/ink@6.4.8",
|
||||
"latest-version": "^9.0.0",
|
||||
"simple-git": "^3.28.0"
|
||||
},
|
||||
@@ -10537,9 +10537,9 @@
|
||||
},
|
||||
"node_modules/ink": {
|
||||
"name": "@jrichman/ink",
|
||||
"version": "6.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.7.tgz",
|
||||
"integrity": "sha512-QHyxhNF5VonF5cRmdAJD/UPucB9nRx3FozWMjQrDGfBxfAL9lpyu72/MlFPgloS1TMTGsOt7YN6dTPPA6mh0Aw==",
|
||||
"version": "6.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz",
|
||||
"integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||
@@ -17999,7 +17999,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"version": "0.27.0-preview.5",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.8",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -18055,7 +18055,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"version": "0.27.0-preview.5",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
@@ -18075,7 +18075,7 @@
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.4.7",
|
||||
"ink": "npm:@jrichman/ink@6.4.8",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
@@ -18142,7 +18142,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"version": "0.27.0-preview.5",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.8",
|
||||
@@ -18300,7 +18300,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"version": "0.27.0-preview.5",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18317,7 +18317,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"version": "0.27.0-preview.5",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"version": "0.27.0-preview.5",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.27.0-nightly.20260121.97aac696f"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.27.0-preview.5"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
@@ -64,7 +64,7 @@
|
||||
"pre-commit": "node scripts/pre-commit.js"
|
||||
},
|
||||
"overrides": {
|
||||
"ink": "npm:@jrichman/ink@6.4.7",
|
||||
"ink": "npm:@jrichman/ink@6.4.8",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"cliui": {
|
||||
"wrap-ansi": "7.0.0"
|
||||
@@ -124,7 +124,7 @@
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.7",
|
||||
"ink": "npm:@jrichman/ink@6.4.8",
|
||||
"latest-version": "^9.0.0",
|
||||
"simple-git": "^3.28.0"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"version": "0.27.0-preview.5",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -5,106 +5,127 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as path from 'node:path';
|
||||
import { loadConfig } from './config.js';
|
||||
import type { ExtensionLoader } from '@google/gemini-cli-core';
|
||||
import type { Settings } from './settings.js';
|
||||
import {
|
||||
type ExtensionLoader,
|
||||
FileDiscoveryService,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
const {
|
||||
mockLoadServerHierarchicalMemory,
|
||||
mockConfigConstructor,
|
||||
mockVerifyGitAvailability,
|
||||
} = vi.hoisted(() => ({
|
||||
mockLoadServerHierarchicalMemory: vi.fn().mockResolvedValue({
|
||||
memoryContent: '',
|
||||
fileCount: 0,
|
||||
filePaths: [],
|
||||
}),
|
||||
mockConfigConstructor: vi.fn(),
|
||||
mockVerifyGitAvailability: vi.fn(),
|
||||
}));
|
||||
// Mock dependencies
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
Config: vi.fn().mockImplementation((params) => ({
|
||||
initialize: vi.fn(),
|
||||
refreshAuth: vi.fn(),
|
||||
...params, // Expose params for assertion
|
||||
})),
|
||||
loadServerHierarchicalMemory: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ memoryContent: '', fileCount: 0, filePaths: [] }),
|
||||
startupProfiler: {
|
||||
flush: vi.fn(),
|
||||
},
|
||||
FileDiscoveryService: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async () => ({
|
||||
Config: class MockConfig {
|
||||
constructor(params: unknown) {
|
||||
mockConfigConstructor(params);
|
||||
}
|
||||
initialize = vi.fn();
|
||||
refreshAuth = vi.fn();
|
||||
},
|
||||
loadServerHierarchicalMemory: mockLoadServerHierarchicalMemory,
|
||||
startupProfiler: {
|
||||
flush: vi.fn(),
|
||||
},
|
||||
FileDiscoveryService: vi.fn(),
|
||||
ApprovalMode: { DEFAULT: 'default', YOLO: 'yolo' },
|
||||
AuthType: {
|
||||
LOGIN_WITH_GOOGLE: 'login_with_google',
|
||||
USE_GEMINI: 'use_gemini',
|
||||
},
|
||||
GEMINI_DIR: '.gemini',
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL: 'models/embedding-001',
|
||||
DEFAULT_GEMINI_MODEL: 'models/gemini-1.5-flash',
|
||||
PREVIEW_GEMINI_MODEL: 'models/gemini-1.5-pro-latest',
|
||||
homedir: () => '/tmp',
|
||||
GitService: {
|
||||
verifyGitAvailability: mockVerifyGitAvailability,
|
||||
vi.mock('../utils/logger.js', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('loadConfig', () => {
|
||||
const mockSettings = {
|
||||
checkpointing: { enabled: true },
|
||||
};
|
||||
const mockExtensionLoader = {
|
||||
start: vi.fn(),
|
||||
getExtensions: vi.fn().mockReturnValue([]),
|
||||
} as unknown as ExtensionLoader;
|
||||
const mockSettings = {} as Settings;
|
||||
const mockExtensionLoader = {} as ExtensionLoader;
|
||||
const taskId = 'test-task-id';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.clearAllMocks();
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
// Reset the mock return value just in case
|
||||
mockLoadServerHierarchicalMemory.mockResolvedValue({
|
||||
memoryContent: '',
|
||||
fileCount: 0,
|
||||
filePaths: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env['CUSTOM_IGNORE_FILE_PATHS'];
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
delete process.env['CHECKPOINTING'];
|
||||
});
|
||||
|
||||
it('should disable checkpointing if git is not installed', async () => {
|
||||
mockVerifyGitAvailability.mockResolvedValue(false);
|
||||
|
||||
await loadConfig(
|
||||
mockSettings as unknown as Settings,
|
||||
mockExtensionLoader,
|
||||
'test-task',
|
||||
);
|
||||
|
||||
expect(mockConfigConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
checkpointing: false,
|
||||
}),
|
||||
);
|
||||
it('should set customIgnoreFilePaths when CUSTOM_IGNORE_FILE_PATHS env var is present', async () => {
|
||||
const testPath = '/tmp/ignore';
|
||||
process.env['CUSTOM_IGNORE_FILE_PATHS'] = testPath;
|
||||
const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
|
||||
testPath,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should enable checkpointing if git is installed', async () => {
|
||||
mockVerifyGitAvailability.mockResolvedValue(true);
|
||||
it('should set customIgnoreFilePaths when settings.fileFiltering.customIgnoreFilePaths is present', async () => {
|
||||
const testPath = '/settings/ignore';
|
||||
const settings: Settings = {
|
||||
fileFiltering: {
|
||||
customIgnoreFilePaths: [testPath],
|
||||
},
|
||||
};
|
||||
const config = await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
|
||||
testPath,
|
||||
]);
|
||||
});
|
||||
|
||||
await loadConfig(
|
||||
mockSettings as unknown as Settings,
|
||||
mockExtensionLoader,
|
||||
'test-task',
|
||||
);
|
||||
it('should merge customIgnoreFilePaths from settings and env var', async () => {
|
||||
const envPath = '/env/ignore';
|
||||
const settingsPath = '/settings/ignore';
|
||||
process.env['CUSTOM_IGNORE_FILE_PATHS'] = envPath;
|
||||
const settings: Settings = {
|
||||
fileFiltering: {
|
||||
customIgnoreFilePaths: [settingsPath],
|
||||
},
|
||||
};
|
||||
const config = await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
|
||||
settingsPath,
|
||||
envPath,
|
||||
]);
|
||||
});
|
||||
|
||||
expect(mockConfigConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
checkpointing: true,
|
||||
}),
|
||||
);
|
||||
it('should split CUSTOM_IGNORE_FILE_PATHS using system delimiter', async () => {
|
||||
const paths = ['/path/one', '/path/two'];
|
||||
process.env['CUSTOM_IGNORE_FILE_PATHS'] = paths.join(path.delimiter);
|
||||
const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual(paths);
|
||||
});
|
||||
|
||||
it('should have empty customIgnoreFilePaths when both are missing', async () => {
|
||||
const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([]);
|
||||
});
|
||||
|
||||
it('should initialize FileDiscoveryService with correct options', async () => {
|
||||
const testPath = '/tmp/ignore';
|
||||
process.env['CUSTOM_IGNORE_FILE_PATHS'] = testPath;
|
||||
const settings: Settings = {
|
||||
fileFiltering: {
|
||||
respectGitIgnore: false,
|
||||
},
|
||||
};
|
||||
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(FileDiscoveryService).toHaveBeenCalledWith(expect.any(String), {
|
||||
respectGitIgnore: false,
|
||||
respectGeminiIgnore: undefined,
|
||||
customIgnoreFilePaths: [testPath],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,8 +86,15 @@ export async function loadConfig(
|
||||
// Git-aware file filtering settings
|
||||
fileFiltering: {
|
||||
respectGitIgnore: settings.fileFiltering?.respectGitIgnore,
|
||||
respectGeminiIgnore: settings.fileFiltering?.respectGeminiIgnore,
|
||||
enableRecursiveFileSearch:
|
||||
settings.fileFiltering?.enableRecursiveFileSearch,
|
||||
customIgnoreFilePaths: [
|
||||
...(settings.fileFiltering?.customIgnoreFilePaths || []),
|
||||
...(process.env['CUSTOM_IGNORE_FILE_PATHS']
|
||||
? process.env['CUSTOM_IGNORE_FILE_PATHS'].split(path.delimiter)
|
||||
: []),
|
||||
],
|
||||
},
|
||||
ideMode: false,
|
||||
folderTrust,
|
||||
@@ -100,7 +107,11 @@ export async function loadConfig(
|
||||
ptyInfo: 'auto',
|
||||
};
|
||||
|
||||
const fileService = new FileDiscoveryService(workspaceDir);
|
||||
const fileService = new FileDiscoveryService(workspaceDir, {
|
||||
respectGitIgnore: configParams?.fileFiltering?.respectGitIgnore,
|
||||
respectGeminiIgnore: configParams?.fileFiltering?.respectGeminiIgnore,
|
||||
customIgnoreFilePaths: configParams?.fileFiltering?.customIgnoreFilePaths,
|
||||
});
|
||||
const { memoryContent, fileCount, filePaths } =
|
||||
await loadServerHierarchicalMemory(
|
||||
workspaceDir,
|
||||
|
||||
@@ -38,7 +38,9 @@ export interface Settings {
|
||||
// Git-aware file filtering settings
|
||||
fileFiltering?: {
|
||||
respectGitIgnore?: boolean;
|
||||
respectGeminiIgnore?: boolean;
|
||||
enableRecursiveFileSearch?: boolean;
|
||||
customIgnoreFilePaths?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
## React & Ink (CLI UI)
|
||||
|
||||
- **Side Effects**: Use reducers for complex state transitions; avoid `setState`
|
||||
triggers in callbacks.
|
||||
- Always fix react-hooks/exhaustive-deps lint errors by adding the missing
|
||||
dependencies.
|
||||
- **Shortcuts**: only define keyboard shortcuts in
|
||||
`packages/cli/src/config/keyBindings.ts
|
||||
|
||||
## Testing
|
||||
|
||||
- **Utilities**: Use `renderWithProviders` and `waitFor` from
|
||||
`packages/cli/src/test-utils/`.
|
||||
- **Snapshots**: Use `toMatchSnapshot()` to verify Ink output.
|
||||
- **Mocks**: Use mocks as sparingly as possilble.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.27.0-nightly.20260121.97aac696f",
|
||||
"version": "0.27.0-preview.5",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -26,7 +26,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.27.0-nightly.20260121.97aac696f"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.27.0-preview.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
@@ -46,7 +46,7 @@
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.4.7",
|
||||
"ink": "npm:@jrichman/ink@6.4.8",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type ExtensionSetting,
|
||||
} from '../../config/extensions/extensionSettings.js';
|
||||
import prompts from 'prompts';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
const {
|
||||
mockExtensionManager,
|
||||
@@ -79,11 +80,15 @@ vi.mock('../../config/settings.js', () => ({
|
||||
}));
|
||||
|
||||
describe('extensions configure command', () => {
|
||||
let tempWorkspaceDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(debugLogger, 'log');
|
||||
vi.spyOn(debugLogger, 'error');
|
||||
vi.clearAllMocks();
|
||||
|
||||
tempWorkspaceDir = fs.mkdtempSync('gemini-cli-test-workspace');
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
|
||||
// Default behaviors
|
||||
mockLoadSettings.mockReturnValue({ merged: {} });
|
||||
mockGetExtensionAndManager.mockResolvedValue({
|
||||
@@ -141,6 +146,7 @@ describe('extensions configure command', () => {
|
||||
'TEST_VAR',
|
||||
promptForSetting,
|
||||
'user',
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -186,6 +192,7 @@ describe('extensions configure command', () => {
|
||||
'VAR_1',
|
||||
promptForSetting,
|
||||
'user',
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ async function configureSpecificSetting(
|
||||
settingKey,
|
||||
promptForSetting,
|
||||
scope,
|
||||
process.cwd(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -174,6 +175,7 @@ async function configureExtensionSettings(
|
||||
extensionConfig,
|
||||
extensionId,
|
||||
scope,
|
||||
process.cwd(),
|
||||
);
|
||||
|
||||
let workspaceSettings: Record<string, string> = {};
|
||||
@@ -182,6 +184,7 @@ async function configureExtensionSettings(
|
||||
extensionConfig,
|
||||
extensionId,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
process.cwd(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -216,6 +219,7 @@ async function configureExtensionSettings(
|
||||
setting.envVar,
|
||||
promptForSetting,
|
||||
scope,
|
||||
process.cwd(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,18 @@ vi.mock('../utils.js', () => ({
|
||||
exitCli: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockEnablementInstance = vi.hoisted(() => ({
|
||||
getDisplayState: vi.fn(),
|
||||
enable: vi.fn(),
|
||||
clearSessionDisable: vi.fn(),
|
||||
autoEnableServers: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../config/mcp/mcpServerEnablement.js', () => ({
|
||||
McpServerEnablementManager: {
|
||||
getInstance: () => mockEnablementInstance,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('extensions enable command', () => {
|
||||
const mockLoadSettings = vi.mocked(loadSettings);
|
||||
const mockExtensionManager = vi.mocked(ExtensionManager);
|
||||
@@ -75,6 +87,12 @@ describe('extensions enable command', () => {
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
mockExtensionManager.prototype.enableExtension = vi.fn();
|
||||
mockExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]);
|
||||
mockEnablementInstance.getDisplayState.mockReset();
|
||||
mockEnablementInstance.enable.mockReset();
|
||||
mockEnablementInstance.clearSessionDisable.mockReset();
|
||||
mockEnablementInstance.autoEnableServers.mockReset();
|
||||
mockEnablementInstance.autoEnableServers.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -134,6 +152,50 @@ describe('extensions enable command', () => {
|
||||
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should auto-enable disabled MCP servers for the extension', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
mockEnablementInstance.autoEnableServers.mockResolvedValue([
|
||||
'test-server',
|
||||
]);
|
||||
mockExtensionManager.prototype.getExtensions = vi
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
{ name: 'my-extension', mcpServers: { 'test-server': {} } },
|
||||
]);
|
||||
|
||||
await handleEnable({ name: 'my-extension' });
|
||||
|
||||
expect(mockEnablementInstance.autoEnableServers).toHaveBeenCalledWith([
|
||||
'test-server',
|
||||
]);
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect.stringContaining("MCP server 'test-server' was disabled"),
|
||||
);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should not log when MCP servers are already enabled', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
mockEnablementInstance.autoEnableServers.mockResolvedValue([]);
|
||||
mockExtensionManager.prototype.getExtensions = vi
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
{ name: 'my-extension', mcpServers: { 'test-server': {} } },
|
||||
]);
|
||||
|
||||
await handleEnable({ name: 'my-extension' });
|
||||
|
||||
expect(mockEnablementInstance.autoEnableServers).toHaveBeenCalledWith([
|
||||
'test-server',
|
||||
]);
|
||||
expect(emitConsoleLog).not.toHaveBeenCalledWith(
|
||||
'log',
|
||||
expect.stringContaining("MCP server 'test-server' was disabled"),
|
||||
);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('enableCommand', () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
|
||||
|
||||
interface EnableArgs {
|
||||
name: string;
|
||||
@@ -37,6 +38,26 @@ export async function handleEnable(args: EnableArgs) {
|
||||
} else {
|
||||
await extensionManager.enableExtension(args.name, SettingScope.User);
|
||||
}
|
||||
|
||||
// Auto-enable any disabled MCP servers for this extension
|
||||
const extension = extensionManager
|
||||
.getExtensions()
|
||||
.find((e) => e.name === args.name);
|
||||
|
||||
if (extension?.mcpServers) {
|
||||
const mcpEnablementManager = McpServerEnablementManager.getInstance();
|
||||
const enabledServers = await mcpEnablementManager.autoEnableServers(
|
||||
Object.keys(extension.mcpServers ?? {}),
|
||||
);
|
||||
|
||||
for (const serverName of enabledServers) {
|
||||
debugLogger.log(
|
||||
`MCP server '${serverName}' was disabled - now enabled.`,
|
||||
);
|
||||
}
|
||||
// Note: No restartServer() - CLI exits immediately, servers load on next session
|
||||
}
|
||||
|
||||
if (args.scope) {
|
||||
debugLogger.log(
|
||||
`Extension "${args.name}" successfully enabled for scope "${args.scope}".`,
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# Ink Library Screen Reader Guidance
|
||||
|
||||
When building custom components, it's important to keep accessibility in mind.
|
||||
While Ink provides the building blocks, ensuring your components are accessible
|
||||
will make your CLIs usable by a wider audience.
|
||||
|
||||
## General Principles
|
||||
|
||||
Provide screen reader-friendly output: Use the useIsScreenReaderEnabled hook to
|
||||
detect if a screen reader is active. You can then render a more descriptive
|
||||
output for screen reader users. Leverage ARIA props: For components that have a
|
||||
specific role (e.g., a checkbox or a button), use the aria-role, aria-state, and
|
||||
aria-label props on <Box> and <Text> to provide semantic meaning to screen
|
||||
readers.
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"name": "context-example",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-error.log
|
||||
yarn-debug.log
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,26 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-error.log
|
||||
yarn-debug.log
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,26 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-error.log
|
||||
yarn-debug.log
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,26 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-error.log
|
||||
yarn-debug.log
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,26 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-error.log
|
||||
yarn-debug.log
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# OS metadata
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -78,6 +78,17 @@ describe('extensions list command', () => {
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should output empty JSON array if no extensions are installed and output-format is json', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
await handleList({ outputFormat: 'json' });
|
||||
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith('log', '[]');
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should list all installed extensions', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
const extensions = [
|
||||
@@ -99,6 +110,24 @@ describe('extensions list command', () => {
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should list all installed extensions in JSON format', async () => {
|
||||
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
|
||||
const extensions = [
|
||||
{ name: 'ext1', version: '1.0.0' },
|
||||
{ name: 'ext2', version: '2.0.0' },
|
||||
];
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue(extensions);
|
||||
await handleList({ outputFormat: 'json' });
|
||||
|
||||
expect(emitConsoleLog).toHaveBeenCalledWith(
|
||||
'log',
|
||||
JSON.stringify(extensions, null, 2),
|
||||
);
|
||||
mockCwd.mockRestore();
|
||||
});
|
||||
|
||||
it('should log an error message and exit with code 1 when listing fails', async () => {
|
||||
const mockProcessExit = vi
|
||||
.spyOn(process, 'exit')
|
||||
@@ -130,11 +159,35 @@ describe('extensions list command', () => {
|
||||
expect(command.describe).toBe('Lists installed extensions.');
|
||||
});
|
||||
|
||||
it('handler should call handleList', async () => {
|
||||
it('builder should have output-format option', () => {
|
||||
const mockYargs = {
|
||||
option: vi.fn().mockReturnThis(),
|
||||
};
|
||||
(
|
||||
command.builder as unknown as (
|
||||
yargs: typeof mockYargs,
|
||||
) => typeof mockYargs
|
||||
)(mockYargs);
|
||||
expect(mockYargs.option).toHaveBeenCalledWith('output-format', {
|
||||
alias: 'o',
|
||||
type: 'string',
|
||||
describe: 'The format of the CLI output.',
|
||||
choices: ['text', 'json'],
|
||||
default: 'text',
|
||||
});
|
||||
});
|
||||
|
||||
it('handler should call handleList with parsed arguments', async () => {
|
||||
mockExtensionManager.prototype.loadExtensions = vi
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
await (command.handler as () => Promise<void>)();
|
||||
await (
|
||||
command.handler as unknown as (args: {
|
||||
'output-format': string;
|
||||
}) => Promise<void>
|
||||
)({
|
||||
'output-format': 'json',
|
||||
});
|
||||
expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import { loadSettings } from '../../config/settings.js';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
export async function handleList() {
|
||||
export async function handleList(options?: { outputFormat?: 'text' | 'json' }) {
|
||||
try {
|
||||
const workspaceDir = process.cwd();
|
||||
const extensionManager = new ExtensionManager({
|
||||
@@ -24,16 +24,25 @@ export async function handleList() {
|
||||
});
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
if (extensions.length === 0) {
|
||||
debugLogger.log('No extensions installed.');
|
||||
if (options?.outputFormat === 'json') {
|
||||
debugLogger.log('[]');
|
||||
} else {
|
||||
debugLogger.log('No extensions installed.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
debugLogger.log(
|
||||
extensions
|
||||
.map((extension, _): string =>
|
||||
extensionManager.toOutputString(extension),
|
||||
)
|
||||
.join('\n\n'),
|
||||
);
|
||||
|
||||
if (options?.outputFormat === 'json') {
|
||||
debugLogger.log(JSON.stringify(extensions, null, 2));
|
||||
} else {
|
||||
debugLogger.log(
|
||||
extensions
|
||||
.map((extension, _): string =>
|
||||
extensionManager.toOutputString(extension),
|
||||
)
|
||||
.join('\n\n'),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error(getErrorMessage(error));
|
||||
process.exit(1);
|
||||
@@ -43,9 +52,18 @@ export async function handleList() {
|
||||
export const listCommand: CommandModule = {
|
||||
command: 'list',
|
||||
describe: 'Lists installed extensions.',
|
||||
builder: (yargs) => yargs,
|
||||
handler: async () => {
|
||||
await handleList();
|
||||
builder: (yargs) =>
|
||||
yargs.option('output-format', {
|
||||
alias: 'o',
|
||||
type: 'string',
|
||||
describe: 'The format of the CLI output.',
|
||||
choices: ['text', 'json'],
|
||||
default: 'text',
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await handleList({
|
||||
outputFormat: argv['output-format'] as 'text' | 'json',
|
||||
});
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -42,21 +42,6 @@ async function handleEnable(args: Args): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if server is from an extension
|
||||
const serverKey = Object.keys(servers).find(
|
||||
(key) => normalizeServerId(key) === name,
|
||||
);
|
||||
const server = serverKey ? servers[serverKey] : undefined;
|
||||
if (server?.extension) {
|
||||
debugLogger.log(
|
||||
`${RED}Error:${RESET} Server '${args.name}' is provided by extension '${server.extension.name}'.`,
|
||||
);
|
||||
debugLogger.log(
|
||||
`Use 'gemini extensions enable ${server.extension.name}' to manage this extension.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await canLoadServer(name, {
|
||||
adminMcpEnabled: settings.merged.admin?.mcp?.enabled ?? true,
|
||||
allowedList: settings.merged.mcp?.allowed,
|
||||
@@ -100,21 +85,6 @@ async function handleDisable(args: Args): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if server is from an extension
|
||||
const serverKey = Object.keys(servers).find(
|
||||
(key) => normalizeServerId(key) === name,
|
||||
);
|
||||
const server = serverKey ? servers[serverKey] : undefined;
|
||||
if (server?.extension) {
|
||||
debugLogger.log(
|
||||
`${RED}Error:${RESET} Server '${args.name}' is provided by extension '${server.extension.name}'.`,
|
||||
);
|
||||
debugLogger.log(
|
||||
`Use 'gemini extensions disable ${server.extension.name}' to manage this extension.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.session) {
|
||||
manager.disableForSession(name);
|
||||
debugLogger.log(
|
||||
|
||||
@@ -57,7 +57,9 @@ vi.mock('fs', async (importOriginal) => {
|
||||
|
||||
return {
|
||||
...actualFs,
|
||||
mkdirSync: vi.fn(),
|
||||
mkdirSync: vi.fn((p) => {
|
||||
mockPaths.add(p.toString());
|
||||
}),
|
||||
writeFileSync: vi.fn(),
|
||||
existsSync: vi.fn((p) => mockPaths.has(p.toString())),
|
||||
statSync: vi.fn((p) => {
|
||||
@@ -124,10 +126,12 @@ vi.mock('@google/gemini-cli-core', async () => {
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: {
|
||||
respectGitIgnore: false,
|
||||
respectGeminiIgnore: true,
|
||||
customIgnoreFilePaths: [],
|
||||
},
|
||||
DEFAULT_FILE_FILTERING_OPTIONS: {
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
customIgnoreFilePaths: [],
|
||||
},
|
||||
createPolicyEngineConfig: vi.fn(async () => ({
|
||||
rules: [],
|
||||
@@ -702,6 +706,9 @@ describe('loadCliConfig', () => {
|
||||
expect(config.getFileFilteringRespectGeminiIgnore()).toBe(
|
||||
DEFAULT_FILE_FILTERING_OPTIONS.respectGeminiIgnore,
|
||||
);
|
||||
expect(config.getCustomIgnoreFilePaths()).toEqual(
|
||||
DEFAULT_FILE_FILTERING_OPTIONS.customIgnoreFilePaths,
|
||||
);
|
||||
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
|
||||
});
|
||||
|
||||
|
||||
@@ -766,6 +766,7 @@ export async function loadCliConfig(
|
||||
folderTrust,
|
||||
interactive,
|
||||
trustedFolder,
|
||||
useBackgroundColor: settings.ui?.useBackgroundColor,
|
||||
useRipgrep: settings.tools?.useRipgrep,
|
||||
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
|
||||
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
|
||||
import { createTestMergedSettings } from './settings.js';
|
||||
import { createExtension } from '../test-utils/createExtension.js';
|
||||
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
|
||||
|
||||
const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home'));
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:os')>();
|
||||
return {
|
||||
...actual,
|
||||
homedir: mockHomedir,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock @google/gemini-cli-core
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
homedir: mockHomedir,
|
||||
// Use actual implementations for loading skills and agents to test hydration
|
||||
loadAgentsFromDirectory: actual.loadAgentsFromDirectory,
|
||||
loadSkillsFromDir: actual.loadSkillsFromDir,
|
||||
};
|
||||
});
|
||||
|
||||
describe('ExtensionManager hydration', () => {
|
||||
let extensionManager: ExtensionManager;
|
||||
let tempDir: string;
|
||||
let extensionsDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(coreEvents, 'emitFeedback');
|
||||
vi.spyOn(debugLogger, 'debug').mockImplementation(() => {});
|
||||
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-'));
|
||||
mockHomedir.mockReturnValue(tempDir);
|
||||
|
||||
// Create the extensions directory that ExtensionManager expects
|
||||
extensionsDir = path.join(tempDir, '.gemini', EXTENSIONS_DIRECTORY_NAME);
|
||||
fs.mkdirSync(extensionsDir, { recursive: true });
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
experimental: { extensionConfig: true },
|
||||
}),
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: vi.fn(),
|
||||
workspaceDir: tempDir,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
it('should hydrate skill body with extension settings', async () => {
|
||||
const sourceDir = path.join(tempDir, 'source-ext-skill');
|
||||
const extensionName = 'skill-hydration-ext';
|
||||
createExtension({
|
||||
extensionsDir: sourceDir,
|
||||
name: extensionName,
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{
|
||||
name: 'API Key',
|
||||
description: 'API Key',
|
||||
envVar: 'MY_API_KEY',
|
||||
},
|
||||
],
|
||||
installMetadata: {
|
||||
type: 'local',
|
||||
source: path.join(sourceDir, extensionName),
|
||||
},
|
||||
});
|
||||
const extensionPath = path.join(sourceDir, extensionName);
|
||||
|
||||
// Create skill with variable
|
||||
const skillsDir = path.join(extensionPath, 'skills');
|
||||
const skillSubdir = path.join(skillsDir, 'my-skill');
|
||||
fs.mkdirSync(skillSubdir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillSubdir, 'SKILL.md'),
|
||||
`---
|
||||
name: my-skill
|
||||
description: test
|
||||
---
|
||||
Use key: \${MY_API_KEY}
|
||||
`,
|
||||
);
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
extensionManager.setRequestSetting(async (setting) => {
|
||||
if (setting.envVar === 'MY_API_KEY') return 'secret-123';
|
||||
return '';
|
||||
});
|
||||
|
||||
const extension = await extensionManager.installOrUpdateExtension({
|
||||
type: 'local',
|
||||
source: extensionPath,
|
||||
});
|
||||
|
||||
expect(extension.skills).toHaveLength(1);
|
||||
expect(extension.skills![0].body).toContain('Use key: secret-123');
|
||||
});
|
||||
|
||||
it('should hydrate agent system prompt with extension settings', async () => {
|
||||
const sourceDir = path.join(tempDir, 'source-ext-agent');
|
||||
const extensionName = 'agent-hydration-ext';
|
||||
createExtension({
|
||||
extensionsDir: sourceDir,
|
||||
name: extensionName,
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{
|
||||
name: 'Model Name',
|
||||
description: 'Model',
|
||||
envVar: 'MODEL_NAME',
|
||||
},
|
||||
],
|
||||
installMetadata: {
|
||||
type: 'local',
|
||||
source: path.join(sourceDir, extensionName),
|
||||
},
|
||||
});
|
||||
const extensionPath = path.join(sourceDir, extensionName);
|
||||
|
||||
// Create agent with variable
|
||||
const agentsDir = path.join(extensionPath, 'agents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(agentsDir, 'my-agent.md'),
|
||||
`---
|
||||
name: my-agent
|
||||
description: test
|
||||
---
|
||||
System using model: \${MODEL_NAME}
|
||||
`,
|
||||
);
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
extensionManager.setRequestSetting(async (setting) => {
|
||||
if (setting.envVar === 'MODEL_NAME') return 'gemini-pro';
|
||||
return '';
|
||||
});
|
||||
|
||||
const extension = await extensionManager.installOrUpdateExtension({
|
||||
type: 'local',
|
||||
source: extensionPath,
|
||||
});
|
||||
|
||||
expect(extension.agents).toHaveLength(1);
|
||||
const agent = extension.agents![0];
|
||||
if (agent.kind === 'local') {
|
||||
expect(agent.promptConfig.systemPrompt).toContain(
|
||||
'System using model: gemini-pro',
|
||||
);
|
||||
} else {
|
||||
throw new Error('Expected local agent');
|
||||
}
|
||||
});
|
||||
|
||||
it('should hydrate hooks with extension settings', async () => {
|
||||
const sourceDir = path.join(tempDir, 'source-ext-hooks');
|
||||
const extensionName = 'hooks-hydration-ext';
|
||||
createExtension({
|
||||
extensionsDir: sourceDir,
|
||||
name: extensionName,
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{
|
||||
name: 'Hook Command',
|
||||
description: 'Cmd',
|
||||
envVar: 'HOOK_CMD',
|
||||
},
|
||||
],
|
||||
installMetadata: {
|
||||
type: 'local',
|
||||
source: path.join(sourceDir, extensionName),
|
||||
},
|
||||
});
|
||||
const extensionPath = path.join(sourceDir, extensionName);
|
||||
|
||||
const hooksDir = path.join(extensionPath, 'hooks');
|
||||
fs.mkdirSync(hooksDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(hooksDir, 'hooks.json'),
|
||||
JSON.stringify({
|
||||
hooks: {
|
||||
BeforeTool: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'echo $HOOK_CMD',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Enable hooks in settings
|
||||
extensionManager = new ExtensionManager({
|
||||
settings: createTestMergedSettings({
|
||||
telemetry: { enabled: false },
|
||||
experimental: { extensionConfig: true },
|
||||
tools: { enableHooks: true },
|
||||
hooksConfig: { enabled: true },
|
||||
}),
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: vi.fn(),
|
||||
workspaceDir: tempDir,
|
||||
});
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
extensionManager.setRequestSetting(async (setting) => {
|
||||
if (setting.envVar === 'HOOK_CMD') return 'hello-world';
|
||||
return '';
|
||||
});
|
||||
|
||||
const extension = await extensionManager.installOrUpdateExtension({
|
||||
type: 'local',
|
||||
source: extensionPath,
|
||||
});
|
||||
|
||||
expect(extension.hooks).toBeDefined();
|
||||
expect(extension.hooks?.BeforeTool).toHaveLength(1);
|
||||
expect(extension.hooks?.BeforeTool![0].hooks[0].env?.['HOOK_CMD']).toBe(
|
||||
'hello-world',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pick up new settings after restartExtension', async () => {
|
||||
const sourceDir = path.join(tempDir, 'source-ext-restart');
|
||||
const extensionName = 'restart-hydration-ext';
|
||||
createExtension({
|
||||
extensionsDir: sourceDir,
|
||||
name: extensionName,
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{
|
||||
name: 'Value',
|
||||
description: 'Val',
|
||||
envVar: 'MY_VALUE',
|
||||
},
|
||||
],
|
||||
installMetadata: {
|
||||
type: 'local',
|
||||
source: path.join(sourceDir, extensionName),
|
||||
},
|
||||
});
|
||||
const extensionPath = path.join(sourceDir, extensionName);
|
||||
|
||||
const skillsDir = path.join(extensionPath, 'skills');
|
||||
const skillSubdir = path.join(skillsDir, 'my-skill');
|
||||
fs.mkdirSync(skillSubdir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillSubdir, 'SKILL.md'),
|
||||
'---\nname: my-skill\ndescription: test\n---\nValue is: ${MY_VALUE}',
|
||||
);
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
// Initial setting
|
||||
extensionManager.setRequestSetting(async () => 'first');
|
||||
const extension = await extensionManager.installOrUpdateExtension({
|
||||
type: 'local',
|
||||
source: extensionPath,
|
||||
});
|
||||
expect(extension.skills![0].body).toContain('Value is: first');
|
||||
|
||||
const { updateSetting, ExtensionSettingScope } = await import(
|
||||
'./extensions/extensionSettings.js'
|
||||
);
|
||||
const extensionConfig =
|
||||
await extensionManager.loadExtensionConfig(extensionPath);
|
||||
|
||||
const mockRequestSetting = vi.fn().mockResolvedValue('second');
|
||||
await updateSetting(
|
||||
extensionConfig,
|
||||
extension.id,
|
||||
'MY_VALUE',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.USER,
|
||||
process.cwd(),
|
||||
);
|
||||
|
||||
await extensionManager.restartExtension(extension);
|
||||
|
||||
const reloadedExtension = extensionManager
|
||||
.getExtensions()
|
||||
.find((e) => e.name === extensionName)!;
|
||||
expect(reloadedExtension.skills![0].body).toContain('Value is: second');
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
INSTALL_METADATA_FILENAME,
|
||||
recursivelyHydrateStrings,
|
||||
type JsonObject,
|
||||
type VariableContext,
|
||||
} from './extensions/variables.js';
|
||||
import {
|
||||
getEnvContents,
|
||||
@@ -538,12 +539,14 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
extensionId,
|
||||
ExtensionSettingScope.USER,
|
||||
);
|
||||
workspaceSettings = await getScopedEnvContents(
|
||||
config,
|
||||
extensionId,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
this.workspaceDir,
|
||||
);
|
||||
if (isWorkspaceTrusted(this.settings).isTrusted) {
|
||||
workspaceSettings = await getScopedEnvContents(
|
||||
config,
|
||||
extensionId,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
this.workspaceDir,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const customEnv = { ...userSettings, ...workspaceSettings };
|
||||
@@ -612,24 +615,63 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
)
|
||||
.filter((contextFilePath) => fs.existsSync(contextFilePath));
|
||||
|
||||
const hydrationContext: VariableContext = {
|
||||
extensionPath: effectiveExtensionPath,
|
||||
workspacePath: this.workspaceDir,
|
||||
'/': path.sep,
|
||||
pathSeparator: path.sep,
|
||||
...customEnv,
|
||||
};
|
||||
|
||||
let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
|
||||
if (
|
||||
this.settings.tools.enableHooks &&
|
||||
this.settings.hooksConfig.enabled
|
||||
) {
|
||||
hooks = await this.loadExtensionHooks(effectiveExtensionPath, {
|
||||
extensionPath: effectiveExtensionPath,
|
||||
workspacePath: this.workspaceDir,
|
||||
});
|
||||
hooks = await this.loadExtensionHooks(
|
||||
effectiveExtensionPath,
|
||||
hydrationContext,
|
||||
);
|
||||
}
|
||||
|
||||
const skills = await loadSkillsFromDir(
|
||||
// Hydrate hooks with extension settings as environment variables
|
||||
if (hooks && config.settings) {
|
||||
const hookEnv: Record<string, string> = {};
|
||||
for (const setting of config.settings) {
|
||||
const value = customEnv[setting.envVar];
|
||||
if (value !== undefined) {
|
||||
hookEnv[setting.envVar] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(hookEnv).length > 0) {
|
||||
for (const eventName of Object.keys(hooks)) {
|
||||
const eventHooks = hooks[eventName as HookEventName];
|
||||
if (eventHooks) {
|
||||
for (const definition of eventHooks) {
|
||||
for (const hook of definition.hooks) {
|
||||
// Merge existing env with new env vars, giving extension settings precedence.
|
||||
hook.env = { ...hook.env, ...hookEnv };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let skills = await loadSkillsFromDir(
|
||||
path.join(effectiveExtensionPath, 'skills'),
|
||||
);
|
||||
skills = skills.map((skill) =>
|
||||
recursivelyHydrateStrings(skill, hydrationContext),
|
||||
);
|
||||
|
||||
const agentLoadResult = await loadAgentsFromDirectory(
|
||||
path.join(effectiveExtensionPath, 'agents'),
|
||||
);
|
||||
agentLoadResult.agents = agentLoadResult.agents.map((agent) =>
|
||||
recursivelyHydrateStrings(agent, hydrationContext),
|
||||
);
|
||||
|
||||
// Log errors but don't fail the entire extension load
|
||||
for (const error of agentLoadResult.errors) {
|
||||
@@ -671,6 +713,14 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
}
|
||||
}
|
||||
|
||||
override async restartExtension(
|
||||
extension: GeminiCLIExtension,
|
||||
): Promise<void> {
|
||||
const extensionDir = extension.path;
|
||||
await this.unloadExtension(extension);
|
||||
await this.loadExtension(extensionDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes `extension` from the list of extensions and stops it if
|
||||
* appropriate.
|
||||
@@ -720,7 +770,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
|
||||
private async loadExtensionHooks(
|
||||
extensionDir: string,
|
||||
context: { extensionPath: string; workspacePath: string },
|
||||
context: VariableContext,
|
||||
): Promise<{ [K in HookEventName]?: HookDefinition[] } | undefined> {
|
||||
const hooksFilePath = path.join(extensionDir, 'hooks', 'hooks.json');
|
||||
|
||||
|
||||
@@ -398,6 +398,35 @@ describe('extensionSettings', () => {
|
||||
expect(actualContent).toBe('VAR1="a value with spaces"\n');
|
||||
});
|
||||
|
||||
it('should not set sensitive settings if the value is empty during initial setup', async () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{
|
||||
name: 's1',
|
||||
description: 'd1',
|
||||
envVar: 'SENSITIVE_VAR',
|
||||
sensitive: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
mockRequestSetting.mockResolvedValue('');
|
||||
|
||||
await maybePromptForSettings(
|
||||
config,
|
||||
'12345',
|
||||
mockRequestSetting,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
`Gemini CLI Extensions test-ext 12345`,
|
||||
);
|
||||
expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not attempt to clear secrets if keychain is unavailable', async () => {
|
||||
// Arrange
|
||||
const mockIsAvailable = vi.fn().mockResolvedValue(false);
|
||||
@@ -738,5 +767,42 @@ describe('extensionSettings', () => {
|
||||
const lines = actualContent.split('\n').filter((line) => line.length > 0);
|
||||
expect(lines).toHaveLength(3); // Should only have the three variables
|
||||
});
|
||||
|
||||
it('should delete a sensitive setting if the new value is empty', async () => {
|
||||
mockRequestSetting.mockResolvedValue('');
|
||||
|
||||
await updateSetting(
|
||||
config,
|
||||
'12345',
|
||||
'VAR2',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.USER,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
`Gemini CLI Extensions test-ext 12345`,
|
||||
);
|
||||
expect(await userKeychain.getSecret('VAR2')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not throw if deleting a non-existent sensitive setting with empty value', async () => {
|
||||
mockRequestSetting.mockResolvedValue('');
|
||||
// Ensure it doesn't exist first
|
||||
const userKeychain = new KeychainTokenStorage(
|
||||
`Gemini CLI Extensions test-ext 12345`,
|
||||
);
|
||||
await userKeychain.deleteSecret('VAR2');
|
||||
|
||||
await updateSetting(
|
||||
config,
|
||||
'12345',
|
||||
'VAR2',
|
||||
mockRequestSetting,
|
||||
ExtensionSettingScope.USER,
|
||||
tempWorkspaceDir,
|
||||
);
|
||||
// Should complete without error
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,7 +112,7 @@ export async function maybePromptForSettings(
|
||||
const nonSensitiveSettings: Record<string, string> = {};
|
||||
for (const setting of settings) {
|
||||
const value = allSettings[setting.envVar];
|
||||
if (value === undefined) {
|
||||
if (value === undefined || value === '') {
|
||||
continue;
|
||||
}
|
||||
if (setting.sensitive) {
|
||||
@@ -207,7 +207,7 @@ export async function updateSetting(
|
||||
settingKey: string,
|
||||
requestSetting: (setting: ExtensionSetting) => Promise<string>,
|
||||
scope: ExtensionSettingScope,
|
||||
workspaceDir?: string,
|
||||
workspaceDir: string,
|
||||
): Promise<void> {
|
||||
const { name: extensionName, settings } = extensionConfig;
|
||||
if (!settings || settings.length === 0) {
|
||||
@@ -230,7 +230,15 @@ export async function updateSetting(
|
||||
);
|
||||
|
||||
if (settingToUpdate.sensitive) {
|
||||
await keychain.setSecret(settingToUpdate.envVar, newValue);
|
||||
if (newValue) {
|
||||
await keychain.setSecret(settingToUpdate.envVar, newValue);
|
||||
} else {
|
||||
try {
|
||||
await keychain.deleteSecret(settingToUpdate.envVar);
|
||||
} catch {
|
||||
// Ignore if secret does not exist
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export type JsonValue =
|
||||
| JsonArray;
|
||||
|
||||
export type VariableContext = {
|
||||
[key in keyof typeof VARIABLE_SCHEMA]?: string;
|
||||
[key: string]: string | undefined;
|
||||
};
|
||||
|
||||
export function validateVariables(
|
||||
@@ -33,7 +33,7 @@ export function validateVariables(
|
||||
) {
|
||||
for (const key in schema) {
|
||||
const definition = schema[key];
|
||||
if (definition.required && !variables[key as keyof VariableContext]) {
|
||||
if (definition.required && !variables[key]) {
|
||||
throw new Error(`Missing required variable: ${key}`);
|
||||
}
|
||||
}
|
||||
@@ -43,30 +43,33 @@ export function hydrateString(str: string, context: VariableContext): string {
|
||||
validateVariables(context, VARIABLE_SCHEMA);
|
||||
const regex = /\${(.*?)}/g;
|
||||
return str.replace(regex, (match, key) =>
|
||||
context[key as keyof VariableContext] == null
|
||||
? match
|
||||
: (context[key as keyof VariableContext] as string),
|
||||
context[key] == null ? match : context[key],
|
||||
);
|
||||
}
|
||||
|
||||
export function recursivelyHydrateStrings(
|
||||
obj: JsonValue,
|
||||
export function recursivelyHydrateStrings<T>(
|
||||
obj: T,
|
||||
values: VariableContext,
|
||||
): JsonValue {
|
||||
): T {
|
||||
if (typeof obj === 'string') {
|
||||
return hydrateString(obj, values);
|
||||
return hydrateString(obj, values) as unknown as T;
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => recursivelyHydrateStrings(item, values));
|
||||
return obj.map((item) =>
|
||||
recursivelyHydrateStrings(item, values),
|
||||
) as unknown as T;
|
||||
}
|
||||
if (typeof obj === 'object' && obj !== null) {
|
||||
const newObj: JsonObject = {};
|
||||
const newObj: Record<string, unknown> = {};
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
newObj[key] = recursivelyHydrateStrings(obj[key], values);
|
||||
newObj[key] = recursivelyHydrateStrings(
|
||||
(obj as Record<string, unknown>)[key],
|
||||
values,
|
||||
);
|
||||
}
|
||||
}
|
||||
return newObj;
|
||||
return newObj as T;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ export enum Command {
|
||||
NAVIGATION_DOWN = 'nav.down',
|
||||
DIALOG_NAVIGATION_UP = 'nav.dialog.up',
|
||||
DIALOG_NAVIGATION_DOWN = 'nav.dialog.down',
|
||||
DIALOG_NEXT = 'nav.dialog.next',
|
||||
DIALOG_PREV = 'nav.dialog.previous',
|
||||
|
||||
// Suggestions & Completions
|
||||
ACCEPT_SUGGESTION = 'suggest.accept',
|
||||
@@ -206,6 +208,8 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
{ key: 'down', shift: false },
|
||||
{ key: 'j', shift: false },
|
||||
],
|
||||
[Command.DIALOG_NEXT]: [{ key: 'tab', shift: false }],
|
||||
[Command.DIALOG_PREV]: [{ key: 'tab', shift: true }],
|
||||
|
||||
// Suggestions & Completions
|
||||
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
|
||||
@@ -253,7 +257,10 @@ 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.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }],
|
||||
[Command.SHOW_MORE_LINES]: [
|
||||
{ key: 'o', ctrl: true },
|
||||
{ key: 's', ctrl: true },
|
||||
],
|
||||
[Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }],
|
||||
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab' }],
|
||||
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
|
||||
@@ -329,6 +336,8 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
Command.NAVIGATION_DOWN,
|
||||
Command.DIALOG_NAVIGATION_UP,
|
||||
Command.DIALOG_NAVIGATION_DOWN,
|
||||
Command.DIALOG_NEXT,
|
||||
Command.DIALOG_PREV,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -423,6 +432,8 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
[Command.NAVIGATION_DOWN]: 'Move selection down in lists.',
|
||||
[Command.DIALOG_NAVIGATION_UP]: 'Move up within dialog options.',
|
||||
[Command.DIALOG_NAVIGATION_DOWN]: 'Move down within dialog options.',
|
||||
[Command.DIALOG_NEXT]: 'Move to the next item or question in a dialog.',
|
||||
[Command.DIALOG_PREV]: 'Move to the previous item or question in a dialog.',
|
||||
|
||||
// Suggestions & Completions
|
||||
[Command.ACCEPT_SUGGESTION]: 'Accept the inline suggestion.',
|
||||
|
||||
@@ -323,6 +323,35 @@ export class McpServerEnablementManager {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-enable any disabled MCP servers by name.
|
||||
* Returns server names that were actually re-enabled.
|
||||
*/
|
||||
async autoEnableServers(serverNames: string[]): Promise<string[]> {
|
||||
const enabledServers: string[] = [];
|
||||
|
||||
for (const serverName of serverNames) {
|
||||
const normalizedName = normalizeServerId(serverName);
|
||||
const state = await this.getDisplayState(normalizedName);
|
||||
|
||||
let wasDisabled = false;
|
||||
if (state.isPersistentDisabled) {
|
||||
await this.enable(normalizedName);
|
||||
wasDisabled = true;
|
||||
}
|
||||
if (state.isSessionDisabled) {
|
||||
this.clearSessionDisable(normalizedName);
|
||||
wasDisabled = true;
|
||||
}
|
||||
|
||||
if (wasDisabled) {
|
||||
enabledServers.push(serverName);
|
||||
}
|
||||
}
|
||||
|
||||
return enabledServers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read config from file asynchronously.
|
||||
*/
|
||||
|
||||
@@ -324,6 +324,117 @@ describe('Policy Engine Integration Tests', () => {
|
||||
).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should allow write_file to plans directory in Plan mode', async () => {
|
||||
const settings: Settings = {};
|
||||
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.PLAN,
|
||||
);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// Valid plan file path (64-char hex hash, .md extension, safe filename)
|
||||
const validPlanPath =
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/my-plan.md';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: validPlanPath } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
|
||||
// Valid plan with underscore in filename
|
||||
const validPlanPath2 =
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/feature_auth.md';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: validPlanPath2 } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
|
||||
it('should deny write_file outside plans directory in Plan mode', async () => {
|
||||
const settings: Settings = {};
|
||||
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.PLAN,
|
||||
);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// Write to workspace (not plans dir) should be denied
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: '/project/src/file.ts' } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
|
||||
// Write to plans dir but wrong extension should be denied
|
||||
const wrongExtPath =
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: wrongExtPath } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
|
||||
// Path traversal attempt should be denied (filename contains /)
|
||||
const traversalPath =
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: traversalPath } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
|
||||
// Invalid hash length should be denied
|
||||
const shortHashPath = '/home/user/.gemini/tmp/abc123/plans/plan.md';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: shortHashPath } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should deny write_file to subdirectories in Plan mode', async () => {
|
||||
const settings: Settings = {};
|
||||
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.PLAN,
|
||||
);
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
// Write to subdirectory should be denied
|
||||
const subdirPath =
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/subdir/plan.md';
|
||||
expect(
|
||||
(
|
||||
await engine.check(
|
||||
{ name: 'write_file', args: { file_path: subdirPath } },
|
||||
undefined,
|
||||
)
|
||||
).decision,
|
||||
).toBe(PolicyDecision.DENY);
|
||||
});
|
||||
|
||||
it('should verify priority ordering works correctly in practice', async () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
|
||||
@@ -2215,8 +2215,8 @@ describe('Settings Loading and Merging', () => {
|
||||
// and missing properties revert to schema defaults.
|
||||
loadedSettings.setRemoteAdminSettings({ secureModeEnabled: false });
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true); // Reverts to default: true
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true); // Reverts to default: true
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false); // Defaulting to false if missing
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false); // Defaulting to false if missing
|
||||
});
|
||||
|
||||
it('should correctly handle undefined remote admin settings', () => {
|
||||
@@ -2276,10 +2276,10 @@ describe('Settings Loading and Merging', () => {
|
||||
secureModeEnabled: true,
|
||||
});
|
||||
|
||||
// Verify secureModeEnabled is updated, others remain defaults
|
||||
// Verify secureModeEnabled is updated, others default to false
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
|
||||
// Set remote settings with only mcpSetting.mcpEnabled
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
@@ -2289,7 +2289,7 @@ describe('Settings Loading and Merging', () => {
|
||||
// Verify mcpEnabled is updated, others remain defaults (secureModeEnabled reverts to default:false)
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
|
||||
// Set remote settings with only cliFeatureSetting.extensionsSetting.extensionsEnabled
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
@@ -2298,7 +2298,7 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
// Verify extensionsEnabled is updated, others remain defaults
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
@@ -2318,6 +2318,62 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
expect(loadedSettings.merged.admin.skills?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should default mcp.enabled to false if mcpSetting is present but mcpEnabled is undefined', () => {
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
mcpSetting: {},
|
||||
});
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should default extensions.enabled to false if extensionsSetting is present but extensionsEnabled is undefined', () => {
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
cliFeatureSetting: {
|
||||
extensionsSetting: {},
|
||||
},
|
||||
});
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should force secureModeEnabled to false if undefined, overriding schema defaults', () => {
|
||||
// Mock schema to have secureModeEnabled default to true to verify the override
|
||||
const originalSchema = getSettingsSchema();
|
||||
const modifiedSchema = JSON.parse(JSON.stringify(originalSchema));
|
||||
if (modifiedSchema.admin?.properties?.secureModeEnabled) {
|
||||
modifiedSchema.admin.properties.secureModeEnabled.default = true;
|
||||
}
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(modifiedSchema);
|
||||
|
||||
try {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
(fs.readFileSync as Mock).mockImplementation(() => '{}');
|
||||
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Pass a non-empty object that doesn't have secureModeEnabled
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
mcpSetting: {},
|
||||
});
|
||||
|
||||
// It should be forced to false by the logic, overriding the mock default of true
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
} finally {
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(originalSchema);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle completely empty remote admin settings response', () => {
|
||||
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
loadedSettings.setRemoteAdminSettings({});
|
||||
|
||||
// Should default to schema defaults (standard defaults)
|
||||
expect(loadedSettings.merged.admin?.secureModeEnabled).toBe(false);
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultsFromSchema', () => {
|
||||
|
||||
@@ -350,18 +350,17 @@ export class LoadedSettings {
|
||||
const admin: Settings['admin'] = {};
|
||||
const { secureModeEnabled, mcpSetting, cliFeatureSetting } = remoteSettings;
|
||||
|
||||
if (secureModeEnabled !== undefined) {
|
||||
admin.secureModeEnabled = secureModeEnabled;
|
||||
if (Object.keys(remoteSettings).length === 0) {
|
||||
this._remoteAdminSettings = { admin };
|
||||
this._merged = this.computeMergedSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mcpSetting?.mcpEnabled !== undefined) {
|
||||
admin.mcp = { enabled: mcpSetting.mcpEnabled };
|
||||
}
|
||||
|
||||
const extensionsSetting = cliFeatureSetting?.extensionsSetting;
|
||||
if (extensionsSetting?.extensionsEnabled !== undefined) {
|
||||
admin.extensions = { enabled: extensionsSetting.extensionsEnabled };
|
||||
}
|
||||
admin.secureModeEnabled = secureModeEnabled ?? false;
|
||||
admin.mcp = { enabled: mcpSetting?.mcpEnabled ?? false };
|
||||
admin.extensions = {
|
||||
enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled ?? false,
|
||||
};
|
||||
|
||||
if (cliFeatureSetting?.advancedFeaturesEnabled !== undefined) {
|
||||
admin.skills = { enabled: cliFeatureSetting.advancedFeaturesEnabled };
|
||||
|
||||
@@ -107,6 +107,14 @@ describe('SettingsSchema', () => {
|
||||
getSettingsSchema().context.properties.fileFiltering.properties
|
||||
?.enableRecursiveFileSearch,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
getSettingsSchema().context.properties.fileFiltering.properties
|
||||
?.customIgnoreFilePaths,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
getSettingsSchema().context.properties.fileFiltering.properties
|
||||
?.customIgnoreFilePaths.type,
|
||||
).toBe('array');
|
||||
});
|
||||
|
||||
it('should have unique categories', () => {
|
||||
|
||||
@@ -526,15 +526,6 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Show the model name in the chat for each model turn.',
|
||||
showInDialog: true,
|
||||
},
|
||||
useFullWidth: {
|
||||
type: 'boolean',
|
||||
label: 'Use Full Width',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description: 'Use the entire width of the terminal for output.',
|
||||
showInDialog: true,
|
||||
},
|
||||
useAlternateBuffer: {
|
||||
type: 'boolean',
|
||||
label: 'Use Alternate Screen Buffer',
|
||||
@@ -545,6 +536,15 @@ const SETTINGS_SCHEMA = {
|
||||
'Use an alternate screen buffer for the UI, preserving shell history.',
|
||||
showInDialog: true,
|
||||
},
|
||||
useBackgroundColor: {
|
||||
type: 'boolean',
|
||||
label: 'Use Background Color',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description: 'Whether to use background colors in the UI.',
|
||||
showInDialog: true,
|
||||
},
|
||||
incrementalRendering: {
|
||||
type: 'boolean',
|
||||
label: 'Incremental Rendering',
|
||||
@@ -555,6 +555,15 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled.',
|
||||
showInDialog: true,
|
||||
},
|
||||
showSpinner: {
|
||||
type: 'boolean',
|
||||
label: 'Show Spinner',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description: 'Show the spinner during operations.',
|
||||
showInDialog: true,
|
||||
},
|
||||
customWittyPhrases: {
|
||||
type: 'array',
|
||||
label: 'Custom Witty Phrases',
|
||||
@@ -923,6 +932,18 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Enable fuzzy search when searching for files.',
|
||||
showInDialog: true,
|
||||
},
|
||||
customIgnoreFilePaths: {
|
||||
type: 'array',
|
||||
label: 'Custom Ignore File Paths',
|
||||
category: 'Context',
|
||||
requiresRestart: true,
|
||||
default: [] as string[],
|
||||
description:
|
||||
'Additional ignore file paths to respect. These files take precedence over .geminiignore and .gitignore. Files earlier in the array take precedence over files later in the array, e.g. the first file takes precedence over the second one.',
|
||||
showInDialog: true,
|
||||
items: { type: 'string' },
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1471,12 +1492,12 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
skills: {
|
||||
type: 'boolean',
|
||||
label: 'Agent Skills',
|
||||
label: 'Agent Skills (Deprecated)',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable Agent Skills (experimental).',
|
||||
showInDialog: true,
|
||||
description: '[Deprecated] Enable Agent Skills (experimental).',
|
||||
showInDialog: false,
|
||||
},
|
||||
useOSC52Paste: {
|
||||
type: 'boolean',
|
||||
@@ -1552,7 +1573,6 @@ const SETTINGS_SCHEMA = {
|
||||
default: true,
|
||||
description: 'Enable Agent Skills.',
|
||||
showInDialog: true,
|
||||
ignoreInDocs: true,
|
||||
},
|
||||
disabled: {
|
||||
type: 'array',
|
||||
@@ -1582,11 +1602,11 @@ const SETTINGS_SCHEMA = {
|
||||
type: 'boolean',
|
||||
label: 'Enable Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description:
|
||||
'Canonical toggle for the hooks system. When disabled, no hooks will be executed.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
disabled: {
|
||||
type: 'array',
|
||||
|
||||
@@ -14,7 +14,6 @@ import type {
|
||||
UserFeedbackPayload,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
executeToolCall,
|
||||
ToolErrorType,
|
||||
GeminiEventType,
|
||||
OutputFormat,
|
||||
@@ -48,6 +47,8 @@ const mockCoreEvents = vi.hoisted(() => ({
|
||||
drainBacklogs: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSchedulerSchedule = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
@@ -61,7 +62,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
|
||||
return {
|
||||
...original,
|
||||
executeToolCall: vi.fn(),
|
||||
Scheduler: class {
|
||||
schedule = mockSchedulerSchedule;
|
||||
cancelAll = vi.fn();
|
||||
},
|
||||
isTelemetrySdkInitialized: vi.fn().mockReturnValue(true),
|
||||
ChatRecordingService: MockChatRecordingService,
|
||||
uiTelemetryService: {
|
||||
@@ -91,7 +95,6 @@ describe('runNonInteractive', () => {
|
||||
let mockConfig: Config;
|
||||
let mockSettings: LoadedSettings;
|
||||
let mockToolRegistry: ToolRegistry;
|
||||
let mockCoreExecuteToolCall: Mock;
|
||||
let consoleErrorSpy: MockInstance;
|
||||
let processStdoutSpy: MockInstance;
|
||||
let processStderrSpy: MockInstance;
|
||||
@@ -122,7 +125,7 @@ describe('runNonInteractive', () => {
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockCoreExecuteToolCall = vi.mocked(executeToolCall);
|
||||
mockSchedulerSchedule.mockReset();
|
||||
|
||||
mockCommandServiceCreate.mockResolvedValue({
|
||||
getCommands: mockGetCommands,
|
||||
@@ -158,6 +161,11 @@ describe('runNonInteractive', () => {
|
||||
|
||||
mockConfig = {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
getMessageBus: vi.fn().mockReturnValue({
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
publish: vi.fn(),
|
||||
}),
|
||||
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
getMaxSessionTurns: vi.fn().mockReturnValue(10),
|
||||
@@ -263,25 +271,27 @@ describe('runNonInteractive', () => {
|
||||
},
|
||||
};
|
||||
const toolResponse: Part[] = [{ text: 'Tool response' }];
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'success',
|
||||
request: {
|
||||
callId: 'tool-1',
|
||||
name: 'testTool',
|
||||
args: { arg1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-2',
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'success',
|
||||
request: {
|
||||
callId: 'tool-1',
|
||||
name: 'testTool',
|
||||
args: { arg1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-2',
|
||||
},
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: toolResponse,
|
||||
callId: 'tool-1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
},
|
||||
},
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: toolResponse,
|
||||
callId: 'tool-1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
},
|
||||
});
|
||||
]);
|
||||
|
||||
const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent];
|
||||
const secondCallEvents: ServerGeminiStreamEvent[] = [
|
||||
@@ -304,9 +314,8 @@ describe('runNonInteractive', () => {
|
||||
});
|
||||
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
|
||||
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
expect.objectContaining({ name: 'testTool' }),
|
||||
expect(mockSchedulerSchedule).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ name: 'testTool' })],
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
||||
@@ -335,16 +344,18 @@ describe('runNonInteractive', () => {
|
||||
};
|
||||
|
||||
// 2. Mock the execution of the tools. We just need them to succeed.
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'success',
|
||||
request: toolCallEvent.value, // This is generic enough for both calls
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: [],
|
||||
callId: 'mock-tool',
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'success',
|
||||
request: toolCallEvent.value, // This is generic enough for both calls
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: [],
|
||||
callId: 'mock-tool',
|
||||
},
|
||||
},
|
||||
});
|
||||
]);
|
||||
|
||||
// 3. Define the sequence of events streamed from the mock model.
|
||||
// Turn 1: Model outputs text, then requests a tool call.
|
||||
@@ -385,7 +396,7 @@ describe('runNonInteractive', () => {
|
||||
expect(getWrittenOutput()).toMatchSnapshot();
|
||||
|
||||
// Also verify the tools were called as expected.
|
||||
expect(mockCoreExecuteToolCall).toHaveBeenCalledTimes(2);
|
||||
expect(mockSchedulerSchedule).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle error during tool execution and should send error back to the model', async () => {
|
||||
@@ -399,34 +410,36 @@ describe('runNonInteractive', () => {
|
||||
prompt_id: 'prompt-id-3',
|
||||
},
|
||||
};
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'error',
|
||||
request: {
|
||||
callId: 'tool-1',
|
||||
name: 'errorTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-3',
|
||||
},
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
response: {
|
||||
callId: 'tool-1',
|
||||
error: new Error('Execution failed'),
|
||||
errorType: ToolErrorType.EXECUTION_FAILED,
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'errorTool',
|
||||
response: {
|
||||
output: 'Error: Execution failed',
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'error',
|
||||
request: {
|
||||
callId: 'tool-1',
|
||||
name: 'errorTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-3',
|
||||
},
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
response: {
|
||||
callId: 'tool-1',
|
||||
error: new Error('Execution failed'),
|
||||
errorType: ToolErrorType.EXECUTION_FAILED,
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'errorTool',
|
||||
response: {
|
||||
output: 'Error: Execution failed',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
resultDisplay: 'Execution failed',
|
||||
contentLength: undefined,
|
||||
],
|
||||
resultDisplay: 'Execution failed',
|
||||
contentLength: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
]);
|
||||
const finalResponse: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.Content,
|
||||
@@ -448,7 +461,7 @@ describe('runNonInteractive', () => {
|
||||
prompt_id: 'prompt-id-3',
|
||||
});
|
||||
|
||||
expect(mockCoreExecuteToolCall).toHaveBeenCalled();
|
||||
expect(mockSchedulerSchedule).toHaveBeenCalled();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error executing tool errorTool: Execution failed',
|
||||
);
|
||||
@@ -498,24 +511,26 @@ describe('runNonInteractive', () => {
|
||||
prompt_id: 'prompt-id-5',
|
||||
},
|
||||
};
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'error',
|
||||
request: {
|
||||
callId: 'tool-1',
|
||||
name: 'nonexistentTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-5',
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'error',
|
||||
request: {
|
||||
callId: 'tool-1',
|
||||
name: 'nonexistentTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-5',
|
||||
},
|
||||
response: {
|
||||
callId: 'tool-1',
|
||||
error: new Error('Tool "nonexistentTool" not found in registry.'),
|
||||
resultDisplay: 'Tool "nonexistentTool" not found in registry.',
|
||||
responseParts: [],
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
},
|
||||
},
|
||||
response: {
|
||||
callId: 'tool-1',
|
||||
error: new Error('Tool "nonexistentTool" not found in registry.'),
|
||||
resultDisplay: 'Tool "nonexistentTool" not found in registry.',
|
||||
responseParts: [],
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
},
|
||||
});
|
||||
]);
|
||||
const finalResponse: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.Content,
|
||||
@@ -538,7 +553,7 @@ describe('runNonInteractive', () => {
|
||||
prompt_id: 'prompt-id-5',
|
||||
});
|
||||
|
||||
expect(mockCoreExecuteToolCall).toHaveBeenCalled();
|
||||
expect(mockSchedulerSchedule).toHaveBeenCalled();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error executing tool nonexistentTool: Tool "nonexistentTool" not found in registry.',
|
||||
);
|
||||
@@ -665,25 +680,27 @@ describe('runNonInteractive', () => {
|
||||
},
|
||||
};
|
||||
const toolResponse: Part[] = [{ text: 'Tool executed successfully' }];
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'success',
|
||||
request: {
|
||||
callId: 'tool-1',
|
||||
name: 'testTool',
|
||||
args: { arg1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-tool-only',
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'success',
|
||||
request: {
|
||||
callId: 'tool-1',
|
||||
name: 'testTool',
|
||||
args: { arg1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-tool-only',
|
||||
},
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: toolResponse,
|
||||
callId: 'tool-1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
},
|
||||
},
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: toolResponse,
|
||||
callId: 'tool-1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
},
|
||||
});
|
||||
]);
|
||||
|
||||
// First call returns only tool call, no content
|
||||
const firstCallEvents: ServerGeminiStreamEvent[] = [
|
||||
@@ -719,9 +736,8 @@ describe('runNonInteractive', () => {
|
||||
});
|
||||
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
|
||||
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
expect.objectContaining({ name: 'testTool' }),
|
||||
expect(mockSchedulerSchedule).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ name: 'testTool' })],
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
|
||||
@@ -1248,25 +1264,27 @@ describe('runNonInteractive', () => {
|
||||
},
|
||||
};
|
||||
const toolResponse: Part[] = [{ text: 'file.txt' }];
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'success',
|
||||
request: {
|
||||
callId: 'tool-shell-1',
|
||||
name: 'ShellTool',
|
||||
args: { command: 'ls' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-allowed',
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'success',
|
||||
request: {
|
||||
callId: 'tool-shell-1',
|
||||
name: 'ShellTool',
|
||||
args: { command: 'ls' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-allowed',
|
||||
},
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: toolResponse,
|
||||
callId: 'tool-shell-1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
},
|
||||
},
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: toolResponse,
|
||||
callId: 'tool-shell-1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
},
|
||||
});
|
||||
]);
|
||||
|
||||
const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent];
|
||||
const secondCallEvents: ServerGeminiStreamEvent[] = [
|
||||
@@ -1288,9 +1306,8 @@ describe('runNonInteractive', () => {
|
||||
prompt_id: 'prompt-id-allowed',
|
||||
});
|
||||
|
||||
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
|
||||
mockConfig,
|
||||
expect.objectContaining({ name: 'ShellTool' }),
|
||||
expect(mockSchedulerSchedule).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ name: 'ShellTool' })],
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('file.txt\n');
|
||||
@@ -1446,20 +1463,22 @@ describe('runNonInteractive', () => {
|
||||
},
|
||||
};
|
||||
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'success',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: [{ text: 'Tool response' }],
|
||||
callId: 'tool-1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
resultDisplay: 'Tool executed successfully',
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'success',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: [{ text: 'Tool response' }],
|
||||
callId: 'tool-1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
resultDisplay: 'Tool executed successfully',
|
||||
},
|
||||
},
|
||||
});
|
||||
]);
|
||||
|
||||
const firstCallEvents: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Thinking...' },
|
||||
@@ -1636,19 +1655,21 @@ describe('runNonInteractive', () => {
|
||||
prompt_id: 'prompt-id-tool-error',
|
||||
},
|
||||
};
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'success',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: [],
|
||||
callId: 'tool-1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'success',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
responseParts: [],
|
||||
callId: 'tool-1',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
]);
|
||||
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
toolCallEvent,
|
||||
@@ -1717,19 +1738,21 @@ describe('runNonInteractive', () => {
|
||||
};
|
||||
|
||||
// Mock tool execution returning STOP_EXECUTION
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'error',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
callId: 'stop-call',
|
||||
responseParts: [{ text: 'error occurred' }],
|
||||
errorType: ToolErrorType.STOP_EXECUTION,
|
||||
error: new Error('Stop reason from hook'),
|
||||
resultDisplay: undefined,
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'error',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
callId: 'stop-call',
|
||||
responseParts: [{ text: 'error occurred' }],
|
||||
errorType: ToolErrorType.STOP_EXECUTION,
|
||||
error: new Error('Stop reason from hook'),
|
||||
resultDisplay: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
]);
|
||||
|
||||
const firstCallEvents: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Executing tool...' },
|
||||
@@ -1750,7 +1773,7 @@ describe('runNonInteractive', () => {
|
||||
prompt_id: 'prompt-id-stop',
|
||||
});
|
||||
|
||||
expect(mockCoreExecuteToolCall).toHaveBeenCalled();
|
||||
expect(mockSchedulerSchedule).toHaveBeenCalled();
|
||||
|
||||
// The key assertion: sendMessageStream should have been called ONLY ONCE (initial user input).
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
@@ -1777,19 +1800,21 @@ describe('runNonInteractive', () => {
|
||||
},
|
||||
};
|
||||
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'error',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
callId: 'stop-call',
|
||||
responseParts: [{ text: 'error occurred' }],
|
||||
errorType: ToolErrorType.STOP_EXECUTION,
|
||||
error: new Error('Stop reason'),
|
||||
resultDisplay: undefined,
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'error',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
callId: 'stop-call',
|
||||
responseParts: [{ text: 'error occurred' }],
|
||||
errorType: ToolErrorType.STOP_EXECUTION,
|
||||
error: new Error('Stop reason'),
|
||||
resultDisplay: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
]);
|
||||
|
||||
const firstCallEvents: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Partial content' },
|
||||
@@ -1839,19 +1864,21 @@ describe('runNonInteractive', () => {
|
||||
},
|
||||
};
|
||||
|
||||
mockCoreExecuteToolCall.mockResolvedValue({
|
||||
status: 'error',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
callId: 'stop-call',
|
||||
responseParts: [{ text: 'error occurred' }],
|
||||
errorType: ToolErrorType.STOP_EXECUTION,
|
||||
error: new Error('Stop reason'),
|
||||
resultDisplay: undefined,
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'error',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
callId: 'stop-call',
|
||||
responseParts: [{ text: 'error occurred' }],
|
||||
errorType: ToolErrorType.STOP_EXECUTION,
|
||||
error: new Error('Stop reason'),
|
||||
resultDisplay: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
]);
|
||||
|
||||
const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent];
|
||||
|
||||
@@ -2066,5 +2093,63 @@ describe('runNonInteractive', () => {
|
||||
expect.stringContaining('[WARNING] --raw-output is enabled'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should report cancelled tool calls as success in stream-json mode (legacy parity)', async () => {
|
||||
const toolCallEvent: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-1',
|
||||
name: 'testTool',
|
||||
args: { arg1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-id-cancel',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock the scheduler to return a cancelled status
|
||||
mockSchedulerSchedule.mockResolvedValue([
|
||||
{
|
||||
status: 'cancelled',
|
||||
request: toolCallEvent.value,
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
callId: 'tool-1',
|
||||
responseParts: [{ text: 'Operation cancelled' }],
|
||||
resultDisplay: 'Cancelled',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
toolCallEvent,
|
||||
{
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Model continues...',
|
||||
},
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'Test input',
|
||||
prompt_id: 'prompt-id-cancel',
|
||||
});
|
||||
|
||||
const output = getWrittenOutput();
|
||||
expect(output).toContain('"type":"tool_result"');
|
||||
expect(output).toContain('"status":"success"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,13 +8,11 @@ import type {
|
||||
Config,
|
||||
ToolCallRequestInfo,
|
||||
ResumedSessionData,
|
||||
CompletedToolCall,
|
||||
UserFeedbackPayload,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { isSlashCommand } from './ui/utils/commandUtils.js';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import {
|
||||
executeToolCall,
|
||||
GeminiEventType,
|
||||
FatalInputError,
|
||||
promptIdContext,
|
||||
@@ -29,6 +27,8 @@ import {
|
||||
createWorkingStdio,
|
||||
recordToolCallInteractions,
|
||||
ToolErrorType,
|
||||
Scheduler,
|
||||
ROOT_SCHEDULER_ID,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import type { Content, Part } from '@google/genai';
|
||||
@@ -202,6 +202,12 @@ export async function runNonInteractive({
|
||||
});
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
const scheduler = new Scheduler({
|
||||
config,
|
||||
messageBus: config.getMessageBus(),
|
||||
getPreferredEditor: () => undefined,
|
||||
schedulerId: ROOT_SCHEDULER_ID,
|
||||
});
|
||||
|
||||
// Initialize chat. Resume if resume data is passed.
|
||||
if (resumedSessionData) {
|
||||
@@ -375,25 +381,23 @@ export async function runNonInteractive({
|
||||
|
||||
if (toolCallRequests.length > 0) {
|
||||
textOutput.ensureTrailingNewline();
|
||||
const completedToolCalls = await scheduler.schedule(
|
||||
toolCallRequests,
|
||||
abortController.signal,
|
||||
);
|
||||
const toolResponseParts: Part[] = [];
|
||||
const completedToolCalls: CompletedToolCall[] = [];
|
||||
|
||||
for (const requestInfo of toolCallRequests) {
|
||||
const completedToolCall = await executeToolCall(
|
||||
config,
|
||||
requestInfo,
|
||||
abortController.signal,
|
||||
);
|
||||
for (const completedToolCall of completedToolCalls) {
|
||||
const toolResponse = completedToolCall.response;
|
||||
|
||||
completedToolCalls.push(completedToolCall);
|
||||
const requestInfo = completedToolCall.request;
|
||||
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.TOOL_RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
tool_id: requestInfo.callId,
|
||||
status: toolResponse.error ? 'error' : 'success',
|
||||
status:
|
||||
completedToolCall.status === 'error' ? 'error' : 'success',
|
||||
output:
|
||||
typeof toolResponse.resultDisplay === 'string'
|
||||
? toolResponse.resultDisplay
|
||||
|
||||
@@ -34,6 +34,7 @@ import { initCommand } from '../ui/commands/initCommand.js';
|
||||
import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
||||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { modelCommand } from '../ui/commands/modelCommand.js';
|
||||
import { oncallCommand } from '../ui/commands/oncallCommand.js';
|
||||
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
|
||||
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||
import { policiesCommand } from '../ui/commands/policiesCommand.js';
|
||||
@@ -110,6 +111,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
rewindCommand,
|
||||
await ideCommand(),
|
||||
initCommand,
|
||||
...(isNightlyBuild ? [oncallCommand] : []),
|
||||
...(this.config?.getMcpEnabled() === false
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
ShellProcessor,
|
||||
} from './prompt-processors/shellProcessor.js';
|
||||
import { AtFileProcessor } from './prompt-processors/atFileProcessor.js';
|
||||
import { sanitizeForListDisplay } from '../ui/utils/textUtils.js';
|
||||
import { sanitizeForDisplay } from '../ui/utils/textUtils.js';
|
||||
|
||||
interface CommandDirectory {
|
||||
path: string;
|
||||
@@ -248,7 +248,7 @@ export class FileCommandLoader implements ICommandLoader {
|
||||
const defaultDescription = `Custom command from ${path.basename(filePath)}`;
|
||||
let description = validDef.description || defaultDescription;
|
||||
|
||||
description = sanitizeForListDisplay(description, 100);
|
||||
description = sanitizeForDisplay(description, 100);
|
||||
|
||||
if (extensionName) {
|
||||
description = `[${extensionName}] ${description}`;
|
||||
|
||||
@@ -16,7 +16,6 @@ import { SettingsContext } from '../ui/contexts/SettingsContext.js';
|
||||
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
|
||||
import { UIStateContext, type UIState } from '../ui/contexts/UIStateContext.js';
|
||||
import { ConfigContext } from '../ui/contexts/ConfigContext.js';
|
||||
import { calculateMainAreaWidth } from '../ui/utils/ui-sizing.js';
|
||||
import { VimModeProvider } from '../ui/contexts/VimModeContext.js';
|
||||
import { MouseProvider } from '../ui/contexts/MouseContext.js';
|
||||
import { ScrollProvider } from '../ui/contexts/ScrollProvider.js';
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
} from '../ui/contexts/UIActionsContext.js';
|
||||
import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js';
|
||||
import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js';
|
||||
import { AskUserActionsProvider } from '../ui/contexts/AskUserActionsContext.js';
|
||||
|
||||
import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
|
||||
import { FakePersistentState } from './persistentStateFake.js';
|
||||
@@ -38,6 +38,12 @@ vi.mock('../utils/persistentState.js', () => ({
|
||||
persistentState: persistentStateMock,
|
||||
}));
|
||||
|
||||
vi.mock('../ui/utils/terminalUtils.js', () => ({
|
||||
isLowColorDepth: vi.fn(() => false),
|
||||
getColorDepth: vi.fn(() => 24),
|
||||
isITerm2: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
// Wrapper around ink-testing-library's render that ensures act() is called
|
||||
export const render = (
|
||||
tree: React.ReactElement,
|
||||
@@ -147,7 +153,6 @@ export const createMockSettings = (
|
||||
const baseMockUiState = {
|
||||
renderMarkdown: true,
|
||||
streamingState: StreamingState.Idle,
|
||||
mainAreaWidth: 100,
|
||||
terminalWidth: 120,
|
||||
terminalHeight: 40,
|
||||
currentModel: 'gemini-pro',
|
||||
@@ -269,7 +274,7 @@ export const renderWithProviders = (
|
||||
});
|
||||
}
|
||||
|
||||
const mainAreaWidth = calculateMainAreaWidth(terminalWidth, finalSettings);
|
||||
const mainAreaWidth = terminalWidth;
|
||||
|
||||
const finalUiState = {
|
||||
...baseState,
|
||||
@@ -296,20 +301,28 @@ export const renderWithProviders = (
|
||||
config={config}
|
||||
toolCalls={allToolCalls}
|
||||
>
|
||||
<KeypressProvider>
|
||||
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
|
||||
<ScrollProvider>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{component}
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
<AskUserActionsProvider
|
||||
request={null}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
>
|
||||
<KeypressProvider>
|
||||
<MouseProvider
|
||||
mouseEventsEnabled={mouseEventsEnabled}
|
||||
>
|
||||
<ScrollProvider>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{component}
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</AskUserActionsProvider>
|
||||
</ToolActionsProvider>
|
||||
</UIActionsContext.Provider>
|
||||
</StreamingContext.Provider>
|
||||
@@ -403,10 +416,13 @@ export function renderHookWithProviders<Result, Props>(
|
||||
const result = { current: undefined as unknown as Result };
|
||||
|
||||
let setPropsFn: ((props: Props) => void) | undefined;
|
||||
let forceUpdateFn: (() => void) | undefined;
|
||||
|
||||
function TestComponent({ initialProps }: { initialProps: Props }) {
|
||||
const [props, setProps] = useState(initialProps);
|
||||
const [, forceUpdate] = useState(0);
|
||||
setPropsFn = setProps;
|
||||
forceUpdateFn = () => forceUpdate((n) => n + 1);
|
||||
result.current = renderCallback(props);
|
||||
return null;
|
||||
}
|
||||
@@ -426,8 +442,10 @@ export function renderHookWithProviders<Result, Props>(
|
||||
|
||||
function rerender(newProps?: Props) {
|
||||
act(() => {
|
||||
if (setPropsFn && newProps) {
|
||||
setPropsFn(newProps);
|
||||
if (arguments.length > 0 && setPropsFn) {
|
||||
setPropsFn(newProps as Props);
|
||||
} else if (forceUpdateFn) {
|
||||
forceUpdateFn();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest';
|
||||
import type React from 'react';
|
||||
import { renderWithProviders } from '../test-utils/render.js';
|
||||
import { Text, useIsScreenReaderEnabled, type DOMElement } from 'ink';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import { App } from './App.js';
|
||||
import { type UIState } from './contexts/UIStateContext.js';
|
||||
import { StreamingState, ToolCallStatus } from './types.js';
|
||||
@@ -22,10 +21,6 @@ vi.mock('ink', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./components/MainContent.js', () => ({
|
||||
MainContent: () => <Text>MainContent</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./components/DialogManager.js', () => ({
|
||||
DialogManager: () => <Text>DialogManager</Text>,
|
||||
}));
|
||||
@@ -34,9 +29,16 @@ vi.mock('./components/Composer.js', () => ({
|
||||
Composer: () => <Text>Composer</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./components/Notifications.js', () => ({
|
||||
Notifications: () => <Text>Notifications</Text>,
|
||||
}));
|
||||
vi.mock('./components/Notifications.js', async () => {
|
||||
const { Text, Box } = await import('ink');
|
||||
return {
|
||||
Notifications: () => (
|
||||
<Box>
|
||||
<Text>Notifications</Text>
|
||||
</Box>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./components/QuittingDisplay.js', () => ({
|
||||
QuittingDisplay: () => <Text>Quitting...</Text>,
|
||||
@@ -46,9 +48,16 @@ vi.mock('./components/HistoryItemDisplay.js', () => ({
|
||||
HistoryItemDisplay: () => <Text>HistoryItemDisplay</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./components/Footer.js', () => ({
|
||||
Footer: () => <Text>Footer</Text>,
|
||||
}));
|
||||
vi.mock('./components/Footer.js', async () => {
|
||||
const { Text, Box } = await import('ink');
|
||||
return {
|
||||
Footer: () => (
|
||||
<Box>
|
||||
<Text>Footer</Text>
|
||||
</Box>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(() => {
|
||||
@@ -87,7 +96,7 @@ describe('App', () => {
|
||||
useAlternateBuffer: false,
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('MainContent');
|
||||
expect(lastFrame()).toContain('Tips for getting started');
|
||||
expect(lastFrame()).toContain('Notifications');
|
||||
expect(lastFrame()).toContain('Composer');
|
||||
});
|
||||
@@ -133,7 +142,7 @@ describe('App', () => {
|
||||
uiState: dialogUIState,
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('MainContent');
|
||||
expect(lastFrame()).toContain('Tips for getting started');
|
||||
expect(lastFrame()).toContain('Notifications');
|
||||
expect(lastFrame()).toContain('DialogManager');
|
||||
});
|
||||
@@ -165,10 +174,10 @@ describe('App', () => {
|
||||
uiState: mockUIState,
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain(`Notifications
|
||||
Footer
|
||||
MainContent
|
||||
Composer`);
|
||||
expect(lastFrame()).toContain('Notifications');
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
expect(lastFrame()).toContain('Tips for getting started');
|
||||
expect(lastFrame()).toContain('Composer');
|
||||
});
|
||||
|
||||
it('should render DefaultAppLayout when screen reader is not enabled', () => {
|
||||
@@ -178,9 +187,9 @@ Composer`);
|
||||
uiState: mockUIState,
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain(`MainContent
|
||||
Notifications
|
||||
Composer`);
|
||||
expect(lastFrame()).toContain('Tips for getting started');
|
||||
expect(lastFrame()).toContain('Notifications');
|
||||
expect(lastFrame()).toContain('Composer');
|
||||
});
|
||||
|
||||
it('should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on', () => {
|
||||
@@ -209,19 +218,20 @@ Composer`);
|
||||
pendingGeminiHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
|
||||
} as UIState;
|
||||
|
||||
const configWithExperiment = {
|
||||
...makeFakeConfig(),
|
||||
isEventDrivenSchedulerEnabled: () => true,
|
||||
isTrustedFolder: () => true,
|
||||
getIdeMode: () => false,
|
||||
} as unknown as Config;
|
||||
const configWithExperiment = makeFakeConfig();
|
||||
vi.spyOn(
|
||||
configWithExperiment,
|
||||
'isEventDrivenSchedulerEnabled',
|
||||
).mockReturnValue(true);
|
||||
vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true);
|
||||
vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false);
|
||||
|
||||
const { lastFrame } = renderWithProviders(<App />, {
|
||||
uiState: stateWithConfirmingTool,
|
||||
config: configWithExperiment,
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('MainContent');
|
||||
expect(lastFrame()).toContain('Tips for getting started');
|
||||
expect(lastFrame()).toContain('Notifications');
|
||||
expect(lastFrame()).toContain('Action Required'); // From ToolConfirmationQueue
|
||||
expect(lastFrame()).toContain('1 of 1');
|
||||
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
} 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,
|
||||
@@ -63,6 +67,8 @@ import {
|
||||
SessionStartSource,
|
||||
SessionEndReason,
|
||||
generateSummary,
|
||||
MessageBusType,
|
||||
type AskUserRequest,
|
||||
type AgentsDiscoveredPayload,
|
||||
ChangeAuthRequestedError,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -282,6 +288,11 @@ 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);
|
||||
@@ -299,6 +310,56 @@ 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),
|
||||
[],
|
||||
@@ -1340,7 +1401,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setCopyModeEnabled(false);
|
||||
enableMouseEvents();
|
||||
// We don't want to process any other keys if we're in copy mode.
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Debug log keystrokes if enabled
|
||||
@@ -1351,22 +1412,26 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
if (isAlternateBuffer && keyMatchers[Command.TOGGLE_COPY_MODE](key)) {
|
||||
setCopyModeEnabled(true);
|
||||
disableMouseEvents();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
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?.();
|
||||
|
||||
setCtrlCPressCount((prev) => prev + 1);
|
||||
return;
|
||||
return true;
|
||||
} else if (keyMatchers[Command.EXIT](key)) {
|
||||
if (buffer.text.length > 0) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
setCtrlDPressCount((prev) => prev + 1);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
let enteringConstrainHeightMode = false;
|
||||
@@ -1377,8 +1442,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) {
|
||||
setShowErrorDetails((prev) => !prev);
|
||||
return true;
|
||||
} else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) {
|
||||
setShowFullTodos((prev) => !prev);
|
||||
return true;
|
||||
} else if (keyMatchers[Command.TOGGLE_MARKDOWN](key)) {
|
||||
setRenderMarkdown((prev) => {
|
||||
const newValue = !prev;
|
||||
@@ -1386,6 +1453,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
refreshStatic();
|
||||
return newValue;
|
||||
});
|
||||
return true;
|
||||
} else if (
|
||||
keyMatchers[Command.SHOW_IDE_CONTEXT_DETAIL](key) &&
|
||||
config.getIdeMode() &&
|
||||
@@ -1393,11 +1461,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
handleSlashCommand('/ide status');
|
||||
return true;
|
||||
} else if (
|
||||
keyMatchers[Command.SHOW_MORE_LINES](key) &&
|
||||
!enteringConstrainHeightMode
|
||||
) {
|
||||
setConstrainHeight(false);
|
||||
return true;
|
||||
} else if (
|
||||
keyMatchers[Command.UNFOCUS_SHELL_INPUT](key) &&
|
||||
activePtyId &&
|
||||
@@ -1406,7 +1476,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
if (key.name === 'tab' && key.shift) {
|
||||
// Always change focus
|
||||
setEmbeddedShellFocused(false);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
@@ -1426,10 +1496,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
setEmbeddedShellFocused(false);
|
||||
}, 100);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
handleWarning('Press Shift+Tab to focus out.');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[
|
||||
constrainHeight,
|
||||
@@ -1442,6 +1514,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setCtrlDPressCount,
|
||||
handleSlashCommand,
|
||||
cancelOngoingRequest,
|
||||
askUserRequest,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
settings.merged.general.debugKeystrokeLogging,
|
||||
@@ -1554,6 +1627,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const nightly = props.version.includes('nightly');
|
||||
|
||||
const dialogsVisible =
|
||||
!!askUserRequest ||
|
||||
shouldShowIdePrompt ||
|
||||
isFolderTrustDialogOpen ||
|
||||
adminSettingsChanged ||
|
||||
@@ -1888,6 +1962,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setEmbeddedShellFocused,
|
||||
setAuthContext,
|
||||
handleRestart: async () => {
|
||||
if (process.send) {
|
||||
const remoteSettings = config.getRemoteAdminSettings();
|
||||
if (remoteSettings) {
|
||||
process.send({
|
||||
type: 'admin-settings-update',
|
||||
settings: remoteSettings,
|
||||
});
|
||||
}
|
||||
}
|
||||
await runExitCleanup();
|
||||
process.exit(RELAUNCH_EXIT_CODE);
|
||||
},
|
||||
@@ -1963,6 +2046,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setAuthContext({});
|
||||
setAuthState(AuthState.Updating);
|
||||
}}
|
||||
config={config}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1978,9 +2062,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}}
|
||||
>
|
||||
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
<App />
|
||||
</ShellFocusContext.Provider>
|
||||
<AskUserActionsProvider
|
||||
request={askUserRequest}
|
||||
onSubmit={handleAskUserSubmit}
|
||||
onCancel={handleAskUserCancel}
|
||||
>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
<App />
|
||||
</ShellFocusContext.Provider>
|
||||
</AskUserActionsProvider>
|
||||
</ToolActionsProvider>
|
||||
</AppContext.Provider>
|
||||
</ConfigContext.Provider>
|
||||
|
||||
@@ -32,7 +32,9 @@ export function IdeIntegrationNudge({
|
||||
userSelection: 'no',
|
||||
isExtensionPreInstalled: false,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -1,108 +1,135 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`App > Snapshots > renders default layout correctly 1`] = `
|
||||
"MainContent
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Notifications
|
||||
Composer
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`App > Snapshots > renders screen reader layout correctly 1`] = `
|
||||
"Notifications
|
||||
Footer
|
||||
MainContent
|
||||
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
Composer"
|
||||
`;
|
||||
|
||||
exports[`App > Snapshots > renders with dialogs visible 1`] = `
|
||||
"MainContent
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Notifications
|
||||
DialogManager
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = `
|
||||
"MainContent
|
||||
Notifications
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
HistoryItemDisplay
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Action Required 1 of 1 │
|
||||
│ │
|
||||
│ ? ls list directory │
|
||||
│ │
|
||||
│ ls │
|
||||
│ │
|
||||
│ Allow execution of: 'ls'? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
@@ -110,28 +137,15 @@ Notifications
|
||||
│ 3. No, suggest changes (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Notifications
|
||||
Composer
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { ApiAuthDialog } from './ApiAuthDialog.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
@@ -34,7 +35,7 @@ vi.mock('../components/shared/text-buffer.js', () => ({
|
||||
|
||||
vi.mock('../contexts/UIStateContext.js', () => ({
|
||||
useUIState: vi.fn(() => ({
|
||||
mainAreaWidth: 80,
|
||||
terminalWidth: 80,
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -132,17 +133,20 @@ describe('ApiAuthDialog', () => {
|
||||
|
||||
it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => {
|
||||
render(<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />);
|
||||
// calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler)
|
||||
// Call 0 is ApiAuthDialog (isActive: true)
|
||||
// Call 1 is TextInput (isActive: true, priority: true)
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
|
||||
await keypressHandler({
|
||||
keypressHandler({
|
||||
name: 'c',
|
||||
shift: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
});
|
||||
|
||||
expect(clearApiKey).toHaveBeenCalled();
|
||||
expect(mockBuffer.setText).toHaveBeenCalledWith('');
|
||||
await waitFor(() => {
|
||||
expect(clearApiKey).toHaveBeenCalled();
|
||||
expect(mockBuffer.setText).toHaveBeenCalledWith('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,8 +28,8 @@ export function ApiAuthDialog({
|
||||
error,
|
||||
defaultValue = '',
|
||||
}: ApiAuthDialogProps): React.JSX.Element {
|
||||
const { mainAreaWidth } = useUIState();
|
||||
const viewportWidth = mainAreaWidth - 8;
|
||||
const { terminalWidth } = useUIState();
|
||||
const viewportWidth = terminalWidth - 8;
|
||||
|
||||
const pendingPromise = useRef<{ cancel: () => void } | null>(null);
|
||||
|
||||
@@ -86,10 +86,12 @@ export function ApiAuthDialog({
|
||||
};
|
||||
|
||||
useKeypress(
|
||||
async (key) => {
|
||||
(key) => {
|
||||
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||
await handleClear();
|
||||
void handleClear();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -169,18 +169,20 @@ export function AuthDialog({
|
||||
// Prevent exit if there is an error message.
|
||||
// This means they user is not authenticated yet.
|
||||
if (authError) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (settings.merged.security.auth.selectedType === undefined) {
|
||||
// Prevent exiting if no auth method is set
|
||||
onAuthError(
|
||||
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
|
||||
);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
onSelect(undefined, SettingScope.User);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { LoginWithGoogleRestartDialog } from './LoginWithGoogleRestartDialog.js'
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
|
||||
// Mocks
|
||||
vi.mock('../hooks/useKeypress.js', () => ({
|
||||
@@ -29,6 +30,10 @@ describe('LoginWithGoogleRestartDialog', () => {
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation(() => undefined as never);
|
||||
|
||||
const mockConfig = {
|
||||
getRemoteAdminSettings: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
exitSpy.mockClear();
|
||||
@@ -37,13 +42,21 @@ describe('LoginWithGoogleRestartDialog', () => {
|
||||
|
||||
it('renders correctly', () => {
|
||||
const { lastFrame } = render(
|
||||
<LoginWithGoogleRestartDialog onDismiss={onDismiss} />,
|
||||
<LoginWithGoogleRestartDialog
|
||||
onDismiss={onDismiss}
|
||||
config={mockConfig}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('calls onDismiss when escape is pressed', () => {
|
||||
render(<LoginWithGoogleRestartDialog onDismiss={onDismiss} />);
|
||||
render(
|
||||
<LoginWithGoogleRestartDialog
|
||||
onDismiss={onDismiss}
|
||||
config={mockConfig}
|
||||
/>,
|
||||
);
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
|
||||
keypressHandler({
|
||||
@@ -62,7 +75,12 @@ describe('LoginWithGoogleRestartDialog', () => {
|
||||
async (keyName) => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
render(<LoginWithGoogleRestartDialog onDismiss={onDismiss} />);
|
||||
render(
|
||||
<LoginWithGoogleRestartDialog
|
||||
onDismiss={onDismiss}
|
||||
config={mockConfig}
|
||||
/>,
|
||||
);
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
|
||||
keypressHandler({
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
@@ -12,21 +13,35 @@ import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
|
||||
|
||||
interface LoginWithGoogleRestartDialogProps {
|
||||
onDismiss: () => void;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
export const LoginWithGoogleRestartDialog = ({
|
||||
onDismiss,
|
||||
config,
|
||||
}: LoginWithGoogleRestartDialogProps) => {
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onDismiss();
|
||||
return true;
|
||||
} else if (key.name === 'r' || key.name === 'R') {
|
||||
setTimeout(async () => {
|
||||
if (process.send) {
|
||||
const remoteSettings = config.getRemoteAdminSettings();
|
||||
if (remoteSettings) {
|
||||
process.send({
|
||||
type: 'admin-settings-update',
|
||||
settings: remoteSettings,
|
||||
});
|
||||
}
|
||||
}
|
||||
await runExitCleanup();
|
||||
process.exit(RELAUNCH_EXIT_CODE);
|
||||
}, 100);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { bugCommand } from './bugCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { getVersion } from '@google/gemini-cli-core';
|
||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||
import { formatMemoryUsage } from '../utils/formatters.js';
|
||||
import { formatBytes } from '../utils/formatters.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('open');
|
||||
@@ -68,7 +68,7 @@ vi.mock('../utils/terminalCapabilityManager.js', () => ({
|
||||
describe('bugCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getVersion).mockResolvedValue('0.1.0');
|
||||
vi.mocked(formatMemoryUsage).mockReturnValue('100 MB');
|
||||
vi.mocked(formatBytes).mockReturnValue('100 MB');
|
||||
vi.stubEnv('SANDBOX', 'gemini-test');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||
import { formatMemoryUsage } from '../utils/formatters.js';
|
||||
import { formatBytes } from '../utils/formatters.js';
|
||||
import {
|
||||
IdeClient,
|
||||
sessionId,
|
||||
@@ -45,7 +45,7 @@ export const bugCommand: SlashCommand = {
|
||||
}
|
||||
const modelVersion = config?.getModel() || 'Unknown';
|
||||
const cliVersion = await getVersion();
|
||||
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
|
||||
const memoryUsage = formatBytes(process.memoryUsage().rss);
|
||||
const ideClient = await getIdeClientName(context);
|
||||
const terminalName =
|
||||
terminalCapabilityManager.getTerminalName() || 'Unknown';
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
inferInstallMetadata,
|
||||
} from '../../config/extension-manager.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { stat } from 'node:fs/promises';
|
||||
|
||||
@@ -381,6 +382,38 @@ async function enableAction(context: CommandContext, args: string) {
|
||||
type: MessageType.INFO,
|
||||
text: `Extension "${name}" enabled for the scope "${scope}"`,
|
||||
});
|
||||
|
||||
// Auto-enable any disabled MCP servers for this extension
|
||||
const extension = extensionManager
|
||||
.getExtensions()
|
||||
.find((e) => e.name === name);
|
||||
|
||||
if (extension?.mcpServers) {
|
||||
const mcpEnablementManager = McpServerEnablementManager.getInstance();
|
||||
const mcpClientManager = context.services.config?.getMcpClientManager();
|
||||
const enabledServers = await mcpEnablementManager.autoEnableServers(
|
||||
Object.keys(extension.mcpServers ?? {}),
|
||||
);
|
||||
|
||||
if (mcpClientManager && enabledServers.length > 0) {
|
||||
const restartPromises = enabledServers.map((serverName) =>
|
||||
mcpClientManager.restartServer(serverName).catch((error) => {
|
||||
context.ui.addItem({
|
||||
type: MessageType.WARNING,
|
||||
text: `Failed to restart MCP server '${serverName}': ${getErrorMessage(error)}`,
|
||||
});
|
||||
}),
|
||||
);
|
||||
await Promise.all(restartPromises);
|
||||
}
|
||||
|
||||
if (enabledServers.length > 0) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.INFO,
|
||||
text: `Re-enabled MCP servers: ${enabledServers.join(', ')}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -397,19 +397,6 @@ async function handleEnableDisable(
|
||||
};
|
||||
}
|
||||
|
||||
// Check if server is from an extension
|
||||
const serverKey = Object.keys(servers).find(
|
||||
(key) => normalizeServerId(key) === name,
|
||||
);
|
||||
const server = serverKey ? servers[serverKey] : undefined;
|
||||
if (server?.extension) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Server '${serverName}' is provided by extension '${server.extension.name}'.\nUse '/extensions ${action} ${server.extension.name}' to manage this extension.`,
|
||||
};
|
||||
}
|
||||
|
||||
const manager = McpServerEnablementManager.getInstance();
|
||||
|
||||
if (enable) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
CommandKind,
|
||||
type SlashCommand,
|
||||
type OpenCustomDialogActionReturn,
|
||||
} from './types.js';
|
||||
import { TriageDuplicates } from '../components/triage/TriageDuplicates.js';
|
||||
|
||||
export const oncallCommand: SlashCommand = {
|
||||
name: 'oncall',
|
||||
description: 'Oncall related commands',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'dedup',
|
||||
description: 'Triage issues labeled as status/possible-duplicate',
|
||||
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 = 50;
|
||||
if (args && args.trim().length > 0) {
|
||||
const argArray = args.trim().split(/\s+/);
|
||||
const parsedLimit = parseInt(argArray[0], 10);
|
||||
if (!isNaN(parsedLimit) && parsedLimit > 0) {
|
||||
limit = parsedLimit;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'custom_dialog',
|
||||
component: (
|
||||
<TriageDuplicates
|
||||
config={config}
|
||||
initialLimit={limit}
|
||||
onExit={() => context.ui.removeComponent()}
|
||||
/>
|
||||
),
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -17,7 +17,9 @@ export const AdminSettingsChangedDialog = () => {
|
||||
(key) => {
|
||||
if (keyMatchers[Command.RESTART_APP](key)) {
|
||||
handleRestart();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ interface AppHeaderProps {
|
||||
export const AppHeader = ({ version }: AppHeaderProps) => {
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
const { nightly, mainAreaWidth, bannerData, bannerVisible } = useUIState();
|
||||
const { nightly, terminalWidth, bannerData, bannerVisible } = useUIState();
|
||||
|
||||
const { bannerText } = useBanner(bannerData, config);
|
||||
const { showTips } = useTips();
|
||||
@@ -33,7 +33,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
|
||||
<Header version={version} nightly={nightly} />
|
||||
{bannerVisible && bannerText && (
|
||||
<Banner
|
||||
width={mainAreaWidth}
|
||||
width={terminalWidth}
|
||||
bannerText={bannerText}
|
||||
isWarning={bannerData.warningText !== ''}
|
||||
/>
|
||||
|
||||
@@ -42,6 +42,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -105,6 +106,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
actions(stdin);
|
||||
@@ -123,6 +125,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
// Move down to custom option
|
||||
@@ -157,6 +160,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
// Type a character without navigating down
|
||||
@@ -206,6 +210,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -218,6 +223,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -230,6 +236,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -259,6 +266,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Which testing framework?');
|
||||
@@ -299,6 +307,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
// Answer first question (should auto-advance)
|
||||
@@ -365,6 +374,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -392,6 +402,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
writeKey(stdin, '\x1b[C'); // Right arrow
|
||||
@@ -435,6 +446,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
// Navigate directly to Review tab without answering
|
||||
@@ -469,6 +481,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
// Answer only first question
|
||||
@@ -500,6 +513,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -520,6 +534,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -540,6 +555,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
for (const char of 'abc') {
|
||||
@@ -573,6 +589,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -602,6 +619,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
for (const char of 'useAuth') {
|
||||
@@ -615,9 +633,6 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
|
||||
writeKey(stdin, '\x1b[D'); // Left arrow should work when NOT focusing a text input
|
||||
// Wait, Async question is a CHOICE question, so Left arrow SHOULD work.
|
||||
// But ChoiceQuestionView also captures editing custom option state?
|
||||
// No, only if it is FOCUSING the custom option.
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('useAuth');
|
||||
@@ -650,6 +665,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
for (const char of 'DataTable') {
|
||||
@@ -698,6 +714,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
writeKey(stdin, '\r');
|
||||
@@ -722,6 +739,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
for (const char of 'SomeText') {
|
||||
@@ -766,6 +784,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
// 1. Move to Text Q (Right arrow works for Choice Q)
|
||||
@@ -823,6 +842,7 @@ describe('AskUserDialog', () => {
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
// Answer Q1 and Q2 sequentialy
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
useReducer,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import { Box, Text, useStdout } from 'ink';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import type { Question } from '@google/gemini-cli-core';
|
||||
import { BaseSelectionList } from './shared/BaseSelectionList.js';
|
||||
@@ -25,40 +25,30 @@ import { checkExhaustive } from '../../utils/checks.js';
|
||||
import { TextInput } from './shared/TextInput.js';
|
||||
import { useTextBuffer } from './shared/text-buffer.js';
|
||||
import { UIStateContext } from '../contexts/UIStateContext.js';
|
||||
import { cpLen } from '../utils/textUtils.js';
|
||||
import { getCachedStringWidth } from '../utils/textUtils.js';
|
||||
import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
|
||||
import { DialogFooter } from './shared/DialogFooter.js';
|
||||
|
||||
interface AskUserDialogState {
|
||||
currentQuestionIndex: number;
|
||||
answers: { [key: string]: string };
|
||||
isEditingCustomOption: boolean;
|
||||
cursorEdge: { left: boolean; right: boolean };
|
||||
submitted: boolean;
|
||||
}
|
||||
|
||||
type AskUserDialogAction =
|
||||
| {
|
||||
type: 'NEXT_QUESTION';
|
||||
payload: { maxIndex: number };
|
||||
}
|
||||
| { type: 'PREV_QUESTION' }
|
||||
| {
|
||||
type: 'SET_ANSWER';
|
||||
payload: {
|
||||
index?: number;
|
||||
index: number;
|
||||
answer: string;
|
||||
autoAdvance?: boolean;
|
||||
maxIndex?: number;
|
||||
};
|
||||
}
|
||||
| { type: 'SET_EDITING_CUSTOM'; payload: { isEditing: boolean } }
|
||||
| { type: 'SET_CURSOR_EDGE'; payload: { left: boolean; right: boolean } }
|
||||
| { type: 'SUBMIT' };
|
||||
|
||||
const initialState: AskUserDialogState = {
|
||||
currentQuestionIndex: 0,
|
||||
answers: {},
|
||||
isEditingCustomOption: false,
|
||||
cursorEdge: { left: true, right: true },
|
||||
submitted: false,
|
||||
};
|
||||
|
||||
@@ -71,56 +61,22 @@ function askUserDialogReducerLogic(
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case 'NEXT_QUESTION': {
|
||||
const { maxIndex } = action.payload;
|
||||
if (state.currentQuestionIndex < maxIndex) {
|
||||
return {
|
||||
...state,
|
||||
currentQuestionIndex: state.currentQuestionIndex + 1,
|
||||
isEditingCustomOption: false,
|
||||
cursorEdge: { left: true, right: true },
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
case 'PREV_QUESTION': {
|
||||
if (state.currentQuestionIndex > 0) {
|
||||
return {
|
||||
...state,
|
||||
currentQuestionIndex: state.currentQuestionIndex - 1,
|
||||
isEditingCustomOption: false,
|
||||
cursorEdge: { left: true, right: true },
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
case 'SET_ANSWER': {
|
||||
const { index, answer, autoAdvance, maxIndex } = action.payload;
|
||||
const targetIndex = index ?? state.currentQuestionIndex;
|
||||
const { index, answer } = action.payload;
|
||||
const hasAnswer =
|
||||
answer !== undefined && answer !== null && answer.trim() !== '';
|
||||
const newAnswers = { ...state.answers };
|
||||
|
||||
if (hasAnswer) {
|
||||
newAnswers[targetIndex] = answer;
|
||||
newAnswers[index] = answer;
|
||||
} else {
|
||||
delete newAnswers[targetIndex];
|
||||
delete newAnswers[index];
|
||||
}
|
||||
|
||||
const newState = {
|
||||
return {
|
||||
...state,
|
||||
answers: newAnswers,
|
||||
};
|
||||
|
||||
if (autoAdvance && typeof maxIndex === 'number') {
|
||||
if (newState.currentQuestionIndex < maxIndex) {
|
||||
newState.currentQuestionIndex += 1;
|
||||
newState.isEditingCustomOption = false;
|
||||
newState.cursorEdge = { left: true, right: true };
|
||||
}
|
||||
}
|
||||
|
||||
return newState;
|
||||
}
|
||||
case 'SET_EDITING_CUSTOM': {
|
||||
if (state.isEditingCustomOption === action.payload.isEditing) {
|
||||
@@ -131,16 +87,6 @@ function askUserDialogReducerLogic(
|
||||
isEditingCustomOption: action.payload.isEditing,
|
||||
};
|
||||
}
|
||||
case 'SET_CURSOR_EDGE': {
|
||||
const { left, right } = action.payload;
|
||||
if (state.cursorEdge.left === left && state.cursorEdge.right === right) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
cursorEdge: { left, right },
|
||||
};
|
||||
}
|
||||
case 'SUBMIT': {
|
||||
return {
|
||||
...state,
|
||||
@@ -198,7 +144,9 @@ const ReviewView: React.FC<ReviewViewProps> = ({
|
||||
(key: Key) => {
|
||||
if (keyMatchers[Command.RETURN](key)) {
|
||||
onSubmit();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
@@ -235,11 +183,10 @@ const ReviewView: React.FC<ReviewViewProps> = ({
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel
|
||||
</Text>
|
||||
</Box>
|
||||
<DialogFooter
|
||||
primaryAction="Enter to submit"
|
||||
navigationActions="Tab/Shift+Tab to edit answers"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -251,7 +198,7 @@ interface TextQuestionViewProps {
|
||||
onAnswer: (answer: string) => void;
|
||||
onSelectionChange?: (answer: string) => void;
|
||||
onEditingCustomOption?: (editing: boolean) => void;
|
||||
onCursorEdgeChange?: (edge: { left: boolean; right: boolean }) => void;
|
||||
availableWidth: number;
|
||||
initialAnswer?: string;
|
||||
progressHeader?: React.ReactNode;
|
||||
keyboardHints?: React.ReactNode;
|
||||
@@ -262,18 +209,19 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
onAnswer,
|
||||
onSelectionChange,
|
||||
onEditingCustomOption,
|
||||
onCursorEdgeChange,
|
||||
availableWidth,
|
||||
initialAnswer,
|
||||
progressHeader,
|
||||
keyboardHints,
|
||||
}) => {
|
||||
const uiState = useContext(UIStateContext);
|
||||
const { stdout } = useStdout();
|
||||
const terminalWidth = uiState?.terminalWidth ?? stdout?.columns ?? 80;
|
||||
const prefix = '> ';
|
||||
const horizontalPadding = 4 + 1; // Padding from Box (2) and border (2) + 1 for cursor
|
||||
const bufferWidth =
|
||||
availableWidth - getCachedStringWidth(prefix) - horizontalPadding;
|
||||
|
||||
const buffer = useTextBuffer({
|
||||
initialText: initialAnswer,
|
||||
viewport: { width: terminalWidth - 10, height: 1 },
|
||||
viewport: { width: Math.max(1, bufferWidth), height: 1 },
|
||||
singleLine: true,
|
||||
isValidPath: () => false,
|
||||
});
|
||||
@@ -289,32 +237,19 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
}
|
||||
}, [textValue, onSelectionChange]);
|
||||
|
||||
// Sync cursor edge state with parent - only when it actually changes
|
||||
const lastEdgeRef = useRef<{ left: boolean; right: boolean } | null>(null);
|
||||
useEffect(() => {
|
||||
const isLeft = buffer.cursor[1] === 0;
|
||||
const isRight = buffer.cursor[1] === cpLen(buffer.lines[0] || '');
|
||||
if (
|
||||
!lastEdgeRef.current ||
|
||||
isLeft !== lastEdgeRef.current.left ||
|
||||
isRight !== lastEdgeRef.current.right
|
||||
) {
|
||||
onCursorEdgeChange?.({ left: isLeft, right: isRight });
|
||||
lastEdgeRef.current = { left: isLeft, right: isRight };
|
||||
}
|
||||
}, [buffer.cursor, buffer.lines, onCursorEdgeChange]);
|
||||
|
||||
// Handle Ctrl+C to clear all text
|
||||
const handleExtraKeys = useCallback(
|
||||
(key: Key) => {
|
||||
if (keyMatchers[Command.QUIT](key)) {
|
||||
buffer.setText('');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[buffer],
|
||||
);
|
||||
|
||||
useKeypress(handleExtraKeys, { isActive: true });
|
||||
useKeypress(handleExtraKeys, { isActive: true, priority: true });
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(val: string) => {
|
||||
@@ -445,7 +380,7 @@ interface ChoiceQuestionViewProps {
|
||||
onAnswer: (answer: string) => void;
|
||||
onSelectionChange?: (answer: string) => void;
|
||||
onEditingCustomOption?: (editing: boolean) => void;
|
||||
onCursorEdgeChange?: (edge: { left: boolean; right: boolean }) => void;
|
||||
availableWidth: number;
|
||||
initialAnswer?: string;
|
||||
progressHeader?: React.ReactNode;
|
||||
keyboardHints?: React.ReactNode;
|
||||
@@ -456,14 +391,33 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
onAnswer,
|
||||
onSelectionChange,
|
||||
onEditingCustomOption,
|
||||
onCursorEdgeChange,
|
||||
initialAnswer,
|
||||
progressHeader,
|
||||
keyboardHints,
|
||||
}) => {
|
||||
const uiState = useContext(UIStateContext);
|
||||
const { stdout } = useStdout();
|
||||
const terminalWidth = uiState?.terminalWidth ?? stdout?.columns ?? 80;
|
||||
const terminalWidth = uiState?.terminalWidth ?? 80;
|
||||
const availableWidth = terminalWidth;
|
||||
|
||||
const numOptions =
|
||||
(question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0);
|
||||
const numLen = String(numOptions).length;
|
||||
const radioWidth = 2; // "● "
|
||||
const numberWidth = numLen + 2; // e.g., "1. "
|
||||
const checkboxWidth = question.multiSelect ? 4 : 1; // "[x] " or " "
|
||||
const checkmarkWidth = question.multiSelect ? 0 : 2; // "" or " ✓"
|
||||
const cursorPadding = 1; // Extra character for cursor at end of line
|
||||
const outerBoxPadding = 4; // border (2) + paddingX (2)
|
||||
|
||||
const horizontalPadding =
|
||||
outerBoxPadding +
|
||||
radioWidth +
|
||||
numberWidth +
|
||||
checkboxWidth +
|
||||
checkmarkWidth +
|
||||
cursorPadding;
|
||||
|
||||
const bufferWidth = availableWidth - horizontalPadding;
|
||||
|
||||
const questionOptions = useMemo(
|
||||
() => question.options ?? [],
|
||||
@@ -537,29 +491,13 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
|
||||
const customBuffer = useTextBuffer({
|
||||
initialText: initialCustomText,
|
||||
viewport: { width: terminalWidth - 20, height: 1 },
|
||||
viewport: { width: Math.max(1, bufferWidth), height: 1 },
|
||||
singleLine: true,
|
||||
isValidPath: () => false,
|
||||
});
|
||||
|
||||
const customOptionText = customBuffer.text;
|
||||
|
||||
// Sync cursor edge state with parent - only when it actually changes
|
||||
const lastEdgeRef = useRef<{ left: boolean; right: boolean } | null>(null);
|
||||
useEffect(() => {
|
||||
const isLeft = customBuffer.cursor[1] === 0;
|
||||
const isRight =
|
||||
customBuffer.cursor[1] === cpLen(customBuffer.lines[0] || '');
|
||||
if (
|
||||
!lastEdgeRef.current ||
|
||||
isLeft !== lastEdgeRef.current.left ||
|
||||
isRight !== lastEdgeRef.current.right
|
||||
) {
|
||||
onCursorEdgeChange?.({ left: isLeft, right: isRight });
|
||||
lastEdgeRef.current = { left: isLeft, right: isRight };
|
||||
}
|
||||
}, [customBuffer.cursor, customBuffer.lines, onCursorEdgeChange]);
|
||||
|
||||
// Helper to build answer string from selections
|
||||
const buildAnswerString = useCallback(
|
||||
(
|
||||
@@ -607,31 +545,51 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
// If focusing custom option, handle Ctrl+C
|
||||
if (isCustomOptionFocused && keyMatchers[Command.QUIT](key)) {
|
||||
customBuffer.setText('');
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Type-to-jump: if a printable character is typed and not focused, jump to custom
|
||||
// Don't jump if a navigation or selection key is pressed
|
||||
if (
|
||||
keyMatchers[Command.DIALOG_NAVIGATION_UP](key) ||
|
||||
keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key) ||
|
||||
keyMatchers[Command.DIALOG_NEXT](key) ||
|
||||
keyMatchers[Command.DIALOG_PREV](key) ||
|
||||
keyMatchers[Command.MOVE_LEFT](key) ||
|
||||
keyMatchers[Command.MOVE_RIGHT](key) ||
|
||||
keyMatchers[Command.RETURN](key) ||
|
||||
keyMatchers[Command.ESCAPE](key) ||
|
||||
keyMatchers[Command.QUIT](key)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it's a numeric quick selection key (if numbers are shown)
|
||||
const isNumeric = /^[0-9]$/.test(key.sequence);
|
||||
if (isNumeric) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Type-to-jump: if printable characters are typed and not focused, jump to custom
|
||||
const isPrintable =
|
||||
key.sequence &&
|
||||
key.sequence.length === 1 &&
|
||||
!key.ctrl &&
|
||||
!key.alt &&
|
||||
key.sequence.charCodeAt(0) >= 32;
|
||||
(key.sequence.length > 1 || key.sequence.charCodeAt(0) >= 32);
|
||||
|
||||
const isNumber = /^[0-9]$/.test(key.sequence);
|
||||
|
||||
if (isPrintable && !isCustomOptionFocused && !isNumber) {
|
||||
if (isPrintable && !isCustomOptionFocused) {
|
||||
dispatch({ type: 'SET_CUSTOM_FOCUSED', payload: { focused: true } });
|
||||
onEditingCustomOption?.(true);
|
||||
// We can't easily inject the first key into useTextBuffer's internal state
|
||||
// but TextInput will handle subsequent keys once it's focused.
|
||||
// For IME or multi-char sequences, we want to capture the whole thing.
|
||||
// If it's a single char, we start the buffer with it.
|
||||
customBuffer.setText(key.sequence);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[isCustomOptionFocused, customBuffer, onEditingCustomOption],
|
||||
);
|
||||
|
||||
useKeypress(handleExtraKeys, { isActive: true });
|
||||
useKeypress(handleExtraKeys, { isActive: true, priority: true });
|
||||
|
||||
const selectionItems = useMemo((): Array<SelectionListItem<OptionItem>> => {
|
||||
const list: Array<SelectionListItem<OptionItem>> = questionOptions.map(
|
||||
@@ -841,11 +799,6 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A dialog component for asking the user a series of questions.
|
||||
* Supports multiple question types (text, choice, yes/no, multi-select),
|
||||
* navigation between questions, and a final review step.
|
||||
*/
|
||||
export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
questions,
|
||||
onSubmit,
|
||||
@@ -853,30 +806,29 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
onActiveTextInputChange,
|
||||
}) => {
|
||||
const [state, dispatch] = useReducer(askUserDialogReducerLogic, initialState);
|
||||
const {
|
||||
currentQuestionIndex,
|
||||
answers,
|
||||
isEditingCustomOption,
|
||||
cursorEdge,
|
||||
submitted,
|
||||
} = state;
|
||||
const { answers, isEditingCustomOption, submitted } = state;
|
||||
|
||||
// Use refs for synchronous checks to prevent race conditions in handleCancel
|
||||
const isEditingCustomOptionRef = useRef(false);
|
||||
isEditingCustomOptionRef.current = isEditingCustomOption;
|
||||
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;
|
||||
|
||||
const { currentIndex, goToNextTab, goToPrevTab } = useTabbedNavigation({
|
||||
tabCount,
|
||||
isActive: !submitted && questions.length > 1,
|
||||
enableArrowNavigation: false, // We'll handle arrows via textBuffer callbacks or manually
|
||||
enableTabKey: false, // We'll handle tab manually to match existing behavior
|
||||
});
|
||||
|
||||
const currentQuestionIndex = currentIndex;
|
||||
|
||||
const handleEditingCustomOption = useCallback((isEditing: boolean) => {
|
||||
dispatch({ type: 'SET_EDITING_CUSTOM', payload: { isEditing } });
|
||||
}, []);
|
||||
|
||||
const handleCursorEdgeChange = useCallback(
|
||||
(edge: { left: boolean; right: boolean }) => {
|
||||
dispatch({ type: 'SET_CURSOR_EDGE', payload: edge });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Sync isEditingCustomOption state with parent for global keypress handling
|
||||
useEffect(() => {
|
||||
onActiveTextInputChange?.(isEditingCustomOption);
|
||||
return () => {
|
||||
@@ -884,70 +836,58 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
};
|
||||
}, [isEditingCustomOption, onActiveTextInputChange]);
|
||||
|
||||
// Handle Escape or Ctrl+C to cancel (but not Ctrl+C when editing custom option)
|
||||
const handleCancel = useCallback(
|
||||
(key: Key) => {
|
||||
if (submitted) return;
|
||||
if (submitted) return false;
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
onCancel();
|
||||
} else if (
|
||||
keyMatchers[Command.QUIT](key) &&
|
||||
!isEditingCustomOptionRef.current
|
||||
) {
|
||||
return true;
|
||||
} else if (keyMatchers[Command.QUIT](key) && !isEditingCustomOption) {
|
||||
onCancel();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[onCancel, submitted],
|
||||
[onCancel, submitted, isEditingCustomOption],
|
||||
);
|
||||
|
||||
useKeypress(handleCancel, {
|
||||
isActive: !submitted,
|
||||
});
|
||||
|
||||
// Review tab is at index questions.length (after all questions)
|
||||
const reviewTabIndex = questions.length;
|
||||
const isOnReviewTab = currentQuestionIndex === reviewTabIndex;
|
||||
|
||||
// Bidirectional navigation between questions using custom useKeypress for consistency
|
||||
const handleNavigation = useCallback(
|
||||
(key: Key) => {
|
||||
if (submitted) return;
|
||||
if (submitted || questions.length <= 1) return false;
|
||||
|
||||
const isTab = key.name === 'tab';
|
||||
const isShiftTab = isTab && key.shift;
|
||||
const isPlainTab = isTab && !key.shift;
|
||||
const isNextKey = keyMatchers[Command.DIALOG_NEXT](key);
|
||||
const isPrevKey = keyMatchers[Command.DIALOG_PREV](key);
|
||||
|
||||
const isRight = key.name === 'right' && !key.ctrl && !key.alt;
|
||||
const isLeft = key.name === 'left' && !key.ctrl && !key.alt;
|
||||
const isRight = keyMatchers[Command.MOVE_RIGHT](key);
|
||||
const isLeft = keyMatchers[Command.MOVE_LEFT](key);
|
||||
|
||||
// Tab always works. Arrows work if NOT editing OR if at the corresponding edge.
|
||||
const shouldGoNext =
|
||||
isPlainTab || (isRight && (!isEditingCustomOption || cursorEdge.right));
|
||||
const shouldGoPrev =
|
||||
isShiftTab || (isLeft && (!isEditingCustomOption || cursorEdge.left));
|
||||
// Tab keys always trigger navigation.
|
||||
// Arrows trigger navigation if NOT in a text input OR if the input bubbles the event (already at edge).
|
||||
const shouldGoNext = isNextKey || isRight;
|
||||
const shouldGoPrev = isPrevKey || isLeft;
|
||||
|
||||
if (shouldGoNext) {
|
||||
// Allow navigation up to Review tab for multi-question flows
|
||||
const maxIndex =
|
||||
questions.length > 1 ? reviewTabIndex : questions.length - 1;
|
||||
dispatch({
|
||||
type: 'NEXT_QUESTION',
|
||||
payload: { maxIndex },
|
||||
});
|
||||
goToNextTab();
|
||||
return true;
|
||||
} else if (shouldGoPrev) {
|
||||
dispatch({
|
||||
type: 'PREV_QUESTION',
|
||||
});
|
||||
goToPrevTab();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[isEditingCustomOption, cursorEdge, questions, reviewTabIndex, submitted],
|
||||
[questions.length, submitted, goToNextTab, goToPrevTab],
|
||||
);
|
||||
|
||||
useKeypress(handleNavigation, {
|
||||
isActive: questions.length > 1 && !submitted,
|
||||
});
|
||||
|
||||
// Effect to trigger submission when state.submitted becomes true
|
||||
useEffect(() => {
|
||||
if (submitted) {
|
||||
onSubmit(answers);
|
||||
@@ -958,24 +898,23 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
(answer: string) => {
|
||||
if (submitted) return;
|
||||
|
||||
const reviewTabIndex = questions.length;
|
||||
dispatch({
|
||||
type: 'SET_ANSWER',
|
||||
payload: {
|
||||
index: currentQuestionIndex,
|
||||
answer,
|
||||
autoAdvance: questions.length > 1,
|
||||
maxIndex: reviewTabIndex,
|
||||
},
|
||||
});
|
||||
|
||||
if (questions.length === 1) {
|
||||
if (questions.length > 1) {
|
||||
goToNextTab();
|
||||
} else {
|
||||
dispatch({ type: 'SUBMIT' });
|
||||
}
|
||||
},
|
||||
[questions.length, submitted],
|
||||
[currentQuestionIndex, questions.length, submitted, goToNextTab],
|
||||
);
|
||||
|
||||
// Submit from Review tab
|
||||
const handleReviewSubmit = useCallback(() => {
|
||||
if (submitted) return;
|
||||
dispatch({ type: 'SUBMIT' });
|
||||
@@ -987,12 +926,12 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
dispatch({
|
||||
type: 'SET_ANSWER',
|
||||
payload: {
|
||||
index: currentQuestionIndex,
|
||||
answer,
|
||||
autoAdvance: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
[submitted],
|
||||
[submitted, currentQuestionIndex],
|
||||
);
|
||||
|
||||
const answeredIndices = useMemo(
|
||||
@@ -1002,7 +941,6 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
|
||||
const currentQuestion = questions[currentQuestionIndex];
|
||||
|
||||
// For yesno type, generate Yes/No options and force single-select
|
||||
const effectiveQuestion = useMemo(() => {
|
||||
if (currentQuestion?.type === 'yesno') {
|
||||
return {
|
||||
@@ -1017,13 +955,11 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
return currentQuestion;
|
||||
}, [currentQuestion]);
|
||||
|
||||
// Build tabs array for TabHeader
|
||||
const tabs = useMemo((): Tab[] => {
|
||||
const questionTabs: Tab[] = questions.map((q, i) => ({
|
||||
key: String(i),
|
||||
header: q.header,
|
||||
}));
|
||||
// Add review tab when there are multiple questions
|
||||
if (questions.length > 1) {
|
||||
questionTabs.push({
|
||||
key: 'review',
|
||||
@@ -1043,63 +979,74 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
/>
|
||||
) : null;
|
||||
|
||||
// Render Review tab when on it
|
||||
if (isOnReviewTab) {
|
||||
return (
|
||||
<ReviewView
|
||||
questions={questions}
|
||||
answers={answers}
|
||||
onSubmit={handleReviewSubmit}
|
||||
progressHeader={progressHeader}
|
||||
/>
|
||||
<Box aria-label="Review your answers">
|
||||
<ReviewView
|
||||
questions={questions}
|
||||
answers={answers}
|
||||
onSubmit={handleReviewSubmit}
|
||||
progressHeader={progressHeader}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Safeguard for invalid question index
|
||||
if (!currentQuestion) return null;
|
||||
|
||||
const keyboardHints = (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{currentQuestion.type === 'text' || isEditingCustomOption
|
||||
? questions.length > 1
|
||||
? 'Enter to submit · Tab/Shift+Tab to switch questions · Esc to cancel'
|
||||
: 'Enter to submit · Esc to cancel'
|
||||
: questions.length > 1
|
||||
? 'Enter to select · ←/→ to switch questions · Esc to cancel'
|
||||
: 'Enter to select · ↑/↓ to navigate · Esc to cancel'}
|
||||
</Text>
|
||||
</Box>
|
||||
<DialogFooter
|
||||
primaryAction={
|
||||
currentQuestion.type === 'text' || isEditingCustomOption
|
||||
? 'Enter to submit'
|
||||
: 'Enter to select'
|
||||
}
|
||||
navigationActions={
|
||||
questions.length > 1
|
||||
? currentQuestion.type === 'text' || isEditingCustomOption
|
||||
? 'Tab/Shift+Tab to switch questions'
|
||||
: '←/→ to switch questions'
|
||||
: currentQuestion.type === 'text' || isEditingCustomOption
|
||||
? undefined
|
||||
: '↑/↓ to navigate'
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
// Render text-type or choice-type question view
|
||||
if (currentQuestion.type === 'text') {
|
||||
return (
|
||||
const questionView =
|
||||
currentQuestion.type === 'text' ? (
|
||||
<TextQuestionView
|
||||
key={currentQuestionIndex}
|
||||
question={currentQuestion}
|
||||
onAnswer={handleAnswer}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onEditingCustomOption={handleEditingCustomOption}
|
||||
onCursorEdgeChange={handleCursorEdgeChange}
|
||||
availableWidth={availableWidth}
|
||||
initialAnswer={answers[currentQuestionIndex]}
|
||||
progressHeader={progressHeader}
|
||||
keyboardHints={keyboardHints}
|
||||
/>
|
||||
) : (
|
||||
<ChoiceQuestionView
|
||||
key={currentQuestionIndex}
|
||||
question={effectiveQuestion}
|
||||
onAnswer={handleAnswer}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onEditingCustomOption={handleEditingCustomOption}
|
||||
availableWidth={availableWidth}
|
||||
initialAnswer={answers[currentQuestionIndex]}
|
||||
progressHeader={progressHeader}
|
||||
keyboardHints={keyboardHints}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChoiceQuestionView
|
||||
key={currentQuestionIndex}
|
||||
question={effectiveQuestion}
|
||||
onAnswer={handleAnswer}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onEditingCustomOption={handleEditingCustomOption}
|
||||
onCursorEdgeChange={handleCursorEdgeChange}
|
||||
initialAnswer={answers[currentQuestionIndex]}
|
||||
progressHeader={progressHeader}
|
||||
keyboardHints={keyboardHints}
|
||||
/>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={availableWidth}
|
||||
aria-label={`Question ${currentQuestionIndex + 1} of ${questions.length}: ${currentQuestion.question}`}
|
||||
>
|
||||
{questionView}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { AskUserDialog } from './AskUserDialog.js';
|
||||
import type { Question } from '@google/gemini-cli-core';
|
||||
|
||||
describe('Key Bubbling Regression', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const choiceQuestion: Question[] = [
|
||||
{
|
||||
question: 'Choice Q?',
|
||||
header: 'Choice',
|
||||
options: [
|
||||
{ label: 'Option 1', description: '' },
|
||||
{ label: 'Option 2', description: '' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
it('does not navigate when pressing "j" or "k" in a focused text input', async () => {
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={choiceQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
// 1. Move down to "Enter a custom value" (3rd item)
|
||||
act(() => {
|
||||
stdin.write('\x1b[B'); // Down arrow to Option 2
|
||||
});
|
||||
act(() => {
|
||||
stdin.write('\x1b[B'); // Down arrow to Custom
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Enter a custom value');
|
||||
});
|
||||
|
||||
// 2. Type "j"
|
||||
act(() => {
|
||||
stdin.write('j');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('j');
|
||||
// Verify we are still focusing the custom option (3rd item in list)
|
||||
expect(lastFrame()).toMatch(/● 3\.\s+j/);
|
||||
});
|
||||
|
||||
// 3. Type "k"
|
||||
act(() => {
|
||||
stdin.write('k');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('jk');
|
||||
expect(lastFrame()).toMatch(/● 3\.\s+jk/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,10 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import {
|
||||
renderWithProviders,
|
||||
createMockSettings,
|
||||
} from '../../test-utils/render.js';
|
||||
import { CliSpinner } from './CliSpinner.js';
|
||||
import { debugState } from '../debug.js';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
@@ -16,9 +19,15 @@ describe('<CliSpinner />', () => {
|
||||
|
||||
it('should increment debugNumAnimatedComponents on mount and decrement on unmount', () => {
|
||||
expect(debugState.debugNumAnimatedComponents).toBe(0);
|
||||
const { unmount } = render(<CliSpinner />);
|
||||
const { unmount } = renderWithProviders(<CliSpinner />);
|
||||
expect(debugState.debugNumAnimatedComponents).toBe(1);
|
||||
unmount();
|
||||
expect(debugState.debugNumAnimatedComponents).toBe(0);
|
||||
});
|
||||
|
||||
it('should not render when showSpinner is false', () => {
|
||||
const settings = createMockSettings({ ui: { showSpinner: false } });
|
||||
const { lastFrame } = renderWithProviders(<CliSpinner />, { settings });
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,16 +7,27 @@
|
||||
import Spinner from 'ink-spinner';
|
||||
import { type ComponentProps, useEffect } from 'react';
|
||||
import { debugState } from '../debug.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
|
||||
export type SpinnerProps = ComponentProps<typeof Spinner>;
|
||||
|
||||
export const CliSpinner = (props: SpinnerProps) => {
|
||||
const settings = useSettings();
|
||||
const shouldShow = settings.merged.ui?.showSpinner !== false;
|
||||
|
||||
useEffect(() => {
|
||||
debugState.debugNumAnimatedComponents++;
|
||||
return () => {
|
||||
debugState.debugNumAnimatedComponents--;
|
||||
};
|
||||
}, []);
|
||||
if (shouldShow) {
|
||||
debugState.debugNumAnimatedComponents++;
|
||||
return () => {
|
||||
debugState.debugNumAnimatedComponents--;
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [shouldShow]);
|
||||
|
||||
if (!shouldShow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Spinner {...props} />;
|
||||
};
|
||||
|
||||
@@ -50,7 +50,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={uiState.mainAreaWidth}
|
||||
width={uiState.terminalWidth}
|
||||
flexGrow={0}
|
||||
flexShrink={0}
|
||||
>
|
||||
@@ -113,7 +113,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
maxHeight={
|
||||
uiState.constrainHeight ? debugConsoleMaxHeight : undefined
|
||||
}
|
||||
width={uiState.mainAreaWidth}
|
||||
width={uiState.terminalWidth}
|
||||
hasFocus={uiState.showErrorDetails}
|
||||
/>
|
||||
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('DialogManager', () => {
|
||||
constrainHeight: false,
|
||||
terminalHeight: 24,
|
||||
staticExtraHeight: 0,
|
||||
mainAreaWidth: 80,
|
||||
terminalWidth: 80,
|
||||
confirmUpdateExtensionRequests: [],
|
||||
showIdeRestartPrompt: false,
|
||||
proQuotaRequest: null,
|
||||
|
||||
@@ -32,6 +32,8 @@ 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';
|
||||
|
||||
@@ -50,8 +52,28 @@ export const DialogManager = ({
|
||||
|
||||
const uiState = useUIState();
|
||||
const uiActions = useUIActions();
|
||||
const { constrainHeight, terminalHeight, staticExtraHeight, mainAreaWidth } =
|
||||
uiState;
|
||||
const {
|
||||
constrainHeight,
|
||||
terminalHeight,
|
||||
staticExtraHeight,
|
||||
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 />;
|
||||
@@ -147,7 +169,7 @@ export const DialogManager = ({
|
||||
availableTerminalHeight={
|
||||
constrainHeight ? terminalHeight - staticExtraHeight : undefined
|
||||
}
|
||||
terminalWidth={mainAreaWidth}
|
||||
terminalWidth={uiTerminalWidth}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -50,10 +50,13 @@ export function EditorSettingsDialog({
|
||||
(key) => {
|
||||
if (key.name === 'tab') {
|
||||
setFocusedSection((prev) => (prev === 'editor' ? 'scope' : 'editor'));
|
||||
return true;
|
||||
}
|
||||
if (key.name === 'escape') {
|
||||
onExit();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -59,7 +59,9 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
handleExit();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: !isRestarting },
|
||||
);
|
||||
|
||||
@@ -42,7 +42,7 @@ export const Footer: React.FC = () => {
|
||||
promptTokenCount,
|
||||
nightly,
|
||||
isTrustedFolder,
|
||||
mainAreaWidth,
|
||||
terminalWidth,
|
||||
} = {
|
||||
model: uiState.currentModel,
|
||||
targetDir: config.getTargetDir(),
|
||||
@@ -55,7 +55,7 @@ export const Footer: React.FC = () => {
|
||||
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
|
||||
nightly: uiState.nightly,
|
||||
isTrustedFolder: uiState.isTrustedFolder,
|
||||
mainAreaWidth: uiState.mainAreaWidth,
|
||||
terminalWidth: uiState.terminalWidth,
|
||||
};
|
||||
|
||||
const showMemoryUsage =
|
||||
@@ -65,7 +65,7 @@ export const Footer: React.FC = () => {
|
||||
const hideModelInfo = settings.merged.ui.footer.hideModelInfo;
|
||||
const hideContextPercentage = settings.merged.ui.footer.hideContextPercentage;
|
||||
|
||||
const pathLength = Math.max(20, Math.floor(mainAreaWidth * 0.25));
|
||||
const pathLength = Math.max(20, Math.floor(terminalWidth * 0.25));
|
||||
const displayPath = shortenPath(tildeifyPath(targetDir), pathLength);
|
||||
|
||||
const justifyContent = hideCWD && hideModelInfo ? 'center' : 'space-between';
|
||||
@@ -76,7 +76,7 @@ export const Footer: React.FC = () => {
|
||||
return (
|
||||
<Box
|
||||
justifyContent={justifyContent}
|
||||
width={mainAreaWidth}
|
||||
width={terminalWidth}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
paddingX={1}
|
||||
@@ -134,7 +134,7 @@ export const Footer: React.FC = () => {
|
||||
) : (
|
||||
<Text color={theme.status.error}>
|
||||
no sandbox
|
||||
{mainAreaWidth >= 100 && (
|
||||
{terminalWidth >= 100 && (
|
||||
<Text color={theme.text.secondary}> (see /docs)</Text>
|
||||
)}
|
||||
</Text>
|
||||
@@ -155,7 +155,7 @@ export const Footer: React.FC = () => {
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={promptTokenCount}
|
||||
model={model}
|
||||
terminalWidth={mainAreaWidth}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { type SlashCommand, CommandKind } from '../commands/types.js';
|
||||
import { KEYBOARD_SHORTCUTS_URL } from '../constants.js';
|
||||
import { sanitizeForListDisplay } from '../utils/textUtils.js';
|
||||
import { sanitizeForDisplay } from '../utils/textUtils.js';
|
||||
|
||||
interface Help {
|
||||
commands: readonly SlashCommand[];
|
||||
@@ -79,7 +79,7 @@ export const Help: React.FC<Help> = ({ commands }) => (
|
||||
<Text color={theme.text.secondary}> [MCP]</Text>
|
||||
)}
|
||||
{command.description &&
|
||||
' - ' + sanitizeForListDisplay(command.description, 100)}
|
||||
' - ' + sanitizeForDisplay(command.description, 100)}
|
||||
</Text>
|
||||
{command.subCommands &&
|
||||
command.subCommands
|
||||
@@ -91,7 +91,7 @@ export const Help: React.FC<Help> = ({ commands }) => (
|
||||
{subCommand.name}
|
||||
</Text>
|
||||
{subCommand.description &&
|
||||
' - ' + sanitizeForListDisplay(subCommand.description, 100)}
|
||||
' - ' + sanitizeForDisplay(subCommand.description, 100)}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -67,7 +67,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
<UserMessage text={itemForDisplay.text} width={terminalWidth} />
|
||||
)}
|
||||
{itemForDisplay.type === 'user_shell' && (
|
||||
<UserShellMessage text={itemForDisplay.text} />
|
||||
<UserShellMessage text={itemForDisplay.text} width={terminalWidth} />
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini' && (
|
||||
<GeminiMessage
|
||||
@@ -141,6 +141,8 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
isFocused={isFocused}
|
||||
activeShellPtyId={activeShellPtyId}
|
||||
embeddedShellFocused={embeddedShellFocused}
|
||||
borderTop={itemForDisplay.borderTop}
|
||||
borderBottom={itemForDisplay.borderBottom}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'compression' && (
|
||||
|
||||
@@ -21,7 +21,9 @@ export const IdeTrustChangeDialog = ({ reason }: IdeTrustChangeDialogProps) => {
|
||||
if (key.name === 'r' || key.name === 'R') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
relaunchApp();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
createMockSettings,
|
||||
} from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { act } from 'react';
|
||||
import { act, useState } from 'react';
|
||||
import type { InputPromptProps } from './InputPrompt.js';
|
||||
import { InputPrompt } from './InputPrompt.js';
|
||||
import type { TextBuffer } from './shared/text-buffer.js';
|
||||
@@ -23,6 +23,7 @@ import * as path from 'node:path';
|
||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import { CommandKind } from '../commands/types.js';
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { Text } from 'ink';
|
||||
import type { UseShellHistoryReturn } from '../hooks/useShellHistory.js';
|
||||
import { useShellHistory } from '../hooks/useShellHistory.js';
|
||||
import type { UseCommandCompletionReturn } from '../hooks/useCommandCompletion.js';
|
||||
@@ -42,6 +43,8 @@ import stripAnsi from 'strip-ansi';
|
||||
import chalk from 'chalk';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
||||
import type { UIState } from '../contexts/UIStateContext.js';
|
||||
import { isLowColorDepth } from '../utils/terminalUtils.js';
|
||||
|
||||
vi.mock('../hooks/useShellHistory.js');
|
||||
vi.mock('../hooks/useCommandCompletion.js');
|
||||
@@ -50,6 +53,20 @@ vi.mock('../hooks/useReverseSearchCompletion.js');
|
||||
vi.mock('clipboardy');
|
||||
vi.mock('../utils/clipboardUtils.js');
|
||||
vi.mock('../hooks/useKittyKeyboardProtocol.js');
|
||||
vi.mock('../utils/terminalUtils.js', () => ({
|
||||
isLowColorDepth: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
// Mock ink BEFORE importing components that use it to intercept terminalCursorPosition
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ink')>();
|
||||
return {
|
||||
...actual,
|
||||
Text: vi.fn(({ children, ...props }) => (
|
||||
<actual.Text {...props}>{children}</actual.Text>
|
||||
)),
|
||||
};
|
||||
});
|
||||
|
||||
const mockSlashCommands: SlashCommand[] = [
|
||||
{
|
||||
@@ -260,6 +277,8 @@ describe('InputPrompt', () => {
|
||||
getProjectRoot: () => path.join('test', 'project'),
|
||||
getTargetDir: () => path.join('test', 'project', 'src'),
|
||||
getVimMode: () => false,
|
||||
getUseBackgroundColor: () => true,
|
||||
getTerminalBackground: () => undefined,
|
||||
getWorkspaceContext: () => ({
|
||||
getDirectories: () => ['/test/project/src'],
|
||||
}),
|
||||
@@ -1320,6 +1339,168 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('Background Color Styles', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(isLowColorDepth).mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should render with background color by default', async () => {
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrame();
|
||||
expect(frame).toContain('▀');
|
||||
expect(frame).toContain('▄');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ color: 'black', name: 'black' },
|
||||
{ color: '#000000', name: '#000000' },
|
||||
{ color: '#000', name: '#000' },
|
||||
{ color: undefined, name: 'default (black)' },
|
||||
{ color: 'white', name: 'white' },
|
||||
{ color: '#ffffff', name: '#ffffff' },
|
||||
{ color: '#fff', name: '#fff' },
|
||||
])(
|
||||
'should render with safe grey background but NO side borders in 8-bit mode when background is $name',
|
||||
async ({ color }) => {
|
||||
vi.mocked(isLowColorDepth).mockReturnValue(true);
|
||||
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
uiState: {
|
||||
terminalBackgroundColor: color,
|
||||
} as Partial<UIState>,
|
||||
},
|
||||
);
|
||||
|
||||
const isWhite =
|
||||
color === 'white' || color === '#ffffff' || color === '#fff';
|
||||
const expectedBgColor = isWhite ? '#eeeeee' : '#1c1c1c';
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrame();
|
||||
|
||||
// Use chalk to get the expected background color escape sequence
|
||||
const bgCheck = chalk.bgHex(expectedBgColor)(' ');
|
||||
const bgCode = bgCheck.substring(0, bgCheck.indexOf(' '));
|
||||
|
||||
// Background color code should be present
|
||||
expect(frame).toContain(bgCode);
|
||||
// Background characters should be rendered
|
||||
expect(frame).toContain('▀');
|
||||
expect(frame).toContain('▄');
|
||||
// Side borders should STILL be removed
|
||||
expect(frame).not.toContain('│');
|
||||
});
|
||||
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it('should NOT render with background color but SHOULD render horizontal lines when color depth is < 24 and background is NOT black', async () => {
|
||||
vi.mocked(isLowColorDepth).mockReturnValue(true);
|
||||
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
uiState: {
|
||||
terminalBackgroundColor: '#333333',
|
||||
} as Partial<UIState>,
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrame();
|
||||
expect(frame).not.toContain('▀');
|
||||
expect(frame).not.toContain('▄');
|
||||
// It SHOULD have horizontal fallback lines
|
||||
expect(frame).toContain('─');
|
||||
// It SHOULD NOT have vertical side borders (standard Box borders have │)
|
||||
expect(frame).not.toContain('│');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
it('should handle 4-bit color mode (16 colors) as low color depth', async () => {
|
||||
vi.mocked(isLowColorDepth).mockReturnValue(true);
|
||||
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrame();
|
||||
|
||||
expect(frame).toContain('▀');
|
||||
|
||||
expect(frame).not.toContain('│');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render horizontal lines (but NO background) in 8-bit mode when background is blue', async () => {
|
||||
vi.mocked(isLowColorDepth).mockReturnValue(true);
|
||||
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
|
||||
{
|
||||
uiState: {
|
||||
terminalBackgroundColor: 'blue',
|
||||
} as Partial<UIState>,
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrame();
|
||||
|
||||
// Should NOT have background characters
|
||||
|
||||
expect(frame).not.toContain('▀');
|
||||
|
||||
expect(frame).not.toContain('▄');
|
||||
|
||||
// Should HAVE horizontal lines from the fallback Box borders
|
||||
|
||||
// Box style "round" uses these for top/bottom
|
||||
|
||||
expect(frame).toContain('─');
|
||||
|
||||
// Should NOT have vertical side borders
|
||||
|
||||
expect(frame).not.toContain('│');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render with plain borders when useBackgroundColor is false', async () => {
|
||||
props.config.getUseBackgroundColor = () => false;
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrame();
|
||||
expect(frame).not.toContain('▀');
|
||||
expect(frame).not.toContain('▄');
|
||||
// Check for Box borders (round style uses unicode box chars)
|
||||
expect(frame).toMatch(/[─│┐└┘┌]/);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cursor-based completion trigger', () => {
|
||||
it.each([
|
||||
{
|
||||
@@ -1539,12 +1720,24 @@ describe('InputPrompt', () => {
|
||||
visualCursor: [0, 6],
|
||||
expected: `hello ${chalk.inverse('👍')} world`,
|
||||
},
|
||||
{
|
||||
name: 'after multi-byte unicode characters',
|
||||
text: '👍A',
|
||||
visualCursor: [0, 1],
|
||||
expected: `👍${chalk.inverse('A')}`,
|
||||
},
|
||||
{
|
||||
name: 'at the end of a line with unicode characters',
|
||||
text: 'hello 👍',
|
||||
visualCursor: [0, 8],
|
||||
expected: `hello 👍${chalk.inverse(' ')}`,
|
||||
},
|
||||
{
|
||||
name: 'at the end of a short line with unicode characters',
|
||||
text: '👍',
|
||||
visualCursor: [0, 1],
|
||||
expected: `👍${chalk.inverse(' ')}`,
|
||||
},
|
||||
{
|
||||
name: 'on an empty line',
|
||||
text: '',
|
||||
@@ -1564,11 +1757,11 @@ describe('InputPrompt', () => {
|
||||
mockBuffer.lines = [text];
|
||||
mockBuffer.viewportVisualLines = [text];
|
||||
mockBuffer.visualCursor = visualCursor as [number, number];
|
||||
props.config.getUseBackgroundColor = () => false;
|
||||
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrame();
|
||||
expect(frame).toContain(expected);
|
||||
@@ -1621,11 +1814,11 @@ describe('InputPrompt', () => {
|
||||
mockBuffer.visualToLogicalMap = visualToLogicalMap as Array<
|
||||
[number, number]
|
||||
>;
|
||||
props.config.getUseBackgroundColor = () => false;
|
||||
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrame();
|
||||
expect(frame).toContain(expected);
|
||||
@@ -1645,11 +1838,11 @@ describe('InputPrompt', () => {
|
||||
[1, 0],
|
||||
[2, 0],
|
||||
];
|
||||
props.config.getUseBackgroundColor = () => false;
|
||||
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrame();
|
||||
const lines = frame!.split('\n');
|
||||
@@ -1673,15 +1866,15 @@ describe('InputPrompt', () => {
|
||||
mockBuffer.visualCursor = [2, 5]; // cursor at the end of "world"
|
||||
// Provide a visual-to-logical mapping for each visual line
|
||||
mockBuffer.visualToLogicalMap = [
|
||||
[0, 0], // 'hello' starts at col 0 of logical line 0
|
||||
[1, 0], // '' (blank) is logical line 1, col 0
|
||||
[2, 0], // 'world' is logical line 2, col 0
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[2, 0],
|
||||
];
|
||||
props.config.getUseBackgroundColor = () => false;
|
||||
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrame();
|
||||
// Check that all lines, including the empty one, are rendered.
|
||||
@@ -2505,20 +2698,23 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\x12');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toMatchSnapshot(
|
||||
'command-search-render-collapsed-match',
|
||||
);
|
||||
expect(stdout.lastFrame()).toContain('(r:)');
|
||||
});
|
||||
expect(stdout.lastFrame()).toMatchSnapshot(
|
||||
'command-search-render-collapsed-match',
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\u001B[C');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toMatchSnapshot(
|
||||
'command-search-render-expanded-match',
|
||||
);
|
||||
// Just wait for any update to ensure it is stable.
|
||||
// We could also wait for specific text if we knew it.
|
||||
expect(stdout.lastFrame()).toContain('(r:)');
|
||||
});
|
||||
|
||||
expect(stdout.lastFrame()).toMatchSnapshot(
|
||||
'command-search-render-expanded-match',
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -2637,28 +2833,28 @@ describe('InputPrompt', () => {
|
||||
name: 'first line, first char',
|
||||
relX: 0,
|
||||
relY: 0,
|
||||
mouseCol: 5,
|
||||
mouseCol: 4,
|
||||
mouseRow: 2,
|
||||
},
|
||||
{
|
||||
name: 'first line, middle char',
|
||||
relX: 6,
|
||||
relY: 0,
|
||||
mouseCol: 11,
|
||||
mouseCol: 10,
|
||||
mouseRow: 2,
|
||||
},
|
||||
{
|
||||
name: 'second line, first char',
|
||||
relX: 0,
|
||||
relY: 1,
|
||||
mouseCol: 5,
|
||||
mouseCol: 4,
|
||||
mouseRow: 3,
|
||||
},
|
||||
{
|
||||
name: 'second line, end char',
|
||||
relX: 5,
|
||||
relY: 1,
|
||||
mouseCol: 10,
|
||||
mouseCol: 9,
|
||||
mouseRow: 3,
|
||||
},
|
||||
])(
|
||||
@@ -2685,7 +2881,7 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
// Simulate left mouse press at calculated coordinates.
|
||||
// Assumes inner box is at x=4, y=1 based on border(1)+padding(1)+prompt(2) and border-top(1).
|
||||
// Without left border: inner box is at x=3, y=1 based on padding(1)+prompt(2) and border-top(1).
|
||||
await act(async () => {
|
||||
stdin.write(`\x1b[<0;${mouseCol};${mouseRow}M`);
|
||||
});
|
||||
@@ -2727,6 +2923,207 @@ describe('InputPrompt', () => {
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should toggle paste expansion on double-click', async () => {
|
||||
const id = '[Pasted Text: 10 lines]';
|
||||
const largeText =
|
||||
'line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10';
|
||||
|
||||
const baseProps = props;
|
||||
const TestWrapper = () => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const currentLines = isExpanded ? largeText.split('\n') : [id];
|
||||
const currentText = isExpanded ? largeText : id;
|
||||
|
||||
const buffer = {
|
||||
...baseProps.buffer,
|
||||
text: currentText,
|
||||
lines: currentLines,
|
||||
viewportVisualLines: currentLines,
|
||||
allVisualLines: currentLines,
|
||||
pastedContent: { [id]: largeText },
|
||||
transformationsByLine: isExpanded
|
||||
? currentLines.map(() => [])
|
||||
: [
|
||||
[
|
||||
{
|
||||
logStart: 0,
|
||||
logEnd: id.length,
|
||||
logicalText: id,
|
||||
collapsedText: id,
|
||||
type: 'paste',
|
||||
id,
|
||||
},
|
||||
],
|
||||
],
|
||||
visualScrollRow: 0,
|
||||
visualToLogicalMap: currentLines.map(
|
||||
(_, i) => [i, 0] as [number, number],
|
||||
),
|
||||
visualToTransformedMap: currentLines.map(() => 0),
|
||||
getLogicalPositionFromVisual: vi.fn().mockReturnValue({
|
||||
row: 0,
|
||||
col: 2,
|
||||
}),
|
||||
togglePasteExpansion: vi.fn().mockImplementation(() => {
|
||||
setIsExpanded(!isExpanded);
|
||||
}),
|
||||
getExpandedPasteAtLine: vi
|
||||
.fn()
|
||||
.mockReturnValue(isExpanded ? id : null),
|
||||
};
|
||||
|
||||
return <InputPrompt {...baseProps} buffer={buffer as TextBuffer} />;
|
||||
};
|
||||
|
||||
const { stdin, stdout, unmount, simulateClick } = renderWithProviders(
|
||||
<TestWrapper />,
|
||||
{
|
||||
mouseEventsEnabled: true,
|
||||
useAlternateBuffer: true,
|
||||
uiActions,
|
||||
},
|
||||
);
|
||||
|
||||
// 1. Verify initial placeholder
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
// Simulate double-click to expand
|
||||
await simulateClick(stdin, 5, 2);
|
||||
await simulateClick(stdin, 5, 2);
|
||||
|
||||
// 2. Verify expanded content is visible
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
// Simulate double-click to collapse
|
||||
await simulateClick(stdin, 5, 2);
|
||||
await simulateClick(stdin, 5, 2);
|
||||
|
||||
// 3. Verify placeholder is restored
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should collapse expanded paste on double-click after the end of the line', async () => {
|
||||
const id = '[Pasted Text: 10 lines]';
|
||||
const largeText =
|
||||
'line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10';
|
||||
|
||||
const baseProps = props;
|
||||
const TestWrapper = () => {
|
||||
const [isExpanded, setIsExpanded] = useState(true); // Start expanded
|
||||
const currentLines = isExpanded ? largeText.split('\n') : [id];
|
||||
const currentText = isExpanded ? largeText : id;
|
||||
|
||||
const buffer = {
|
||||
...baseProps.buffer,
|
||||
text: currentText,
|
||||
lines: currentLines,
|
||||
viewportVisualLines: currentLines,
|
||||
allVisualLines: currentLines,
|
||||
pastedContent: { [id]: largeText },
|
||||
transformationsByLine: isExpanded
|
||||
? currentLines.map(() => [])
|
||||
: [
|
||||
[
|
||||
{
|
||||
logStart: 0,
|
||||
logEnd: id.length,
|
||||
logicalText: id,
|
||||
collapsedText: id,
|
||||
type: 'paste',
|
||||
id,
|
||||
},
|
||||
],
|
||||
],
|
||||
visualScrollRow: 0,
|
||||
visualToLogicalMap: currentLines.map(
|
||||
(_, i) => [i, 0] as [number, number],
|
||||
),
|
||||
visualToTransformedMap: currentLines.map(() => 0),
|
||||
getLogicalPositionFromVisual: vi.fn().mockImplementation(
|
||||
(_vRow, _vCol) =>
|
||||
// Simulate that we are past the end of the line by returning something
|
||||
// that getTransformUnderCursor won't match, or having the caller handle it.
|
||||
null,
|
||||
),
|
||||
togglePasteExpansion: vi.fn().mockImplementation(() => {
|
||||
setIsExpanded(!isExpanded);
|
||||
}),
|
||||
getExpandedPasteAtLine: vi
|
||||
.fn()
|
||||
.mockImplementation((row) =>
|
||||
isExpanded && row >= 0 && row < 10 ? id : null,
|
||||
),
|
||||
};
|
||||
|
||||
return <InputPrompt {...baseProps} buffer={buffer as TextBuffer} />;
|
||||
};
|
||||
|
||||
const { stdin, stdout, unmount, simulateClick } = renderWithProviders(
|
||||
<TestWrapper />,
|
||||
{
|
||||
mouseEventsEnabled: true,
|
||||
useAlternateBuffer: true,
|
||||
uiActions,
|
||||
},
|
||||
);
|
||||
|
||||
// Verify initially expanded
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toContain('line1');
|
||||
});
|
||||
|
||||
// Simulate double-click WAY to the right on the first line
|
||||
await simulateClick(stdin, 100, 2);
|
||||
await simulateClick(stdin, 100, 2);
|
||||
|
||||
// Verify it is NOW collapsed
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toContain(id);
|
||||
expect(stdout.lastFrame()).not.toContain('line1');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should move cursor on mouse click with plain borders', async () => {
|
||||
props.config.getUseBackgroundColor = () => false;
|
||||
props.buffer.text = 'hello world';
|
||||
props.buffer.lines = ['hello world'];
|
||||
props.buffer.viewportVisualLines = ['hello world'];
|
||||
props.buffer.visualToLogicalMap = [[0, 0]];
|
||||
props.buffer.visualCursor = [0, 11];
|
||||
props.buffer.visualScrollRow = 0;
|
||||
|
||||
const { stdin, stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ mouseEventsEnabled: true, uiActions },
|
||||
);
|
||||
|
||||
// Wait for initial render
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toContain('hello world');
|
||||
});
|
||||
|
||||
// With plain borders: 1(border) + 1(padding) + 2(prompt) = 4 offset (x=4, col=5)
|
||||
await act(async () => {
|
||||
stdin.write(`\x1b[<0;5;2M`); // Click at col 5, row 2
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.buffer.moveToVisualPosition).toHaveBeenCalledWith(0, 0);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('queued message editing', () => {
|
||||
@@ -2889,7 +3286,8 @@ describe('InputPrompt', () => {
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
await waitFor(() => expect(stdout.lastFrame()).toMatchSnapshot());
|
||||
await waitFor(() => expect(stdout.lastFrame()).toContain('!'));
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -2898,7 +3296,8 @@ describe('InputPrompt', () => {
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
await waitFor(() => expect(stdout.lastFrame()).toMatchSnapshot());
|
||||
await waitFor(() => expect(stdout.lastFrame()).toContain('>'));
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -2907,10 +3306,10 @@ describe('InputPrompt', () => {
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
await waitFor(() => expect(stdout.lastFrame()).toMatchSnapshot());
|
||||
await waitFor(() => expect(stdout.lastFrame()).toContain('*'));
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not show inverted cursor when shell is focused', async () => {
|
||||
props.isEmbeddedShellFocused = true;
|
||||
props.focus = false;
|
||||
@@ -2919,8 +3318,8 @@ describe('InputPrompt', () => {
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).not.toContain(`{chalk.inverse(' ')}`);
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -2993,6 +3392,202 @@ describe('InputPrompt', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('IME Cursor Support', () => {
|
||||
it('should report correct cursor position for simple ASCII text', async () => {
|
||||
const text = 'hello';
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = [text];
|
||||
mockBuffer.viewportVisualLines = [text];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
mockBuffer.visualCursor = [0, 3]; // Cursor after 'hel'
|
||||
mockBuffer.visualScrollRow = 0;
|
||||
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ uiActions },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toContain('hello');
|
||||
});
|
||||
|
||||
// Check Text calls from the LAST render
|
||||
const textCalls = vi.mocked(Text).mock.calls;
|
||||
const cursorLineCall = [...textCalls]
|
||||
.reverse()
|
||||
.find((call) => call[0].terminalCursorFocus === true);
|
||||
|
||||
expect(cursorLineCall).toBeDefined();
|
||||
// 'hel' is 3 characters wide
|
||||
expect(cursorLineCall![0].terminalCursorPosition).toBe(3);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should report correct cursor position for text with double-width characters', async () => {
|
||||
const text = '👍hello';
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = [text];
|
||||
mockBuffer.viewportVisualLines = [text];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
mockBuffer.visualCursor = [0, 2]; // Cursor after '👍h' (Note: '👍' is one code point but width 2)
|
||||
mockBuffer.visualScrollRow = 0;
|
||||
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ uiActions },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toContain('👍hello');
|
||||
});
|
||||
|
||||
const textCalls = vi.mocked(Text).mock.calls;
|
||||
const cursorLineCall = [...textCalls]
|
||||
.reverse()
|
||||
.find((call) => call[0].terminalCursorFocus === true);
|
||||
|
||||
expect(cursorLineCall).toBeDefined();
|
||||
// '👍' is width 2, 'h' is width 1. Total width = 3.
|
||||
expect(cursorLineCall![0].terminalCursorPosition).toBe(3);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should report correct cursor position for a line full of "😀" emojis', async () => {
|
||||
const text = '😀😀😀';
|
||||
mockBuffer.text = text;
|
||||
mockBuffer.lines = [text];
|
||||
mockBuffer.viewportVisualLines = [text];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
mockBuffer.visualCursor = [0, 2]; // Cursor after 2 emojis (each 1 code point, width 2)
|
||||
mockBuffer.visualScrollRow = 0;
|
||||
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ uiActions },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toContain('😀😀😀');
|
||||
});
|
||||
|
||||
const textCalls = vi.mocked(Text).mock.calls;
|
||||
const cursorLineCall = [...textCalls]
|
||||
.reverse()
|
||||
.find((call) => call[0].terminalCursorFocus === true);
|
||||
|
||||
expect(cursorLineCall).toBeDefined();
|
||||
// 2 emojis * width 2 = 4
|
||||
expect(cursorLineCall![0].terminalCursorPosition).toBe(4);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should report correct cursor position for mixed emojis and multi-line input', async () => {
|
||||
const lines = ['😀😀', 'hello 😀', 'world'];
|
||||
mockBuffer.text = lines.join('\n');
|
||||
mockBuffer.lines = lines;
|
||||
mockBuffer.viewportVisualLines = lines;
|
||||
mockBuffer.visualToLogicalMap = [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[2, 0],
|
||||
];
|
||||
mockBuffer.visualCursor = [1, 7]; // Second line, after 'hello 😀' (6 chars + 1 emoji = 7 code points)
|
||||
mockBuffer.visualScrollRow = 0;
|
||||
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ uiActions },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toContain('hello 😀');
|
||||
});
|
||||
|
||||
const textCalls = vi.mocked(Text).mock.calls;
|
||||
const lineCalls = textCalls.filter(
|
||||
(call) => call[0].terminalCursorPosition !== undefined,
|
||||
);
|
||||
const lastRenderLineCalls = lineCalls.slice(-3);
|
||||
|
||||
const focusCall = lastRenderLineCalls.find(
|
||||
(call) => call[0].terminalCursorFocus === true,
|
||||
);
|
||||
expect(focusCall).toBeDefined();
|
||||
// 'hello ' is 6 units, '😀' is 2 units. Total = 8.
|
||||
expect(focusCall![0].terminalCursorPosition).toBe(8);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should report correct cursor position and focus for multi-line input', async () => {
|
||||
const lines = ['first line', 'second line', 'third line'];
|
||||
mockBuffer.text = lines.join('\n');
|
||||
mockBuffer.lines = lines;
|
||||
mockBuffer.viewportVisualLines = lines;
|
||||
mockBuffer.visualToLogicalMap = [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[2, 0],
|
||||
];
|
||||
mockBuffer.visualCursor = [1, 7]; // Cursor on second line, after 'second '
|
||||
mockBuffer.visualScrollRow = 0;
|
||||
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{ uiActions },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toContain('second line');
|
||||
});
|
||||
|
||||
const textCalls = vi.mocked(Text).mock.calls;
|
||||
|
||||
// We look for the last set of line calls.
|
||||
// Line calls have terminalCursorPosition set.
|
||||
const lineCalls = textCalls.filter(
|
||||
(call) => call[0].terminalCursorPosition !== undefined,
|
||||
);
|
||||
const lastRenderLineCalls = lineCalls.slice(-3);
|
||||
|
||||
expect(lastRenderLineCalls.length).toBe(3);
|
||||
|
||||
// Only one line should have terminalCursorFocus=true
|
||||
const focusCalls = lastRenderLineCalls.filter(
|
||||
(call) => call[0].terminalCursorFocus === true,
|
||||
);
|
||||
expect(focusCalls.length).toBe(1);
|
||||
expect(focusCalls[0][0].terminalCursorPosition).toBe(7);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should report cursor position 0 when input is empty and placeholder is shown', async () => {
|
||||
mockBuffer.text = '';
|
||||
mockBuffer.lines = [''];
|
||||
mockBuffer.viewportVisualLines = [''];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
mockBuffer.visualCursor = [0, 0];
|
||||
mockBuffer.visualScrollRow = 0;
|
||||
|
||||
const { stdout, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} placeholder="Type here" />,
|
||||
{ uiActions },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toContain('Type here');
|
||||
});
|
||||
|
||||
const textCalls = vi.mocked(Text).mock.calls;
|
||||
const cursorLineCall = [...textCalls]
|
||||
.reverse()
|
||||
.find((call) => call[0].terminalCursorFocus === true);
|
||||
|
||||
expect(cursorLineCall).toBeDefined();
|
||||
expect(cursorLineCall![0].terminalCursorPosition).toBe(0);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('image path transformation snapshots', () => {
|
||||
const logicalLine = '@/path/to/screenshots/screenshot2x.png';
|
||||
const transformations = calculateTransformationsForLine(logicalLine);
|
||||
@@ -3022,8 +3617,9 @@ describe('InputPrompt', () => {
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
expect(stdout.lastFrame()).toContain('[Image');
|
||||
});
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -3040,8 +3636,9 @@ describe('InputPrompt', () => {
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
expect(stdout.lastFrame()).toContain('@/path/to/screenshots');
|
||||
});
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,17 +6,24 @@
|
||||
|
||||
import type React from 'react';
|
||||
import clipboardy from 'clipboardy';
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { Box, Text, useStdout, type DOMElement } from 'ink';
|
||||
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||
import type { TextBuffer } from './shared/text-buffer.js';
|
||||
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
|
||||
import {
|
||||
type TextBuffer,
|
||||
logicalPosToOffset,
|
||||
PASTED_TEXT_PLACEHOLDER_REGEX,
|
||||
getTransformUnderCursor,
|
||||
} from './shared/text-buffer.js';
|
||||
import { cpSlice, cpLen, toCodePoints } from '../utils/textUtils.js';
|
||||
import {
|
||||
cpSlice,
|
||||
cpLen,
|
||||
toCodePoints,
|
||||
cpIndexToOffset,
|
||||
} from '../utils/textUtils.js';
|
||||
import chalk from 'chalk';
|
||||
import stringWidth from 'string-width';
|
||||
import { useShellHistory } from '../hooks/useShellHistory.js';
|
||||
@@ -47,6 +54,9 @@ import {
|
||||
} from '../utils/commandUtils.js';
|
||||
import * as path from 'node:path';
|
||||
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
|
||||
import { DEFAULT_BACKGROUND_OPACITY } from '../constants.js';
|
||||
import { getSafeLowColorBackground } from '../themes/color-utils.js';
|
||||
import { isLowColorDepth } from '../utils/terminalUtils.js';
|
||||
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
@@ -54,6 +64,7 @@ import { StreamingState } from '../types.js';
|
||||
import { useMouseClick } from '../hooks/useMouseClick.js';
|
||||
import { useMouse, type MouseEvent } from '../contexts/MouseContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
|
||||
/**
|
||||
* Returns if the terminal can be trusted to handle paste events atomically
|
||||
@@ -141,7 +152,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const kittyProtocol = useKittyKeyboardProtocol();
|
||||
const isShellFocused = useShellFocusState();
|
||||
const { setEmbeddedShellFocused } = useUIActions();
|
||||
const { mainAreaWidth, activePtyId, history } = useUIState();
|
||||
const { terminalWidth, activePtyId, history, terminalBackgroundColor } =
|
||||
useUIState();
|
||||
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
||||
const escPressCount = useRef(0);
|
||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||
@@ -321,6 +333,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const allMessages = popAllMessages();
|
||||
if (allMessages) {
|
||||
buffer.setText(allMessages);
|
||||
return true;
|
||||
} else {
|
||||
// No queued messages, proceed with input history
|
||||
inputHistory.navigateUp();
|
||||
@@ -364,7 +377,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
// Insert at cursor position
|
||||
buffer.replaceRangeByOffset(offset, offset, textToInsert);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,6 +403,59 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
{ isActive: focus },
|
||||
);
|
||||
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
// Double-click to expand/collapse paste placeholders
|
||||
useMouseClick(
|
||||
innerBoxRef,
|
||||
(_event, relX, relY) => {
|
||||
if (!isAlternateBuffer) return;
|
||||
|
||||
const visualLine = buffer.viewportVisualLines[relY];
|
||||
if (!visualLine) return;
|
||||
|
||||
// Even if we click past the end of the line, we might want to collapse an expanded paste
|
||||
const isPastEndOfLine = relX >= stringWidth(visualLine);
|
||||
|
||||
const logicalPos = isPastEndOfLine
|
||||
? null
|
||||
: buffer.getLogicalPositionFromVisual(
|
||||
buffer.visualScrollRow + relY,
|
||||
relX,
|
||||
);
|
||||
|
||||
// Check for paste placeholder (collapsed state)
|
||||
if (logicalPos) {
|
||||
const transform = getTransformUnderCursor(
|
||||
logicalPos.row,
|
||||
logicalPos.col,
|
||||
buffer.transformationsByLine,
|
||||
);
|
||||
if (transform?.type === 'paste' && transform.id) {
|
||||
buffer.togglePasteExpansion(
|
||||
transform.id,
|
||||
logicalPos.row,
|
||||
logicalPos.col,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If we didn't click a placeholder to expand, check if we are inside or after
|
||||
// an expanded paste region and collapse it.
|
||||
const row = buffer.visualScrollRow + relY;
|
||||
const expandedId = buffer.getExpandedPasteAtLine(row);
|
||||
if (expandedId) {
|
||||
buffer.togglePasteExpansion(
|
||||
expandedId,
|
||||
row,
|
||||
logicalPos?.col ?? relX, // Fallback to relX if past end of line
|
||||
);
|
||||
}
|
||||
},
|
||||
{ isActive: focus, name: 'double-click' },
|
||||
);
|
||||
|
||||
useMouse(
|
||||
(event: MouseEvent) => {
|
||||
if (event.name === 'right-release') {
|
||||
@@ -408,7 +473,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
// focused.
|
||||
/// We want to handle paste even when not focused to support drag and drop.
|
||||
if (!focus && key.name !== 'paste') {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (key.name === 'paste') {
|
||||
@@ -437,11 +502,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
// Ensure we never accidentally interpret paste as regular input.
|
||||
buffer.handleInput(key);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (vimHandleInput && vimHandleInput(key)) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Reset ESC count and hide prompt on any non-ESC key
|
||||
@@ -458,7 +523,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
) {
|
||||
setShellModeActive(!shellModeActive);
|
||||
buffer.setText(''); // Clear the '!' from input
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
@@ -483,27 +548,27 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setReverseSearchActive,
|
||||
reverseSearchCompletion.resetCompletionState,
|
||||
);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (commandSearchActive) {
|
||||
cancelSearch(
|
||||
setCommandSearchActive,
|
||||
commandSearchCompletion.resetCompletionState,
|
||||
);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (shellModeActive) {
|
||||
setShellModeActive(false);
|
||||
resetEscapeState();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (completion.showSuggestions) {
|
||||
completion.resetCompletionState();
|
||||
setExpandedSuggestionIndex(-1);
|
||||
resetEscapeState();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle double ESC
|
||||
@@ -516,7 +581,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
escapeTimerRef.current = setTimeout(() => {
|
||||
resetEscapeState();
|
||||
}, 500);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Second ESC
|
||||
@@ -524,26 +589,26 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
resetCompletionState();
|
||||
return;
|
||||
return true;
|
||||
} else if (history.length > 0) {
|
||||
onSubmit('/rewind');
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
coreEvents.emitFeedback('info', 'Nothing to rewind to');
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
|
||||
setReverseSearchActive(true);
|
||||
setTextBeforeReverseSearch(buffer.text);
|
||||
setCursorPosition(buffer.cursor);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
|
||||
setBannerVisible(false);
|
||||
onClearScreen();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (reverseSearchActive || commandSearchActive) {
|
||||
@@ -568,29 +633,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (showSuggestions) {
|
||||
if (keyMatchers[Command.NAVIGATION_UP](key)) {
|
||||
navigateUp();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
||||
navigateDown();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
|
||||
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
|
||||
setExpandedSuggestionIndex(-1);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
|
||||
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
|
||||
setExpandedSuggestionIndex(activeSuggestionIndex);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) {
|
||||
sc.handleAutocomplete(activeSuggestionIndex);
|
||||
resetState();
|
||||
setActive(false);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,7 +667,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
handleSubmitAndClear(textToSubmit);
|
||||
resetState();
|
||||
setActive(false);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Prevent up/down from falling through to regular history navigation
|
||||
@@ -610,7 +675,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
keyMatchers[Command.NAVIGATION_UP](key) ||
|
||||
keyMatchers[Command.NAVIGATION_DOWN](key)
|
||||
) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,7 +687,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
(!completion.showSuggestions || completion.activeSuggestionIndex <= 0)
|
||||
) {
|
||||
handleSubmit(buffer.text);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (completion.showSuggestions) {
|
||||
@@ -630,12 +695,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (keyMatchers[Command.COMPLETION_UP](key)) {
|
||||
completion.navigateUp();
|
||||
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
|
||||
completion.navigateDown();
|
||||
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,7 +729,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (completedText) {
|
||||
setExpandedSuggestionIndex(-1);
|
||||
handleSubmit(completedText.trim());
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
} else if (!isArgumentCompletion) {
|
||||
// Existing logic for command name completion
|
||||
@@ -684,7 +749,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (completedText) {
|
||||
setExpandedSuggestionIndex(-1);
|
||||
handleSubmit(completedText.trim());
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -695,7 +760,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setExpandedSuggestionIndex(-1); // Reset expansion after selection
|
||||
}
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -706,7 +771,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
completion.promptCompletion.text
|
||||
) {
|
||||
completion.promptCompletion.accept();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!shellModeActive) {
|
||||
@@ -714,22 +779,22 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setCommandSearchActive(true);
|
||||
setTextBeforeReverseSearch(buffer.text);
|
||||
setCursorPosition(buffer.cursor);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.HISTORY_UP](key)) {
|
||||
// Check for queued messages first when input is empty
|
||||
// If no queued messages, inputHistory.navigateUp() is called inside tryLoadQueuedMessages
|
||||
if (tryLoadQueuedMessages()) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
// Only navigate history if popAllMessages doesn't exist
|
||||
inputHistory.navigateUp();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.HISTORY_DOWN](key)) {
|
||||
inputHistory.navigateDown();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
// Handle arrow-up/down for history on single-line or at edges
|
||||
if (
|
||||
@@ -740,11 +805,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
// Check for queued messages first when input is empty
|
||||
// If no queued messages, inputHistory.navigateUp() is called inside tryLoadQueuedMessages
|
||||
if (tryLoadQueuedMessages()) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
// Only navigate history if popAllMessages doesn't exist
|
||||
inputHistory.navigateUp();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
keyMatchers[Command.NAVIGATION_DOWN](key) &&
|
||||
@@ -752,19 +817,19 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
|
||||
) {
|
||||
inputHistory.navigateDown();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Shell History Navigation
|
||||
if (keyMatchers[Command.NAVIGATION_UP](key)) {
|
||||
const prevCommand = shellHistory.getPreviousCommand();
|
||||
if (prevCommand !== null) buffer.setText(prevCommand);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
|
||||
const nextCommand = shellHistory.getNextCommand();
|
||||
if (nextCommand !== null) buffer.setText(nextCommand);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -779,7 +844,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
// get some feedback that their keypress was handled rather than
|
||||
// wondering why it was completely ignored.
|
||||
buffer.newline();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const [row, col] = buffer.cursor;
|
||||
@@ -792,23 +857,23 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
handleSubmit(buffer.text);
|
||||
}
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Newline insertion
|
||||
if (keyMatchers[Command.NEWLINE](key)) {
|
||||
buffer.newline();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ctrl+A (Home) / Ctrl+E (End)
|
||||
if (keyMatchers[Command.HOME](key)) {
|
||||
buffer.move('home');
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.END](key)) {
|
||||
buffer.move('end');
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
// Ctrl+C (Clear input)
|
||||
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||
@@ -816,36 +881,36 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
buffer.setText('');
|
||||
resetCompletionState();
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Kill line commands
|
||||
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
|
||||
buffer.killLineRight();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
|
||||
buffer.killLineLeft();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
|
||||
buffer.deleteWordLeft();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// External editor
|
||||
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
buffer.openInExternalEditor();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ctrl+V for clipboard paste
|
||||
if (keyMatchers[Command.PASTE_CLIPBOARD](key)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
handleClipboardPaste();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
|
||||
@@ -853,11 +918,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (activePtyId) {
|
||||
setEmbeddedShellFocused(true);
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fall back to the text buffer's default input handling for all other keys
|
||||
buffer.handleInput(key);
|
||||
const handled = buffer.handleInput(key);
|
||||
|
||||
// Clear ghost text when user types regular characters (not navigation/control keys)
|
||||
if (
|
||||
@@ -871,6 +936,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
completion.promptCompletion.clear();
|
||||
setExpandedSuggestionIndex(-1);
|
||||
}
|
||||
return handled;
|
||||
},
|
||||
[
|
||||
focus,
|
||||
@@ -1033,6 +1099,23 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const activeCompletion = getActiveCompletion();
|
||||
const shouldShowSuggestions = activeCompletion.showSuggestions;
|
||||
|
||||
const useBackgroundColor = config.getUseBackgroundColor();
|
||||
const isLowColor = isLowColorDepth();
|
||||
const terminalBg = terminalBackgroundColor || 'black';
|
||||
|
||||
// We should fallback to lines if the background color is disabled OR if it is
|
||||
// enabled but we are in a low color depth terminal where we don't have a safe
|
||||
// background color to use.
|
||||
const useLineFallback = useMemo(() => {
|
||||
if (!useBackgroundColor) {
|
||||
return true;
|
||||
}
|
||||
if (isLowColor) {
|
||||
return !getSafeLowColorBackground(terminalBg);
|
||||
}
|
||||
return false;
|
||||
}, [useBackgroundColor, isLowColor, terminalBg]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onSuggestionsVisibilityChange) {
|
||||
onSuggestionsVisibilityChange(shouldShowSuggestions);
|
||||
@@ -1085,198 +1168,252 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
</Box>
|
||||
) : null;
|
||||
|
||||
const borderColor =
|
||||
isShellFocused && !isEmbeddedShellFocused
|
||||
? (statusColor ?? theme.border.focused)
|
||||
: theme.border.default;
|
||||
|
||||
return (
|
||||
<>
|
||||
{suggestionsPosition === 'above' && suggestionsNode}
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={
|
||||
{useLineFallback ? (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderTop={true}
|
||||
borderBottom={false}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderColor={borderColor}
|
||||
width={terminalWidth}
|
||||
flexDirection="row"
|
||||
alignItems="flex-start"
|
||||
height={0}
|
||||
/>
|
||||
) : null}
|
||||
<HalfLinePaddedBox
|
||||
backgroundBaseColor={
|
||||
isShellFocused && !isEmbeddedShellFocused
|
||||
? (statusColor ?? theme.border.focused)
|
||||
? theme.border.focused
|
||||
: theme.border.default
|
||||
}
|
||||
paddingX={1}
|
||||
width={mainAreaWidth}
|
||||
flexDirection="row"
|
||||
alignItems="flex-start"
|
||||
minHeight={3}
|
||||
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
|
||||
useBackgroundColor={useBackgroundColor}
|
||||
>
|
||||
<Text
|
||||
color={statusColor ?? theme.text.accent}
|
||||
aria-label={statusText || undefined}
|
||||
<Box
|
||||
flexGrow={1}
|
||||
flexDirection="row"
|
||||
paddingX={1}
|
||||
borderColor={borderColor}
|
||||
borderStyle={useLineFallback ? 'round' : undefined}
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderLeft={!useBackgroundColor}
|
||||
borderRight={!useBackgroundColor}
|
||||
>
|
||||
{shellModeActive ? (
|
||||
reverseSearchActive ? (
|
||||
<Text
|
||||
color={theme.text.link}
|
||||
aria-label={SCREEN_READER_USER_PREFIX}
|
||||
>
|
||||
(r:){' '}
|
||||
</Text>
|
||||
<Text
|
||||
color={statusColor ?? theme.text.accent}
|
||||
aria-label={statusText || undefined}
|
||||
>
|
||||
{shellModeActive ? (
|
||||
reverseSearchActive ? (
|
||||
<Text
|
||||
color={theme.text.link}
|
||||
aria-label={SCREEN_READER_USER_PREFIX}
|
||||
>
|
||||
(r:){' '}
|
||||
</Text>
|
||||
) : (
|
||||
'!'
|
||||
)
|
||||
) : commandSearchActive ? (
|
||||
<Text color={theme.text.accent}>(r:) </Text>
|
||||
) : showYoloStyling ? (
|
||||
'*'
|
||||
) : (
|
||||
'!'
|
||||
)
|
||||
) : commandSearchActive ? (
|
||||
<Text color={theme.text.accent}>(r:) </Text>
|
||||
) : showYoloStyling ? (
|
||||
'*'
|
||||
) : (
|
||||
'>'
|
||||
)}{' '}
|
||||
</Text>
|
||||
<Box flexGrow={1} flexDirection="column" ref={innerBoxRef}>
|
||||
{buffer.text.length === 0 && placeholder ? (
|
||||
showCursor ? (
|
||||
<Text>
|
||||
{chalk.inverse(placeholder.slice(0, 1))}
|
||||
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
|
||||
</Text>
|
||||
'>'
|
||||
)}{' '}
|
||||
</Text>
|
||||
<Box flexGrow={1} flexDirection="column" ref={innerBoxRef}>
|
||||
{buffer.text.length === 0 && placeholder ? (
|
||||
showCursor ? (
|
||||
<Text
|
||||
terminalCursorFocus={showCursor}
|
||||
terminalCursorPosition={0}
|
||||
>
|
||||
{chalk.inverse(placeholder.slice(0, 1))}
|
||||
<Text color={theme.text.secondary}>
|
||||
{placeholder.slice(1)}
|
||||
</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={theme.text.secondary}>{placeholder}</Text>
|
||||
)
|
||||
) : (
|
||||
<Text color={theme.text.secondary}>{placeholder}</Text>
|
||||
)
|
||||
) : (
|
||||
linesToRender
|
||||
.map((lineText, visualIdxInRenderedSet) => {
|
||||
const absoluteVisualIdx =
|
||||
scrollVisualRow + visualIdxInRenderedSet;
|
||||
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
|
||||
const cursorVisualRow =
|
||||
cursorVisualRowAbsolute - scrollVisualRow;
|
||||
const isOnCursorLine =
|
||||
focus && visualIdxInRenderedSet === cursorVisualRow;
|
||||
linesToRender
|
||||
.map((lineText: string, visualIdxInRenderedSet: number) => {
|
||||
const absoluteVisualIdx =
|
||||
scrollVisualRow + visualIdxInRenderedSet;
|
||||
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
|
||||
if (!mapEntry) return null;
|
||||
|
||||
const renderedLine: React.ReactNode[] = [];
|
||||
const cursorVisualRow =
|
||||
cursorVisualRowAbsolute - scrollVisualRow;
|
||||
const isOnCursorLine =
|
||||
focus && visualIdxInRenderedSet === cursorVisualRow;
|
||||
|
||||
const [logicalLineIdx] = mapEntry;
|
||||
const logicalLine = buffer.lines[logicalLineIdx] || '';
|
||||
const transformations =
|
||||
buffer.transformationsByLine[logicalLineIdx] ?? [];
|
||||
const tokens = parseInputForHighlighting(
|
||||
logicalLine,
|
||||
logicalLineIdx,
|
||||
transformations,
|
||||
...(focus && buffer.cursor[0] === logicalLineIdx
|
||||
? [buffer.cursor[1]]
|
||||
: []),
|
||||
);
|
||||
const startColInTransformed =
|
||||
buffer.visualToTransformedMap[absoluteVisualIdx] ?? 0;
|
||||
const visualStartCol = startColInTransformed;
|
||||
const visualEndCol = visualStartCol + cpLen(lineText);
|
||||
const segments = parseSegmentsFromTokens(
|
||||
tokens,
|
||||
visualStartCol,
|
||||
visualEndCol,
|
||||
);
|
||||
let charCount = 0;
|
||||
segments.forEach((seg, segIdx) => {
|
||||
const segLen = cpLen(seg.text);
|
||||
let display = seg.text;
|
||||
const renderedLine: React.ReactNode[] = [];
|
||||
|
||||
if (isOnCursorLine) {
|
||||
const relativeVisualColForHighlight =
|
||||
cursorVisualColAbsolute;
|
||||
const segStart = charCount;
|
||||
const segEnd = segStart + segLen;
|
||||
if (
|
||||
relativeVisualColForHighlight >= segStart &&
|
||||
relativeVisualColForHighlight < segEnd
|
||||
) {
|
||||
const charToHighlight = cpSlice(
|
||||
display,
|
||||
relativeVisualColForHighlight - segStart,
|
||||
relativeVisualColForHighlight - segStart + 1,
|
||||
);
|
||||
const highlighted = showCursor
|
||||
? chalk.inverse(charToHighlight)
|
||||
: charToHighlight;
|
||||
display =
|
||||
cpSlice(
|
||||
const [logicalLineIdx] = mapEntry;
|
||||
const logicalLine = buffer.lines[logicalLineIdx] || '';
|
||||
const transformations =
|
||||
buffer.transformationsByLine[logicalLineIdx] ?? [];
|
||||
const tokens = parseInputForHighlighting(
|
||||
logicalLine,
|
||||
logicalLineIdx,
|
||||
transformations,
|
||||
...(focus && buffer.cursor[0] === logicalLineIdx
|
||||
? [buffer.cursor[1]]
|
||||
: []),
|
||||
);
|
||||
const startColInTransformed =
|
||||
buffer.visualToTransformedMap[absoluteVisualIdx] ?? 0;
|
||||
const visualStartCol = startColInTransformed;
|
||||
const visualEndCol = visualStartCol + cpLen(lineText);
|
||||
const segments = parseSegmentsFromTokens(
|
||||
tokens,
|
||||
visualStartCol,
|
||||
visualEndCol,
|
||||
);
|
||||
let charCount = 0;
|
||||
segments.forEach((seg, segIdx) => {
|
||||
const segLen = cpLen(seg.text);
|
||||
let display = seg.text;
|
||||
|
||||
if (isOnCursorLine) {
|
||||
const relativeVisualColForHighlight =
|
||||
cursorVisualColAbsolute;
|
||||
const segStart = charCount;
|
||||
const segEnd = segStart + segLen;
|
||||
if (
|
||||
relativeVisualColForHighlight >= segStart &&
|
||||
relativeVisualColForHighlight < segEnd
|
||||
) {
|
||||
const charToHighlight = cpSlice(
|
||||
display,
|
||||
0,
|
||||
relativeVisualColForHighlight - segStart,
|
||||
) +
|
||||
highlighted +
|
||||
cpSlice(
|
||||
display,
|
||||
relativeVisualColForHighlight - segStart + 1,
|
||||
);
|
||||
const highlighted = showCursor
|
||||
? chalk.inverse(charToHighlight)
|
||||
: charToHighlight;
|
||||
display =
|
||||
cpSlice(
|
||||
display,
|
||||
0,
|
||||
relativeVisualColForHighlight - segStart,
|
||||
) +
|
||||
highlighted +
|
||||
cpSlice(
|
||||
display,
|
||||
relativeVisualColForHighlight - segStart + 1,
|
||||
);
|
||||
}
|
||||
charCount = segEnd;
|
||||
} else {
|
||||
// Advance the running counter even when not on cursor line
|
||||
charCount += segLen;
|
||||
}
|
||||
charCount = segEnd;
|
||||
} else {
|
||||
// Advance the running counter even when not on cursor line
|
||||
charCount += segLen;
|
||||
}
|
||||
|
||||
const color =
|
||||
seg.type === 'command' ||
|
||||
seg.type === 'file' ||
|
||||
seg.type === 'paste'
|
||||
? theme.text.accent
|
||||
: theme.text.primary;
|
||||
const color =
|
||||
seg.type === 'command' ||
|
||||
seg.type === 'file' ||
|
||||
seg.type === 'paste'
|
||||
? theme.text.accent
|
||||
: theme.text.primary;
|
||||
|
||||
renderedLine.push(
|
||||
<Text key={`token-${segIdx}`} color={color}>
|
||||
{display}
|
||||
</Text>,
|
||||
);
|
||||
});
|
||||
|
||||
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
|
||||
if (
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText)
|
||||
) {
|
||||
if (!currentLineGhost) {
|
||||
renderedLine.push(
|
||||
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
||||
{showCursor ? chalk.inverse(' ') : ' '}
|
||||
<Text key={`token-${segIdx}`} color={color}>
|
||||
{display}
|
||||
</Text>,
|
||||
);
|
||||
});
|
||||
|
||||
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
|
||||
if (
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText)
|
||||
) {
|
||||
if (!currentLineGhost) {
|
||||
renderedLine.push(
|
||||
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
|
||||
{showCursor ? chalk.inverse(' ') : ' '}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const showCursorBeforeGhost =
|
||||
focus &&
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText) &&
|
||||
currentLineGhost;
|
||||
const showCursorBeforeGhost =
|
||||
focus &&
|
||||
isOnCursorLine &&
|
||||
cursorVisualColAbsolute === cpLen(lineText) &&
|
||||
currentLineGhost;
|
||||
|
||||
return (
|
||||
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
|
||||
<Text>
|
||||
{renderedLine}
|
||||
{showCursorBeforeGhost &&
|
||||
(showCursor ? chalk.inverse(' ') : ' ')}
|
||||
{currentLineGhost && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{currentLineGhost}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
.concat(
|
||||
additionalLines.map((ghostLine, index) => {
|
||||
const padding = Math.max(
|
||||
0,
|
||||
inputWidth - stringWidth(ghostLine),
|
||||
);
|
||||
return (
|
||||
<Text
|
||||
key={`ghost-line-${index}`}
|
||||
color={theme.text.secondary}
|
||||
>
|
||||
{ghostLine}
|
||||
{' '.repeat(padding)}
|
||||
</Text>
|
||||
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
|
||||
<Text
|
||||
terminalCursorFocus={showCursor && isOnCursorLine}
|
||||
terminalCursorPosition={cpIndexToOffset(
|
||||
lineText,
|
||||
cursorVisualColAbsolute,
|
||||
)}
|
||||
>
|
||||
{renderedLine}
|
||||
{showCursorBeforeGhost &&
|
||||
(showCursor ? chalk.inverse(' ') : ' ')}
|
||||
{currentLineGhost && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{currentLineGhost}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}),
|
||||
)
|
||||
)}
|
||||
})
|
||||
.concat(
|
||||
additionalLines.map((ghostLine, index) => {
|
||||
const padding = Math.max(
|
||||
0,
|
||||
inputWidth - stringWidth(ghostLine),
|
||||
);
|
||||
return (
|
||||
<Text
|
||||
key={`ghost-line-${index}`}
|
||||
color={theme.text.secondary}
|
||||
>
|
||||
{ghostLine}
|
||||
{' '.repeat(padding)}
|
||||
</Text>
|
||||
);
|
||||
}),
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</HalfLinePaddedBox>
|
||||
{useLineFallback ? (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderTop={false}
|
||||
borderBottom={true}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderColor={borderColor}
|
||||
width={terminalWidth}
|
||||
flexDirection="row"
|
||||
alignItems="flex-start"
|
||||
height={0}
|
||||
/>
|
||||
) : null}
|
||||
{suggestionsPosition === 'below' && suggestionsNode}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,9 @@ export const LogoutConfirmationDialog: React.FC<
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onSelect(LogoutChoice.EXIT);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user