mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-26 01:31:03 -07:00
Compare commits
37 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 |
@@ -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
|
||||
|
||||
|
||||
+19
-12
@@ -80,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
|
||||
|
||||
@@ -117,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -616,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):
|
||||
@@ -862,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
|
||||
|
||||
@@ -878,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:** `[]`
|
||||
@@ -889,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.
|
||||
|
||||
+10
-2
@@ -77,8 +77,16 @@
|
||||
{
|
||||
"label": "Ecosystem and extensibility",
|
||||
"items": [
|
||||
{ "label": "Agent skills (experimental)", "slug": "docs/cli/skills" },
|
||||
{ "label": "Hooks (experimental)", "slug": "docs/hooks" },
|
||||
{ "label": "Agent skills", "slug": "docs/cli/skills" },
|
||||
{
|
||||
"label": "Sub-agents (experimental)",
|
||||
"slug": "docs/core/subagents"
|
||||
},
|
||||
{
|
||||
"label": "Remote subagents (experimental)",
|
||||
"slug": "docs/core/remote-agents"
|
||||
},
|
||||
{ "label": "Hooks", "slug": "docs/hooks" },
|
||||
{ "label": "IDE integration", "slug": "docs/ide-integration" },
|
||||
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" }
|
||||
]
|
||||
|
||||
+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.
|
||||
|
||||
@@ -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
+8
-31
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"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/*"
|
||||
],
|
||||
@@ -2251,7 +2251,6 @@
|
||||
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^6.0.0",
|
||||
"@octokit/graphql": "^9.0.2",
|
||||
@@ -2432,7 +2431,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -2466,7 +2464,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
|
||||
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
@@ -2835,7 +2832,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
|
||||
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.0.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
@@ -2869,7 +2865,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
|
||||
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.0.1",
|
||||
"@opentelemetry/resources": "2.0.1"
|
||||
@@ -2922,7 +2917,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
|
||||
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.0.1",
|
||||
"@opentelemetry/resources": "2.0.1",
|
||||
@@ -4128,7 +4122,6 @@
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4406,7 +4399,6 @@
|
||||
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.35.0",
|
||||
"@typescript-eslint/types": "8.35.0",
|
||||
@@ -5399,7 +5391,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -8409,7 +8400,6 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8950,7 +8940,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -10552,7 +10541,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz",
|
||||
"integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
@@ -14311,7 +14299,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -14322,7 +14309,6 @@
|
||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -16559,7 +16545,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16783,8 +16768,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -16792,7 +16776,6 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -16965,7 +16948,6 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -17173,7 +17155,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -17287,7 +17268,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -17300,7 +17280,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -18005,7 +17984,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -18021,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",
|
||||
@@ -18077,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",
|
||||
@@ -18164,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 +18278,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -18323,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",
|
||||
@@ -18340,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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -218,6 +219,7 @@ async function configureExtensionSettings(
|
||||
setting.envVar,
|
||||
promptForSetting,
|
||||
scope,
|
||||
process.cwd(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# 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/
|
||||
@@ -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"
|
||||
}
|
||||
@@ -126,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: [],
|
||||
@@ -704,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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }],
|
||||
@@ -332,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,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -426,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.',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -932,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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1480,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',
|
||||
@@ -1561,7 +1573,6 @@ const SETTINGS_SCHEMA = {
|
||||
default: true,
|
||||
description: 'Enable Agent Skills.',
|
||||
showInDialog: true,
|
||||
ignoreInDocs: true,
|
||||
},
|
||||
disabled: {
|
||||
type: 'array',
|
||||
@@ -1591,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',
|
||||
|
||||
@@ -26,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';
|
||||
@@ -300,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>
|
||||
@@ -407,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;
|
||||
}
|
||||
@@ -430,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 ||
|
||||
@@ -1988,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';
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ export const LoginWithGoogleRestartDialog = ({
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onDismiss();
|
||||
return true;
|
||||
} else if (key.name === 'r' || key.name === 'R') {
|
||||
setTimeout(async () => {
|
||||
if (process.send) {
|
||||
@@ -38,7 +39,9 @@ export const LoginWithGoogleRestartDialog = ({
|
||||
await runExitCleanup();
|
||||
process.exit(RELAUNCH_EXIT_CODE);
|
||||
}, 100);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -17,7 +17,9 @@ export const AdminSettingsChangedDialog = () => {
|
||||
(key) => {
|
||||
if (keyMatchers[Command.RESTART_APP](key)) {
|
||||
handleRestart();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -57,6 +59,22 @@ export const DialogManager = ({
|
||||
terminalWidth: uiTerminalWidth,
|
||||
} = uiState;
|
||||
|
||||
const {
|
||||
request: askUserRequest,
|
||||
submit: askUserSubmit,
|
||||
cancel: askUserCancel,
|
||||
} = useAskUserActions();
|
||||
|
||||
if (askUserRequest) {
|
||||
return (
|
||||
<AskUserDialog
|
||||
questions={askUserRequest.questions}
|
||||
onSubmit={askUserSubmit}
|
||||
onCancel={askUserCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.adminSettingsChanged) {
|
||||
return <AdminSettingsChangedDialog />;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
@@ -56,6 +57,17 @@ 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[] = [
|
||||
{
|
||||
name: 'clear',
|
||||
@@ -1708,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: '',
|
||||
@@ -3368,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);
|
||||
|
||||
@@ -18,7 +18,12 @@ import {
|
||||
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';
|
||||
@@ -372,7 +377,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
// Insert at cursor position
|
||||
buffer.replaceRangeByOffset(offset, offset, textToInsert);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,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') {
|
||||
@@ -498,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
|
||||
@@ -519,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)) {
|
||||
@@ -544,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
|
||||
@@ -577,7 +581,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
escapeTimerRef.current = setTimeout(() => {
|
||||
resetEscapeState();
|
||||
}, 500);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Second ESC
|
||||
@@ -585,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) {
|
||||
@@ -629,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,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
|
||||
@@ -671,7 +675,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
keyMatchers[Command.NAVIGATION_UP](key) ||
|
||||
keyMatchers[Command.NAVIGATION_DOWN](key)
|
||||
) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -683,7 +687,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
(!completion.showSuggestions || completion.activeSuggestionIndex <= 0)
|
||||
) {
|
||||
handleSubmit(buffer.text);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (completion.showSuggestions) {
|
||||
@@ -691,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -725,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
|
||||
@@ -745,7 +749,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
if (completedText) {
|
||||
setExpandedSuggestionIndex(-1);
|
||||
handleSubmit(completedText.trim());
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -756,7 +760,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setExpandedSuggestionIndex(-1); // Reset expansion after selection
|
||||
}
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -767,7 +771,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
completion.promptCompletion.text
|
||||
) {
|
||||
completion.promptCompletion.accept();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!shellModeActive) {
|
||||
@@ -775,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 (
|
||||
@@ -801,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) &&
|
||||
@@ -813,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -840,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;
|
||||
@@ -853,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)) {
|
||||
@@ -877,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)) {
|
||||
@@ -914,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 (
|
||||
@@ -932,6 +936,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
completion.promptCompletion.clear();
|
||||
setExpandedSuggestionIndex(-1);
|
||||
}
|
||||
return handled;
|
||||
},
|
||||
[
|
||||
focus,
|
||||
@@ -1231,7 +1236,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
<Box flexGrow={1} flexDirection="column" ref={innerBoxRef}>
|
||||
{buffer.text.length === 0 && placeholder ? (
|
||||
showCursor ? (
|
||||
<Text>
|
||||
<Text
|
||||
terminalCursorFocus={showCursor}
|
||||
terminalCursorPosition={0}
|
||||
>
|
||||
{chalk.inverse(placeholder.slice(0, 1))}
|
||||
<Text color={theme.text.secondary}>
|
||||
{placeholder.slice(1)}
|
||||
@@ -1352,7 +1360,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
return (
|
||||
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
|
||||
<Text>
|
||||
<Text
|
||||
terminalCursorFocus={showCursor && isOnCursorLine}
|
||||
terminalCursorPosition={cpIndexToOffset(
|
||||
lineText,
|
||||
cursorVisualColAbsolute,
|
||||
)}
|
||||
>
|
||||
{renderedLine}
|
||||
{showCursorBeforeGhost &&
|
||||
(showCursor ? chalk.inverse(' ') : ' ')}
|
||||
|
||||
@@ -28,7 +28,9 @@ export const LogoutConfirmationDialog: React.FC<
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onSelect(LogoutChoice.EXIT);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -27,7 +27,9 @@ export function LoopDetectionConfirmation({
|
||||
onComplete({
|
||||
userSelection: 'keep',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { MainContent } from './MainContent.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
@@ -12,30 +12,38 @@ import { Box, Text } from 'ink';
|
||||
import type React from 'react';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../contexts/AppContext.js', () => ({
|
||||
useAppContext: () => ({
|
||||
version: '1.0.0',
|
||||
}),
|
||||
}));
|
||||
vi.mock('../contexts/AppContext.js', async () => {
|
||||
const actual = await vi.importActual('../contexts/AppContext.js');
|
||||
return {
|
||||
...actual,
|
||||
useAppContext: () => ({
|
||||
version: '1.0.0',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../contexts/UIStateContext.js', () => ({
|
||||
useUIState: () => ({
|
||||
history: [
|
||||
{ id: 1, role: 'user', content: 'Hello' },
|
||||
{ id: 2, role: 'model', content: 'Hi there' },
|
||||
],
|
||||
pendingHistoryItems: [],
|
||||
mainAreaWidth: 80,
|
||||
staticAreaMaxItemHeight: 20,
|
||||
availableTerminalHeight: 24,
|
||||
slashCommands: [],
|
||||
constrainHeight: false,
|
||||
isEditorDialogOpen: false,
|
||||
activePtyId: undefined,
|
||||
embeddedShellFocused: false,
|
||||
historyRemountKey: 0,
|
||||
}),
|
||||
}));
|
||||
vi.mock('../contexts/UIStateContext.js', async () => {
|
||||
const actual = await vi.importActual('../contexts/UIStateContext.js');
|
||||
return {
|
||||
...actual,
|
||||
useUIState: () => ({
|
||||
history: [
|
||||
{ id: 1, role: 'user', content: 'Hello' },
|
||||
{ id: 2, role: 'model', content: 'Hi there' },
|
||||
],
|
||||
pendingHistoryItems: [],
|
||||
mainAreaWidth: 80,
|
||||
staticAreaMaxItemHeight: 20,
|
||||
availableTerminalHeight: 24,
|
||||
slashCommands: [],
|
||||
constrainHeight: false,
|
||||
isEditorDialogOpen: false,
|
||||
activePtyId: undefined,
|
||||
embeddedShellFocused: false,
|
||||
historyRemountKey: 0,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../hooks/useAlternateBuffer.js', () => ({
|
||||
useAlternateBuffer: vi.fn(),
|
||||
@@ -95,7 +103,7 @@ describe('MainContent', () => {
|
||||
});
|
||||
|
||||
it('renders in normal buffer mode', async () => {
|
||||
const { lastFrame } = render(<MainContent />);
|
||||
const { lastFrame } = renderWithProviders(<MainContent />);
|
||||
await waitFor(() => expect(lastFrame()).toContain('AppHeader'));
|
||||
const output = lastFrame();
|
||||
|
||||
@@ -105,7 +113,7 @@ describe('MainContent', () => {
|
||||
|
||||
it('renders in alternate buffer mode', async () => {
|
||||
vi.mocked(useAlternateBuffer).mockReturnValue(true);
|
||||
const { lastFrame } = render(<MainContent />);
|
||||
const { lastFrame } = renderWithProviders(<MainContent />);
|
||||
await waitFor(() => expect(lastFrame()).toContain('ScrollableList'));
|
||||
const output = lastFrame();
|
||||
|
||||
@@ -116,7 +124,7 @@ describe('MainContent', () => {
|
||||
|
||||
it('does not constrain height in alternate buffer mode', async () => {
|
||||
vi.mocked(useAlternateBuffer).mockReturnValue(true);
|
||||
const { lastFrame } = render(<MainContent />);
|
||||
const { lastFrame } = renderWithProviders(<MainContent />);
|
||||
await waitFor(() => expect(lastFrame()).toContain('HistoryItem: Hello'));
|
||||
const output = lastFrame();
|
||||
|
||||
|
||||
@@ -6,16 +6,20 @@
|
||||
|
||||
import { Box, Static } from 'ink';
|
||||
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
||||
import { ShowMoreLines } from './ShowMoreLines.js';
|
||||
import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useAppContext } from '../contexts/AppContext.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { SCROLL_TO_ITEM_END } from './shared/VirtualizedList.js';
|
||||
import {
|
||||
SCROLL_TO_ITEM_END,
|
||||
type VirtualizedListRef,
|
||||
} from './shared/VirtualizedList.js';
|
||||
import { ScrollableList } from './shared/ScrollableList.js';
|
||||
import { useMemo, memo, useCallback } from 'react';
|
||||
import { useMemo, memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
|
||||
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
|
||||
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
|
||||
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
|
||||
const MemoizedAppHeader = memo(AppHeader);
|
||||
@@ -27,8 +31,21 @@ const MemoizedAppHeader = memo(AppHeader);
|
||||
export const MainContent = () => {
|
||||
const { version } = useAppContext();
|
||||
const uiState = useUIState();
|
||||
const config = useConfig();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
const confirmingTool = useConfirmingTool();
|
||||
const showConfirmationQueue =
|
||||
config.isEventDrivenSchedulerEnabled() && confirmingTool !== null;
|
||||
|
||||
const scrollableListRef = useRef<VirtualizedListRef<unknown>>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (showConfirmationQueue) {
|
||||
scrollableListRef.current?.scrollToEnd();
|
||||
}
|
||||
}, [showConfirmationQueue, confirmingTool]);
|
||||
|
||||
const {
|
||||
pendingHistoryItems,
|
||||
mainAreaWidth,
|
||||
@@ -59,27 +76,27 @@ export const MainContent = () => {
|
||||
|
||||
const pendingItems = useMemo(
|
||||
() => (
|
||||
<OverflowProvider>
|
||||
<Box flexDirection="column">
|
||||
{pendingHistoryItems.map((item, i) => (
|
||||
<HistoryItemDisplay
|
||||
key={i}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight && !isAlternateBuffer
|
||||
? availableTerminalHeight
|
||||
: undefined
|
||||
}
|
||||
terminalWidth={mainAreaWidth}
|
||||
item={{ ...item, id: 0 }}
|
||||
isPending={true}
|
||||
isFocused={!uiState.isEditorDialogOpen}
|
||||
activeShellPtyId={uiState.activePtyId}
|
||||
embeddedShellFocused={uiState.embeddedShellFocused}
|
||||
/>
|
||||
))}
|
||||
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
|
||||
</Box>
|
||||
</OverflowProvider>
|
||||
<Box flexDirection="column">
|
||||
{pendingHistoryItems.map((item, i) => (
|
||||
<HistoryItemDisplay
|
||||
key={i}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight && !isAlternateBuffer
|
||||
? availableTerminalHeight
|
||||
: undefined
|
||||
}
|
||||
terminalWidth={mainAreaWidth}
|
||||
item={{ ...item, id: 0 }}
|
||||
isPending={true}
|
||||
isFocused={!uiState.isEditorDialogOpen}
|
||||
activeShellPtyId={uiState.activePtyId}
|
||||
embeddedShellFocused={uiState.embeddedShellFocused}
|
||||
/>
|
||||
))}
|
||||
{showConfirmationQueue && confirmingTool && (
|
||||
<ToolConfirmationQueue confirmingTool={confirmingTool} />
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
[
|
||||
pendingHistoryItems,
|
||||
@@ -90,6 +107,8 @@ export const MainContent = () => {
|
||||
uiState.isEditorDialogOpen,
|
||||
uiState.activePtyId,
|
||||
uiState.embeddedShellFocused,
|
||||
showConfirmationQueue,
|
||||
confirmingTool,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -128,6 +147,7 @@ export const MainContent = () => {
|
||||
if (isAlternateBuffer) {
|
||||
return (
|
||||
<ScrollableList
|
||||
ref={scrollableListRef}
|
||||
hasFocus={!uiState.isEditorDialogOpen}
|
||||
width={uiState.terminalWidth}
|
||||
data={virtualizedData}
|
||||
|
||||
@@ -62,10 +62,13 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (key.name === 'tab') {
|
||||
setPersistMode((prev) => !prev);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -72,7 +72,9 @@ export const MultiFolderTrustDialog: React.FC<MultiFolderTrustDialogProps> = ({
|
||||
if (key.name === 'escape') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
handleCancel();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: !submitted },
|
||||
);
|
||||
|
||||
@@ -66,6 +66,7 @@ export function PermissionsModifyTrustDialog({
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onExit();
|
||||
return true;
|
||||
}
|
||||
if (needsRestart && key.name === 'r') {
|
||||
const success = commitTrustLevelChange();
|
||||
@@ -75,7 +76,9 @@ export function PermissionsModifyTrustDialog({
|
||||
} else {
|
||||
onExit();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -62,7 +62,9 @@ export const RewindConfirmation: React.FC<RewindConfirmationProps> = ({
|
||||
(key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
onConfirm(RewindOutcome.Cancel);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -90,7 +90,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
if (!selectedMessageId) {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
onExit();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
|
||||
if (
|
||||
@@ -98,12 +98,15 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
highlightedMessageId !== 'current-position'
|
||||
) {
|
||||
setExpandedMessageId(highlightedMessageId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
|
||||
setExpandedMessageId(null);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -775,10 +775,12 @@ export const useSessionBrowserInput = (
|
||||
state.setSearchQuery('');
|
||||
state.setActiveIndex(0);
|
||||
state.setScrollOffset(0);
|
||||
return true;
|
||||
} else if (key.name === 'backspace') {
|
||||
state.setSearchQuery((prev) => prev.slice(0, -1));
|
||||
state.setActiveIndex(0);
|
||||
state.setScrollOffset(0);
|
||||
return true;
|
||||
} else if (
|
||||
key.sequence &&
|
||||
key.sequence.length === 1 &&
|
||||
@@ -789,6 +791,7 @@ export const useSessionBrowserInput = (
|
||||
state.setSearchQuery((prev) => prev + key.sequence);
|
||||
state.setActiveIndex(0);
|
||||
state.setScrollOffset(0);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Navigation mode input handling. We're keeping the letter-based controls for non-search
|
||||
@@ -796,27 +799,33 @@ export const useSessionBrowserInput = (
|
||||
if (key.sequence === 'g') {
|
||||
state.setActiveIndex(0);
|
||||
state.setScrollOffset(0);
|
||||
return true;
|
||||
} else if (key.sequence === 'G') {
|
||||
state.setActiveIndex(state.totalSessions - 1);
|
||||
state.setScrollOffset(
|
||||
Math.max(0, state.totalSessions - SESSIONS_PER_PAGE),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
// Sorting controls.
|
||||
else if (key.sequence === 's') {
|
||||
cycleSortOrder();
|
||||
return true;
|
||||
} else if (key.sequence === 'r') {
|
||||
state.setSortReverse(!state.sortReverse);
|
||||
return true;
|
||||
}
|
||||
// Searching and exit controls.
|
||||
else if (key.sequence === '/') {
|
||||
state.setIsSearchMode(true);
|
||||
return true;
|
||||
} else if (
|
||||
key.sequence === 'q' ||
|
||||
key.sequence === 'Q' ||
|
||||
key.name === 'escape'
|
||||
) {
|
||||
onExit();
|
||||
return true;
|
||||
}
|
||||
// Delete session control.
|
||||
else if (key.sequence === 'x' || key.sequence === 'X') {
|
||||
@@ -846,12 +855,15 @@ export const useSessionBrowserInput = (
|
||||
);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// less-like u/d controls.
|
||||
else if (key.sequence === 'u') {
|
||||
moveSelection(-Math.round(SESSIONS_PER_PAGE / 2));
|
||||
return true;
|
||||
} else if (key.sequence === 'd') {
|
||||
moveSelection(Math.round(SESSIONS_PER_PAGE / 2));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -866,15 +878,21 @@ export const useSessionBrowserInput = (
|
||||
if (!selectedSession.isCurrentSession) {
|
||||
onResumeSession(selectedSession);
|
||||
}
|
||||
return true;
|
||||
} else if (key.name === 'up') {
|
||||
moveSelection(-1);
|
||||
return true;
|
||||
} else if (key.name === 'down') {
|
||||
moveSelection(1);
|
||||
return true;
|
||||
} else if (key.name === 'pageup') {
|
||||
moveSelection(-SESSIONS_PER_PAGE);
|
||||
return true;
|
||||
} else if (key.name === 'pagedown') {
|
||||
moveSelection(SESSIONS_PER_PAGE);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -201,10 +201,13 @@ export function ThemeDialog({
|
||||
(key) => {
|
||||
if (key.name === 'tab') {
|
||||
setMode((prev) => (prev === 'theme' ? 'scope' : 'theme'));
|
||||
return true;
|
||||
}
|
||||
if (key.name === 'escape') {
|
||||
onCancel();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Box } from 'ink';
|
||||
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
|
||||
import { ToolCallStatus } from '../types.js';
|
||||
import { ToolCallStatus, StreamingState } from '../types.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import type { ConfirmingToolState } from '../hooks/useConfirmingTool.js';
|
||||
|
||||
@@ -86,4 +88,95 @@ describe('ToolConfirmationQueue', () => {
|
||||
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
|
||||
it('renders expansion hint when content is long and constrained', async () => {
|
||||
const longDiff = '@@ -1,1 +1,50 @@\n' + '+line\n'.repeat(50);
|
||||
const confirmingTool = {
|
||||
tool: {
|
||||
callId: 'call-1',
|
||||
name: 'replace',
|
||||
description: 'edit file',
|
||||
status: ToolCallStatus.Confirming,
|
||||
confirmationDetails: {
|
||||
type: 'edit' as const,
|
||||
title: 'Confirm edit',
|
||||
fileName: 'test.ts',
|
||||
filePath: '/test.ts',
|
||||
fileDiff: longDiff,
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
onConfirm: vi.fn(),
|
||||
},
|
||||
},
|
||||
index: 1,
|
||||
total: 1,
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<Box flexDirection="column" height={30}>
|
||||
<ToolConfirmationQueue
|
||||
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
|
||||
/>
|
||||
</Box>,
|
||||
{
|
||||
config: mockConfig,
|
||||
useAlternateBuffer: false,
|
||||
uiState: {
|
||||
terminalWidth: 80,
|
||||
terminalHeight: 20,
|
||||
constrainHeight: true,
|
||||
streamingState: StreamingState.WaitingForConfirmation,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(lastFrame()).toContain('Press ctrl-o to show more lines'),
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(lastFrame()).toContain('Press ctrl-o to show more lines');
|
||||
});
|
||||
|
||||
it('does not render expansion hint when constrainHeight is false', () => {
|
||||
const longDiff = 'line\n'.repeat(50);
|
||||
const confirmingTool = {
|
||||
tool: {
|
||||
callId: 'call-1',
|
||||
name: 'replace',
|
||||
description: 'edit file',
|
||||
status: ToolCallStatus.Confirming,
|
||||
confirmationDetails: {
|
||||
type: 'edit' as const,
|
||||
title: 'Confirm edit',
|
||||
fileName: 'test.ts',
|
||||
filePath: '/test.ts',
|
||||
fileDiff: longDiff,
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
onConfirm: vi.fn(),
|
||||
},
|
||||
},
|
||||
index: 1,
|
||||
total: 1,
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ToolConfirmationQueue
|
||||
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
|
||||
/>,
|
||||
{
|
||||
config: mockConfig,
|
||||
uiState: {
|
||||
terminalWidth: 80,
|
||||
terminalHeight: 40,
|
||||
constrainHeight: false,
|
||||
streamingState: StreamingState.WaitingForConfirmation,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('Press ctrl-o to show more lines');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,10 @@ import { ToolConfirmationMessage } from './messages/ToolConfirmationMessage.js';
|
||||
import { ToolStatusIndicator, ToolInfo } from './messages/ToolShared.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import type { ConfirmingToolState } from '../hooks/useConfirmingTool.js';
|
||||
import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||
import { ShowMoreLines } from './ShowMoreLines.js';
|
||||
import { StickyHeader } from './StickyHeader.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
|
||||
interface ToolConfirmationQueueProps {
|
||||
confirmingTool: ConfirmingToolState;
|
||||
@@ -21,7 +25,8 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
confirmingTool,
|
||||
}) => {
|
||||
const config = useConfig();
|
||||
const { terminalWidth, terminalHeight } = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const { mainAreaWidth, terminalHeight, constrainHeight } = useUIState();
|
||||
const { tool, index, total } = confirmingTool;
|
||||
|
||||
// Safety check: ToolConfirmationMessage requires confirmationDetails
|
||||
@@ -38,52 +43,85 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
|
||||
// - 2 lines for the rounded border
|
||||
// - 2 lines for the Header (text + margin)
|
||||
// - 2 lines for Tool Identity (text + margin)
|
||||
const availableContentHeight = Math.max(maxHeight - 6, 4);
|
||||
const availableContentHeight =
|
||||
constrainHeight && !isAlternateBuffer
|
||||
? Math.max(maxHeight - 6, 4)
|
||||
: undefined;
|
||||
|
||||
const borderColor = theme.status.warning;
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.warning}
|
||||
paddingX={1}
|
||||
// Matches existing layout spacing
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box marginBottom={1} justifyContent="space-between">
|
||||
<Text color={theme.status.warning} bold>
|
||||
Action Required
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{index} of {total}
|
||||
</Text>
|
||||
</Box>
|
||||
<OverflowProvider>
|
||||
<Box flexDirection="column" width={mainAreaWidth} flexShrink={0}>
|
||||
<StickyHeader
|
||||
width={mainAreaWidth}
|
||||
isFirst={true}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={false}
|
||||
>
|
||||
<Box flexDirection="column" width={mainAreaWidth - 4}>
|
||||
{/* Header */}
|
||||
<Box marginBottom={1} justifyContent="space-between">
|
||||
<Text color={theme.status.warning} bold>
|
||||
Action Required
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{index} of {total}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Tool Identity (Context) */}
|
||||
<Box marginBottom={1}>
|
||||
<ToolStatusIndicator status={tool.status} name={tool.name} />
|
||||
<ToolInfo
|
||||
name={tool.name}
|
||||
status={tool.status}
|
||||
description={tool.description}
|
||||
emphasis="high"
|
||||
{/* Tool Identity (Context) */}
|
||||
<Box>
|
||||
<ToolStatusIndicator status={tool.status} name={tool.name} />
|
||||
<ToolInfo
|
||||
name={tool.name}
|
||||
status={tool.status}
|
||||
description={tool.description}
|
||||
emphasis="high"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</StickyHeader>
|
||||
|
||||
<Box
|
||||
width={mainAreaWidth}
|
||||
borderStyle="round"
|
||||
borderColor={borderColor}
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
paddingX={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
{/* Interactive Area */}
|
||||
{/*
|
||||
Note: We force isFocused={true} because if this component is rendered,
|
||||
it effectively acts as a modal over the shell/composer.
|
||||
*/}
|
||||
<ToolConfirmationMessage
|
||||
callId={tool.callId}
|
||||
confirmationDetails={tool.confirmationDetails}
|
||||
config={config}
|
||||
terminalWidth={mainAreaWidth - 4} // Adjust for parent border/padding
|
||||
availableTerminalHeight={availableContentHeight}
|
||||
isFocused={true}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
height={0}
|
||||
width={mainAreaWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={true}
|
||||
borderColor={borderColor}
|
||||
borderStyle="round"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Interactive Area */}
|
||||
{/*
|
||||
Note: We force isFocused={true} because if this component is rendered,
|
||||
it effectively acts as a modal over the shell/composer.
|
||||
*/}
|
||||
<ToolConfirmationMessage
|
||||
callId={tool.callId}
|
||||
confirmationDetails={tool.confirmationDetails}
|
||||
config={config}
|
||||
terminalWidth={terminalWidth - 4} // Adjust for parent border/padding
|
||||
availableTerminalHeight={availableContentHeight}
|
||||
isFocused={true}
|
||||
/>
|
||||
</Box>
|
||||
<Box paddingX={2} marginBottom={1}>
|
||||
<ShowMoreLines constrainHeight={constrainHeight} />
|
||||
</Box>
|
||||
</OverflowProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -53,10 +53,13 @@ export function ValidationDialog({
|
||||
(key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key) || keyMatchers[Command.QUIT](key)) {
|
||||
onChoice('cancel');
|
||||
return true;
|
||||
} else if (state === 'waiting' && keyMatchers[Command.RETURN](key)) {
|
||||
// User confirmed verification is complete - transition to 'complete' state
|
||||
setState('complete');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: state !== 'complete' },
|
||||
);
|
||||
|
||||
+2
-1
@@ -130,5 +130,6 @@ Tips for getting started:
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> Hello Gemini
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
✦ Hello User!"
|
||||
✦ Hello User!
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -34,18 +34,18 @@ exports[`AskUserDialog > Text type questions > shows default placeholder when no
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > allows navigating to Review tab and back 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ← □ Tests │ □ Docs │ ≡ Review → │
|
||||
│ │
|
||||
│ Review your answers: │
|
||||
│ │
|
||||
│ ⚠ You have 2 unanswered questions │
|
||||
│ │
|
||||
│ Tests → (not answered) │
|
||||
│ Docs → (not answered) │
|
||||
│ │
|
||||
│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭─────────────────────────────────────────────────────────────────╮
|
||||
│ ← □ Tests │ □ Docs │ ≡ Review → │
|
||||
│ │
|
||||
│ Review your answers: │
|
||||
│ │
|
||||
│ ⚠ You have 2 unanswered questions │
|
||||
│ │
|
||||
│ Tests → (not answered) │
|
||||
│ Docs → (not answered) │
|
||||
│ │
|
||||
│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel │
|
||||
╰─────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > hides progress header for single question 1`] = `
|
||||
@@ -123,16 +123,16 @@ exports[`AskUserDialog > shows progress header for multiple questions 1`] = `
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > shows warning for unanswered questions on Review tab 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ← □ License │ □ README │ ≡ Review → │
|
||||
│ │
|
||||
│ Review your answers: │
|
||||
│ │
|
||||
│ ⚠ You have 2 unanswered questions │
|
||||
│ │
|
||||
│ License → (not answered) │
|
||||
│ README → (not answered) │
|
||||
│ │
|
||||
│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭─────────────────────────────────────────────────────────────────╮
|
||||
│ ← □ License │ □ README │ ≡ Review → │
|
||||
│ │
|
||||
│ Review your answers: │
|
||||
│ │
|
||||
│ ⚠ You have 2 unanswered questions │
|
||||
│ │
|
||||
│ License → (not answered) │
|
||||
│ README → (not answered) │
|
||||
│ │
|
||||
│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel │
|
||||
╰─────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -51,7 +51,8 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
50 Line 50
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a full gemini_content item when using availableTerminalHeightGemini 1`] = `
|
||||
@@ -105,13 +106,13 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
50 Line 50
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a truncated gemini item 1`] = `
|
||||
"✦ Example code block:
|
||||
... first 41 lines hidden ...
|
||||
42 Line 42
|
||||
... first 42 lines hidden ...
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
@@ -119,13 +120,13 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
50 Line 50
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a truncated gemini_content item 1`] = `
|
||||
" Example code block:
|
||||
... first 41 lines hidden ...
|
||||
42 Line 42
|
||||
... first 42 lines hidden ...
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
@@ -133,7 +134,8 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
50 Line 50
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should render a full gemini item when using availableTerminalHeightGemini 1`] = `
|
||||
@@ -187,7 +189,8 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should r
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
50 Line 50
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should render a full gemini_content item when using availableTerminalHeightGemini 1`] = `
|
||||
@@ -241,7 +244,8 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should r
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
50 Line 50
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should render a truncated gemini item 1`] = `
|
||||
@@ -295,7 +299,8 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should r
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
50 Line 50
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should render a truncated gemini_content item 1`] = `
|
||||
@@ -349,7 +354,8 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should r
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
50 Line 50
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > renders AgentsStatus for "agents_list" type 1`] = `
|
||||
|
||||
@@ -4,6 +4,5 @@ exports[`MainContent > does not constrain height in alternate buffer mode 1`] =
|
||||
"ScrollableList
|
||||
AppHeader
|
||||
HistoryItem: Hello (height: undefined)
|
||||
HistoryItem: Hi there (height: undefined)
|
||||
ShowMoreLines"
|
||||
HistoryItem: Hi there (height: undefined)"
|
||||
`;
|
||||
|
||||
@@ -413,3 +413,371 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render accessibility settings enabled correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
|
||||
│ │ Search to filter │ │
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ Hide the window title bar │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render all boolean settings disabled correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
|
||||
│ │ Search to filter │ │
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Preview Features (e.g., models) false* │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode false* │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update false* │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ Hide the window title bar │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render default state correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
|
||||
│ │ Search to filter │ │
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ Hide the window title bar │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render file filtering settings configured correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
|
||||
│ │ Search to filter │ │
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ Hide the window title bar │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render focused on scope selector correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Settings │
|
||||
│ │
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
|
||||
│ │ Search to filter │ │
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ Preview Features (e.g., models) false │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ Hide the window title bar │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ > Apply To │
|
||||
│ ● 1. User Settings │
|
||||
│ 2. Workspace Settings │
|
||||
│ 3. System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render mixed boolean and number settings correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
|
||||
│ │ Search to filter │ │
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode true* │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ Hide the window title bar │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render tools and security settings correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
|
||||
│ │ Search to filter │ │
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ Hide the window title bar │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`SettingsDialog > Snapshot Tests > should render various boolean settings enabled correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ > Settings │
|
||||
│ │
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
|
||||
│ │ Search to filter │ │
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Preview Features (e.g., models) true* │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode true* │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Prompt Completion false │
|
||||
│ Enable AI-powered prompt completion suggestions while typing. │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ Enable debug logging of keystrokes to the console. │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ Enable automatic session cleanup │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ The format of the CLI output. Can be \`text\` or \`json\`. │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ Hide the window title bar │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ System Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus, Esc to close) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,60 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ToolConfirmationQueue > does not render expansion hint when constrainHeight is false 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Action Required 1 of 1 │
|
||||
│ │
|
||||
│ ? replace edit file │
|
||||
│ │
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────╮ │
|
||||
│ │ │ │
|
||||
│ │ No changes detected. │ │
|
||||
│ │ │ │
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ Apply this change? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
│ 2. Allow for this session │
|
||||
│ 3. Modify with external editor │
|
||||
│ 4. No, suggest changes (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToolConfirmationQueue > renders expansion hint when content is long and constrained 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Action Required 1 of 1 │
|
||||
│ │
|
||||
│ ? replace edit file │
|
||||
│ │
|
||||
│ ... first 49 lines hidden ... │
|
||||
│ 50 line │
|
||||
│ Apply this change? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
│ 2. Allow for this session │
|
||||
│ 3. Modify with external editor │
|
||||
│ 4. No, suggest changes (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Press ctrl-o to show more lines
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ToolConfirmationQueue > renders the confirming tool with progress indicator 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Action Required 1 of 3 │
|
||||
@@ -7,12 +62,12 @@ exports[`ToolConfirmationQueue > renders the confirming tool with progress indic
|
||||
│ ? ls list files │
|
||||
│ │
|
||||
│ ls │
|
||||
│ │
|
||||
│ Allow execution of: 'ls'? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
│ 2. Allow for this session │
|
||||
│ 3. No, suggest changes (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import type React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { ShowMoreLines } from '../ShowMoreLines.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
@@ -42,11 +43,18 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
isAlternateBuffer ? undefined : availableTerminalHeight
|
||||
isAlternateBuffer || availableTerminalHeight === undefined
|
||||
? undefined
|
||||
: Math.max(availableTerminalHeight - 1, 1)
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
renderMarkdown={renderMarkdown}
|
||||
/>
|
||||
<Box marginBottom={1}>
|
||||
<ShowMoreLines
|
||||
constrainHeight={availableTerminalHeight !== undefined}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import type React from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { ShowMoreLines } from '../ShowMoreLines.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
|
||||
@@ -40,11 +41,18 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={
|
||||
isAlternateBuffer ? undefined : availableTerminalHeight
|
||||
isAlternateBuffer || availableTerminalHeight === undefined
|
||||
? undefined
|
||||
: Math.max(availableTerminalHeight - 1, 1)
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
renderMarkdown={renderMarkdown}
|
||||
/>
|
||||
<Box marginBottom={1}>
|
||||
<ShowMoreLines
|
||||
constrainHeight={availableTerminalHeight !== undefined}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -61,7 +61,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
|
||||
const handleConfirm = useCallback(
|
||||
(outcome: ToolConfirmationOutcome) => {
|
||||
void confirm(callId, outcome).catch((error) => {
|
||||
void confirm(callId, outcome).catch((error: unknown) => {
|
||||
debugLogger.error(
|
||||
`Failed to handle tool confirmation for ${callId}:`,
|
||||
error,
|
||||
@@ -75,10 +75,12 @@ export const ToolConfirmationMessage: React.FC<
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (!isFocused) return;
|
||||
if (!isFocused) return false;
|
||||
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
||||
handleConfirm(ToolConfirmationOutcome.Cancel);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: isFocused },
|
||||
);
|
||||
@@ -238,7 +240,8 @@ export const ToolConfirmationMessage: React.FC<
|
||||
MARGIN_BODY_BOTTOM +
|
||||
HEIGHT_QUESTION +
|
||||
MARGIN_QUESTION_BOTTOM +
|
||||
optionsCount;
|
||||
optionsCount +
|
||||
1; // Reserve one line for 'ShowMoreLines' hint
|
||||
|
||||
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
|
||||
}, [availableTerminalHeight, getOptions]);
|
||||
@@ -429,7 +432,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
<Box flexDirection="column" paddingTop={0} paddingBottom={1}>
|
||||
{/* Body Content (Diff Renderer or Command Info) */}
|
||||
{/* No separate context display here anymore for edits */}
|
||||
<Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}>
|
||||
<Box flexGrow={1} flexShrink={1} overflow="hidden">
|
||||
<MaxSizedBox
|
||||
maxHeight={availableBodyContentHeight()}
|
||||
maxWidth={terminalWidth}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ToolGroupMessage } from './ToolGroupMessage.js';
|
||||
import type {
|
||||
ToolCallConfirmationDetails,
|
||||
Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { useToolActions } from '../../contexts/ToolActionsContext.js';
|
||||
import {
|
||||
StreamingState,
|
||||
ToolCallStatus,
|
||||
type IndividualToolCallDisplay,
|
||||
} from '../../types.js';
|
||||
import { OverflowProvider } from '../../contexts/OverflowContext.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
|
||||
vi.mock('../../contexts/ToolActionsContext.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import('../../contexts/ToolActionsContext.js')
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
useToolActions: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('ToolConfirmationMessage Overflow', () => {
|
||||
const mockConfirm = vi.fn();
|
||||
vi.mocked(useToolActions).mockReturnValue({
|
||||
confirm: mockConfirm,
|
||||
cancel: vi.fn(),
|
||||
isDiffingEnabled: false,
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
isTrustedFolder: () => true,
|
||||
getIdeMode: () => false,
|
||||
getMessageBus: () => ({
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
publish: vi.fn(),
|
||||
}),
|
||||
isEventDrivenSchedulerEnabled: () => false,
|
||||
getTheme: () => ({
|
||||
status: { warning: 'yellow' },
|
||||
text: { primary: 'white', secondary: 'gray', link: 'blue' },
|
||||
border: { default: 'gray' },
|
||||
ui: { symbol: 'cyan' },
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
it('should display "press ctrl-o" hint when content overflows in ToolGroupMessage', async () => {
|
||||
// Large diff that will definitely overflow
|
||||
const diffLines = ['--- a/test.txt', '+++ b/test.txt', '@@ -1,20 +1,20 @@'];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
diffLines.push(`+ line ${i + 1}`);
|
||||
}
|
||||
const fileDiff = diffLines.join('\n');
|
||||
|
||||
const confirmationDetails: ToolCallConfirmationDetails = {
|
||||
type: 'edit',
|
||||
title: 'Confirm Edit',
|
||||
fileName: 'test.txt',
|
||||
filePath: '/test.txt',
|
||||
fileDiff,
|
||||
originalContent: '',
|
||||
newContent: 'lots of lines',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
|
||||
const toolCalls: IndividualToolCallDisplay[] = [
|
||||
{
|
||||
callId: 'test-call-id',
|
||||
name: 'test-tool',
|
||||
description: 'a test tool',
|
||||
status: ToolCallStatus.Confirming,
|
||||
confirmationDetails,
|
||||
resultDisplay: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<ToolGroupMessage
|
||||
groupId={1}
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={15} // Small height to force overflow
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{
|
||||
config: mockConfig,
|
||||
uiState: {
|
||||
streamingState: StreamingState.WaitingForConfirmation,
|
||||
constrainHeight: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// ResizeObserver might take a tick
|
||||
await waitFor(() =>
|
||||
expect(lastFrame()).toContain('Press ctrl-o to show more lines'),
|
||||
);
|
||||
|
||||
const frame = lastFrame();
|
||||
expect(frame).toBeDefined();
|
||||
if (frame) {
|
||||
expect(frame).toContain('Press ctrl-o to show more lines');
|
||||
// Ensure it's AFTER the bottom border
|
||||
const linesOfOutput = frame.split('\n');
|
||||
const bottomBorderIndex = linesOfOutput.findLastIndex((l) =>
|
||||
l.includes('╰─'),
|
||||
);
|
||||
const hintIndex = linesOfOutput.findIndex((l) =>
|
||||
l.includes('Press ctrl-o to show more lines'),
|
||||
);
|
||||
expect(hintIndex).toBeGreaterThan(bottomBorderIndex);
|
||||
expect(frame).toMatchSnapshot();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,9 @@ import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import { isShellTool, isThisShellFocused } from './ToolShared.js';
|
||||
import { ASK_USER_DISPLAY_NAME } from '@google/gemini-cli-core';
|
||||
import { ShowMoreLines } from '../ShowMoreLines.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
interface ToolGroupMessageProps {
|
||||
groupId: number;
|
||||
@@ -25,18 +28,38 @@ interface ToolGroupMessageProps {
|
||||
activeShellPtyId?: number | null;
|
||||
embeddedShellFocused?: boolean;
|
||||
onShellInputSubmit?: (input: string) => void;
|
||||
borderTop?: boolean;
|
||||
borderBottom?: boolean;
|
||||
}
|
||||
|
||||
// Helper to identify Ask User tools that are in progress (have their own dialog UI)
|
||||
const isAskUserInProgress = (t: IndividualToolCallDisplay): boolean =>
|
||||
t.name === ASK_USER_DISPLAY_NAME &&
|
||||
[
|
||||
ToolCallStatus.Pending,
|
||||
ToolCallStatus.Executing,
|
||||
ToolCallStatus.Confirming,
|
||||
].includes(t.status);
|
||||
|
||||
// Main component renders the border and maps the tools using ToolMessage
|
||||
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
toolCalls,
|
||||
toolCalls: allToolCalls,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
isFocused = true,
|
||||
activeShellPtyId,
|
||||
embeddedShellFocused,
|
||||
borderTop: borderTopOverride,
|
||||
borderBottom: borderBottomOverride,
|
||||
}) => {
|
||||
// Filter out in-progress Ask User tools (they have their own AskUserDialog UI)
|
||||
const toolCalls = useMemo(
|
||||
() => allToolCalls.filter((t) => !isAskUserInProgress(t)),
|
||||
[allToolCalls],
|
||||
);
|
||||
|
||||
const config = useConfig();
|
||||
const { constrainHeight } = useUIState();
|
||||
|
||||
const isEventDriven = config.isEventDrivenSchedulerEnabled();
|
||||
|
||||
@@ -94,8 +117,12 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
);
|
||||
|
||||
// If all tools are hidden (e.g. group only contains confirming or pending tools),
|
||||
// render nothing in the history log.
|
||||
if (visibleToolCalls.length === 0) {
|
||||
// render nothing in the history log unless we have a border override.
|
||||
if (
|
||||
visibleToolCalls.length === 0 &&
|
||||
borderTopOverride === undefined &&
|
||||
borderBottomOverride === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -145,7 +172,10 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
: toolAwaitingApproval
|
||||
? ('low' as const)
|
||||
: ('medium' as const),
|
||||
isFirst,
|
||||
isFirst:
|
||||
borderTopOverride !== undefined
|
||||
? borderTopOverride && isFirst
|
||||
: isFirst,
|
||||
borderColor,
|
||||
borderDimColor,
|
||||
};
|
||||
@@ -209,20 +239,25 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
We have to keep the bottom border separate so it doesn't get
|
||||
drawn over by the sticky header directly inside it.
|
||||
*/
|
||||
visibleToolCalls.length > 0 && (
|
||||
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
|
||||
<Box
|
||||
height={0}
|
||||
width={terminalWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
borderBottom={true}
|
||||
borderBottom={borderBottomOverride ?? true}
|
||||
borderColor={borderColor}
|
||||
borderDimColor={borderDimColor}
|
||||
borderStyle="round"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{(borderBottomOverride ?? true) && visibleToolCalls.length > 0 && (
|
||||
<Box paddingX={1}>
|
||||
<ShowMoreLines constrainHeight={constrainHeight} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -56,6 +56,9 @@ vi.mock('../../contexts/OverflowContext.js', () => ({
|
||||
addOverflowingId: vi.fn(),
|
||||
removeOverflowingId: vi.fn(),
|
||||
}),
|
||||
useOverflowState: () => ({
|
||||
overflowingIds: new Set(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ToolResultDisplay', () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { tryParseJSON } from '../../../utils/jsonoutput.js';
|
||||
|
||||
const STATIC_HEIGHT = 1;
|
||||
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
|
||||
const RESERVED_LINE_COUNT = 6; // for tool name, status, padding, and 'ShowMoreLines' hint
|
||||
const MIN_LINES_SHOWN = 2; // show at least this many lines
|
||||
|
||||
// Large threshold to ensure we don't cause performance issues for very large
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ToolGroupMessage } from './ToolGroupMessage.js';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import {
|
||||
StreamingState,
|
||||
ToolCallStatus,
|
||||
type IndividualToolCallDisplay,
|
||||
} from '../../types.js';
|
||||
import { OverflowProvider } from '../../contexts/OverflowContext.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
|
||||
describe('ToolResultDisplay Overflow', () => {
|
||||
it('should display "press ctrl-o" hint when content overflows in ToolGroupMessage', async () => {
|
||||
// Large output that will definitely overflow
|
||||
const lines = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
lines.push(`line ${i + 1}`);
|
||||
}
|
||||
const resultDisplay = lines.join('\n');
|
||||
|
||||
const toolCalls: IndividualToolCallDisplay[] = [
|
||||
{
|
||||
callId: 'call-1',
|
||||
name: 'test-tool',
|
||||
description: 'a test tool',
|
||||
status: ToolCallStatus.Success,
|
||||
resultDisplay,
|
||||
confirmationDetails: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<ToolGroupMessage
|
||||
groupId={1}
|
||||
toolCalls={toolCalls}
|
||||
availableTerminalHeight={15} // Small height to force overflow
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{
|
||||
uiState: {
|
||||
streamingState: StreamingState.Idle,
|
||||
constrainHeight: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// ResizeObserver might take a tick
|
||||
await waitFor(() =>
|
||||
expect(lastFrame()).toContain('Press ctrl-o to show more lines'),
|
||||
);
|
||||
|
||||
const frame = lastFrame();
|
||||
expect(frame).toBeDefined();
|
||||
if (frame) {
|
||||
expect(frame).toContain('Press ctrl-o to show more lines');
|
||||
// Ensure it's AFTER the bottom border
|
||||
const linesOfOutput = frame.split('\n');
|
||||
const bottomBorderIndex = linesOfOutput.findLastIndex((l) =>
|
||||
l.includes('╰─'),
|
||||
);
|
||||
const hintIndex = linesOfOutput.findIndex((l) =>
|
||||
l.includes('Press ctrl-o to show more lines'),
|
||||
);
|
||||
expect(hintIndex).toBeGreaterThan(bottomBorderIndex);
|
||||
expect(frame).toMatchSnapshot();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import { theme } from '../../semantic-colors.js';
|
||||
import {
|
||||
type Config,
|
||||
SHELL_TOOL_NAME,
|
||||
ASK_USER_DISPLAY_NAME,
|
||||
type ToolResultDisplay,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useInactivityTimer } from '../../hooks/useInactivityTimer.js';
|
||||
@@ -198,13 +199,28 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
|
||||
}
|
||||
}
|
||||
}, [emphasis]);
|
||||
|
||||
// Hide description for completed Ask User tools (the result display speaks for itself)
|
||||
const isCompletedAskUser =
|
||||
name === ASK_USER_DISPLAY_NAME &&
|
||||
[
|
||||
ToolCallStatus.Success,
|
||||
ToolCallStatus.Error,
|
||||
ToolCallStatus.Canceled,
|
||||
].includes(status);
|
||||
|
||||
return (
|
||||
<Box overflow="hidden" height={1} flexGrow={1} flexShrink={1}>
|
||||
<Text strikethrough={status === ToolCallStatus.Canceled} wrap="truncate">
|
||||
<Text color={nameColor} bold>
|
||||
{name}
|
||||
</Text>{' '}
|
||||
<Text color={theme.text.secondary}>{description}</Text>
|
||||
</Text>
|
||||
{!isCompletedAskUser && (
|
||||
<>
|
||||
{' '}
|
||||
<Text color={theme.text.secondary}>{description}</Text>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -44,4 +44,16 @@ describe('UserMessage', () => {
|
||||
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('transforms image paths in user message', () => {
|
||||
const message = 'Check out this image: @/path/to/my-image.png';
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<UserMessage text={message} width={80} />,
|
||||
{ width: 80 },
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('[Image my-image.png]');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,10 +5,15 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { SCREEN_READER_USER_PREFIX } from '../../textConstants.js';
|
||||
import { isSlashCommand as checkIsSlashCommand } from '../../utils/commandUtils.js';
|
||||
import {
|
||||
calculateTransformationsForLine,
|
||||
calculateTransformedLine,
|
||||
} from '../shared/text-buffer.js';
|
||||
import { HalfLinePaddedBox } from '../shared/HalfLinePaddedBox.js';
|
||||
import { DEFAULT_BACKGROUND_OPACITY } from '../../constants.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
@@ -27,6 +32,24 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text, width }) => {
|
||||
|
||||
const textColor = isSlashCommand ? theme.text.accent : theme.text.secondary;
|
||||
|
||||
const displayText = useMemo(() => {
|
||||
if (!text) return text;
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const transformations = calculateTransformationsForLine(line);
|
||||
// We pass a cursor position of [-1, -1] so that no transformations are expanded (e.g. images remain collapsed)
|
||||
const { transformedLine } = calculateTransformedLine(
|
||||
line,
|
||||
0, // line index doesn't matter since cursor is [-1, -1]
|
||||
[-1, -1],
|
||||
transformations,
|
||||
);
|
||||
return transformedLine;
|
||||
})
|
||||
.join('\n');
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<HalfLinePaddedBox
|
||||
backgroundBaseColor={theme.border.default}
|
||||
@@ -51,7 +74,7 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text, width }) => {
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text wrap="wrap" color={textColor}>
|
||||
{text}
|
||||
{displayText}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -5,13 +5,15 @@ exports[`<GeminiMessage /> - Raw Markdown Display Snapshots > renders pending st
|
||||
|
||||
\`\`\`javascript
|
||||
const x = 1;
|
||||
\`\`\`"
|
||||
\`\`\`
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<GeminiMessage /> - Raw Markdown Display Snapshots > renders pending state with renderMarkdown=true 1`] = `
|
||||
"✦ Test bold and code markdown
|
||||
|
||||
1 const x = 1;"
|
||||
1 const x = 1;
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<GeminiMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=false '(raw markdown with syntax highlightin…' 1`] = `
|
||||
@@ -19,11 +21,13 @@ exports[`<GeminiMessage /> - Raw Markdown Display Snapshots > renders with rende
|
||||
|
||||
\`\`\`javascript
|
||||
const x = 1;
|
||||
\`\`\`"
|
||||
\`\`\`
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<GeminiMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=true '(default)' 1`] = `
|
||||
"✦ Test bold and code markdown
|
||||
|
||||
1 const x = 1;"
|
||||
1 const x = 1;
|
||||
"
|
||||
`;
|
||||
|
||||
-1
@@ -5,7 +5,6 @@ exports[`ToolConfirmationMessage Redirection > should display redirection warnin
|
||||
|
||||
Note: Command contains redirection which can be undesirable.
|
||||
Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future.
|
||||
|
||||
Allow execution of: 'echo, redirection (>)'?
|
||||
|
||||
● 1. Allow once
|
||||
|
||||
-11
@@ -4,7 +4,6 @@ exports[`ToolConfirmationMessage > should display multiple commands for exec typ
|
||||
"echo "hello"
|
||||
ls -la
|
||||
whoami
|
||||
|
||||
Allow execution of 3 commands?
|
||||
|
||||
● 1. Allow once
|
||||
@@ -18,7 +17,6 @@ exports[`ToolConfirmationMessage > should display urls if prompt and url are dif
|
||||
|
||||
URLs to fetch:
|
||||
- https://raw.githubusercontent.com/google/gemini-react/main/README.md
|
||||
|
||||
Do you want to proceed?
|
||||
|
||||
● 1. Allow once
|
||||
@@ -29,7 +27,6 @@ Do you want to proceed?
|
||||
|
||||
exports[`ToolConfirmationMessage > should not display urls if prompt and url are the same 1`] = `
|
||||
"https://example.com
|
||||
|
||||
Do you want to proceed?
|
||||
|
||||
● 1. Allow once
|
||||
@@ -44,7 +41,6 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
|
||||
│ No changes detected. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
Apply this change?
|
||||
|
||||
● 1. Allow once
|
||||
@@ -59,7 +55,6 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
|
||||
│ No changes detected. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
Apply this change?
|
||||
|
||||
● 1. Allow once
|
||||
@@ -71,7 +66,6 @@ Apply this change?
|
||||
|
||||
exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations' > should NOT show "allow always" when folder is untrusted 1`] = `
|
||||
"echo "hello"
|
||||
|
||||
Allow execution of: 'echo'?
|
||||
|
||||
● 1. Allow once
|
||||
@@ -81,7 +75,6 @@ Allow execution of: 'echo'?
|
||||
|
||||
exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations' > should show "allow always" when folder is trusted 1`] = `
|
||||
"echo "hello"
|
||||
|
||||
Allow execution of: 'echo'?
|
||||
|
||||
● 1. Allow once
|
||||
@@ -92,7 +85,6 @@ Allow execution of: 'echo'?
|
||||
|
||||
exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations' > should NOT show "allow always" when folder is untrusted 1`] = `
|
||||
"https://example.com
|
||||
|
||||
Do you want to proceed?
|
||||
|
||||
● 1. Allow once
|
||||
@@ -102,7 +94,6 @@ Do you want to proceed?
|
||||
|
||||
exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations' > should show "allow always" when folder is trusted 1`] = `
|
||||
"https://example.com
|
||||
|
||||
Do you want to proceed?
|
||||
|
||||
● 1. Allow once
|
||||
@@ -114,7 +105,6 @@ Do you want to proceed?
|
||||
exports[`ToolConfirmationMessage > with folder trust > 'for mcp confirmations' > should NOT show "allow always" when folder is untrusted 1`] = `
|
||||
"MCP Server: test-server
|
||||
Tool: test-tool
|
||||
|
||||
Allow execution of MCP tool "test-tool" from server "test-server"?
|
||||
|
||||
● 1. Allow once
|
||||
@@ -125,7 +115,6 @@ Allow execution of MCP tool "test-tool" from server "test-server"?
|
||||
exports[`ToolConfirmationMessage > with folder trust > 'for mcp confirmations' > should show "allow always" when folder is trusted 1`] = `
|
||||
"MCP Server: test-server
|
||||
Tool: test-tool
|
||||
|
||||
Allow execution of MCP tool "test-tool" from server "test-server"?
|
||||
|
||||
● 1. Allow once
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ToolConfirmationMessage Overflow > should display "press ctrl-o" hint when content overflows in ToolGroupMessage 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ? test-tool a test tool ← │
|
||||
│ │
|
||||
│ ... first 49 lines hidden ... │
|
||||
│ 50 line 50 │
|
||||
│ Apply this change? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
│ 2. Allow for this session │
|
||||
│ 3. Modify with external editor │
|
||||
│ 4. No, suggest changes (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Press ctrl-o to show more lines"
|
||||
`;
|
||||
@@ -34,7 +34,6 @@ exports[`<ToolGroupMessage /> > Confirmation Handling > renders confirmation wit
|
||||
│ │
|
||||
│ Test result │
|
||||
│ Do you want to proceed? │
|
||||
│ │
|
||||
│ Do you want to proceed? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
@@ -50,7 +49,6 @@ exports[`<ToolGroupMessage /> > Confirmation Handling > renders confirmation wit
|
||||
│ │
|
||||
│ Test result │
|
||||
│ Do you want to proceed? │
|
||||
│ │
|
||||
│ Do you want to proceed? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
@@ -67,7 +65,6 @@ exports[`<ToolGroupMessage /> > Confirmation Handling > shows confirmation dialo
|
||||
│ │
|
||||
│ Test result │
|
||||
│ Confirm first tool │
|
||||
│ │
|
||||
│ Do you want to proceed? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
@@ -160,7 +157,6 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call awaiting co
|
||||
│ │
|
||||
│ Test result │
|
||||
│ Are you sure you want to proceed? │
|
||||
│ │
|
||||
│ Do you want to proceed? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user