mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-24 16:50:59 -07:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6efa6f61f | |||
| 6c1773170e | |||
| 00966062b8 | |||
| 4138667bae | |||
| bfa791e13d | |||
| e9a9474810 | |||
| 02adfe2bca | |||
| 2a08456ed0 | |||
| 2dac98dc8d | |||
| 77849cadac | |||
| 84ce53aafa | |||
| b19b026f30 | |||
| eb9223b6a4 | |||
| 65d26e73a2 | |||
| 0080589939 | |||
| 34a47a51f4 | |||
| f5dd1068f6 | |||
| c202cf3145 | |||
| 5baad108d9 | |||
| 63e9d5d15f | |||
| 3776c4d613 | |||
| 2af5a3a01e | |||
| 0d034b8c18 | |||
| 49d55d972e | |||
| 7a02d7261a | |||
| 9c11ff2d58 | |||
| b3ecac7086 | |||
| 6d3fff2ea4 | |||
| be2ebd1772 | |||
| cb4e1e684d | |||
| b7a3243334 | |||
| 8257ec447a | |||
| 9590a092ae | |||
| 49533cd106 | |||
| a2174751de | |||
| 8b762111a8 | |||
| c03d96b46c | |||
| ea1f19aa52 | |||
| 2eb1c92347 | |||
| ef02cec2cd | |||
| f9fc9335f5 | |||
| 9813531f81 | |||
| 55571de066 | |||
| 740f0e4c3d | |||
| b37e67451a | |||
| 262138cad5 | |||
| 5920750c24 | |||
| f5b1245f51 | |||
| f2ca0bb38d | |||
| 4e9bafdacd | |||
| 41bbe6ca0a | |||
| eb5492f9b5 | |||
| 37f128a109 |
@@ -25,7 +25,7 @@ You are an expert at fixing behavioral evaluations.
|
||||
the same scenario. We don't want to lose test fidelity by making the prompts too
|
||||
direct (i.e.: easy).
|
||||
- Your primary mechanism for improving the agent's behavior is to make changes to
|
||||
tool instructions, prompt.ts, and/or modules that contribute to the prompt.
|
||||
tool instructions, system prompt (snippets.ts), and/or modules that contribute to the prompt.
|
||||
- If prompt and description changes are unsuccessful, use logs and debugging to
|
||||
confirm that everything is working as expected.
|
||||
- If unable to fix the test, you can make recommendations for architecture changes
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
{
|
||||
"experimental": {
|
||||
"toolOutputMasking": {
|
||||
"enabled": true
|
||||
},
|
||||
"plan": true
|
||||
},
|
||||
"general": {
|
||||
|
||||
@@ -356,11 +356,17 @@ jobs:
|
||||
clean-script: 'clean'
|
||||
|
||||
test_windows:
|
||||
name: 'Slow Test - Win'
|
||||
name: 'Slow Test - Win - ${{ matrix.shard }}'
|
||||
runs-on: 'gemini-cli-windows-16-core'
|
||||
needs: 'merge_queue_skipper'
|
||||
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
|
||||
continue-on-error: true
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
matrix:
|
||||
shard:
|
||||
- 'cli'
|
||||
- 'others'
|
||||
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
@@ -411,7 +417,14 @@ jobs:
|
||||
NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256'
|
||||
UV_THREADPOOL_SIZE: '32'
|
||||
NODE_ENV: 'test'
|
||||
run: 'npm run test:ci -- --coverage.enabled=false'
|
||||
run: |
|
||||
if ("${{ matrix.shard }}" -eq "cli") {
|
||||
npm run test:ci --workspace @google/gemini-cli -- --coverage.enabled=false
|
||||
} else {
|
||||
# Explicitly list non-cli packages to ensure they are sharded correctly
|
||||
npm run test:ci --workspace @google/gemini-cli-core --workspace @google/gemini-cli-a2a-server --workspace gemini-cli-vscode-ide-companion --workspace @google/gemini-cli-test-utils --if-present -- --coverage.enabled=false
|
||||
npm run test:scripts
|
||||
}
|
||||
shell: 'pwsh'
|
||||
|
||||
- name: 'Bundle'
|
||||
|
||||
@@ -67,6 +67,9 @@ powerful tool for developers.
|
||||
and `packages/core` (Backend logic).
|
||||
- **Imports:** Use specific imports and avoid restricted relative imports
|
||||
between packages (enforced by ESLint).
|
||||
- **License Headers:** For all new source code files (`.ts`, `.tsx`, `.js`),
|
||||
include the Apache-2.0 license header with the current year. (e.g.,
|
||||
`Copyright 2026 Google LLC`). This is enforced by ESLint.
|
||||
|
||||
## Testing Conventions
|
||||
|
||||
|
||||
@@ -131,7 +131,8 @@ available combinations.
|
||||
- `!` on an empty prompt: Enter or exit shell mode.
|
||||
- `?` on an empty prompt: Toggle the shortcuts panel above the input. Press
|
||||
`Esc`, `Backspace`, or any printable key to close it. Press `?` again to close
|
||||
the panel and insert a `?` into the prompt.
|
||||
the panel and insert a `?` into the prompt. You can hide only the hint text
|
||||
via `ui.showShortcutsHint`, without changing this keyboard behavior.
|
||||
- `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line
|
||||
mode.
|
||||
- `Esc` pressed twice quickly: Clear the input prompt if it is not empty,
|
||||
|
||||
+87
-7
@@ -30,6 +30,8 @@ implementation strategy.
|
||||
- [The Planning Workflow](#the-planning-workflow)
|
||||
- [Exiting Plan Mode](#exiting-plan-mode)
|
||||
- [Tool Restrictions](#tool-restrictions)
|
||||
- [Customizing Planning with Skills](#customizing-planning-with-skills)
|
||||
- [Customizing Policies](#customizing-policies)
|
||||
|
||||
## Starting in Plan Mode
|
||||
|
||||
@@ -61,15 +63,18 @@ You can enter Plan Mode in three ways:
|
||||
1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes
|
||||
(`Default` -> `Plan` -> `Auto-Edit`).
|
||||
2. **Command:** Type `/plan` in the input box.
|
||||
3. **Natural Language:** Ask the agent to "start a plan for...".
|
||||
3. **Natural Language:** Ask the agent to "start a plan for...". The agent will
|
||||
then call the [`enter_plan_mode`] tool to switch modes.
|
||||
|
||||
### The Planning Workflow
|
||||
|
||||
1. **Requirements:** The agent clarifies goals using `ask_user`.
|
||||
1. **Requirements:** The agent clarifies goals using [`ask_user`].
|
||||
2. **Exploration:** The agent uses read-only tools (like [`read_file`]) to map
|
||||
the codebase and validate assumptions.
|
||||
3. **Planning:** A detailed plan is written to a temporary Markdown file.
|
||||
4. **Review:** You review the plan.
|
||||
3. **Design:** The agent proposes alternative approaches with a recommended
|
||||
solution for you to choose from.
|
||||
4. **Planning:** A detailed plan is written to a temporary Markdown file.
|
||||
5. **Review:** You review the plan.
|
||||
- **Approve:** Exit Plan Mode and start implementation (switching to
|
||||
Auto-Edit or Default approval mode).
|
||||
- **Iterate:** Provide feedback to refine the plan.
|
||||
@@ -79,8 +84,8 @@ You can enter Plan Mode in three ways:
|
||||
To exit Plan Mode:
|
||||
|
||||
1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle to the desired mode.
|
||||
1. **Tool:** The agent calls the `exit_plan_mode` tool to present the finalized
|
||||
plan for your approval.
|
||||
2. **Tool:** The agent calls the [`exit_plan_mode`] tool to present the
|
||||
finalized plan for your approval.
|
||||
|
||||
## Tool Restrictions
|
||||
|
||||
@@ -90,11 +95,80 @@ These are the only allowed tools:
|
||||
|
||||
- **FileSystem (Read):** [`read_file`], [`list_directory`], [`glob`]
|
||||
- **Search:** [`grep_search`], [`google_web_search`]
|
||||
- **Interaction:** `ask_user`
|
||||
- **Interaction:** [`ask_user`]
|
||||
- **MCP Tools (Read):** Read-only [MCP tools] (e.g., `github_read_issue`,
|
||||
`postgres_read_schema`) are allowed.
|
||||
- **Planning (Write):** [`write_file`] and [`replace`] ONLY allowed for `.md`
|
||||
files in the `~/.gemini/tmp/<project>/plans/` directory.
|
||||
- **Skills:** [`activate_skill`] (allows loading specialized instructions and
|
||||
resources in a read-only manner)
|
||||
|
||||
### Customizing Planning with Skills
|
||||
|
||||
You can leverage [Agent Skills](./skills.md) to customize how Gemini CLI
|
||||
approaches planning for specific types of tasks. When a skill is activated
|
||||
during Plan Mode, its specialized instructions and procedural workflows will
|
||||
guide the research and design phases.
|
||||
|
||||
For example:
|
||||
|
||||
- A **"Database Migration"** skill could ensure the plan includes data safety
|
||||
checks and rollback strategies.
|
||||
- A **"Security Audit"** skill could prompt the agent to look for specific
|
||||
vulnerabilities during codebase exploration.
|
||||
- A **"Frontend Design"** skill could guide the agent to use specific UI
|
||||
components and accessibility standards in its proposal.
|
||||
|
||||
To use a skill in Plan Mode, you can explicitly ask the agent to "use the
|
||||
[skill-name] skill to plan..." or the agent may autonomously activate it based
|
||||
on the task description.
|
||||
|
||||
### Customizing Policies
|
||||
|
||||
Plan Mode is designed to be read-only by default to ensure safety during the
|
||||
research phase. However, you may occasionally need to allow specific tools to
|
||||
assist in your planning.
|
||||
|
||||
Because user policies (Tier 2) have a higher base priority than built-in
|
||||
policies (Tier 1), you can override Plan Mode's default restrictions by creating
|
||||
a rule in your `~/.gemini/policies/` directory.
|
||||
|
||||
#### Example: Allow `git status` and `git diff` in Plan Mode
|
||||
|
||||
This rule allows you to check the repository status and see changes while in
|
||||
Plan Mode.
|
||||
|
||||
`~/.gemini/policies/git-research.toml`
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = ["git status", "git diff"]
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
```
|
||||
|
||||
#### Example: Enable research sub-agents in Plan Mode
|
||||
|
||||
You can enable [experimental research sub-agents] like `codebase_investigator`
|
||||
to help gather architecture details during the planning phase.
|
||||
|
||||
`~/.gemini/policies/research-subagents.toml`
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "codebase_investigator"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
```
|
||||
|
||||
Tell the agent it can use these tools in your prompt, for example: _"You can
|
||||
check ongoing changes in git."_
|
||||
|
||||
For more information on how the policy engine works, see the [Policy Engine
|
||||
Guide].
|
||||
|
||||
[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder
|
||||
[`read_file`]: /docs/tools/file-system.md#2-read_file-readfile
|
||||
@@ -104,3 +178,9 @@ These are the only allowed tools:
|
||||
[`google_web_search`]: /docs/tools/web-search.md
|
||||
[`replace`]: /docs/tools/file-system.md#6-replace-edit
|
||||
[MCP tools]: /docs/tools/mcp-server.md
|
||||
[`activate_skill`]: /docs/cli/skills.md
|
||||
[experimental research sub-agents]: /docs/core/subagents.md
|
||||
[Policy Engine Guide]: /docs/core/policy-engine.md
|
||||
[`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode
|
||||
[`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode
|
||||
[`ask_user`]: /docs/tools/ask-user.md
|
||||
|
||||
+12
-4
@@ -49,6 +49,7 @@ they appear in the UI.
|
||||
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
|
||||
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
||||
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
||||
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
|
||||
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
|
||||
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
|
||||
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` |
|
||||
@@ -116,12 +117,19 @@ they appear in the UI.
|
||||
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` |
|
||||
| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
|
||||
|
||||
### Advanced
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| --------------------------------- | ------------------------------ | --------------------------------------------- | ------- |
|
||||
| Auto Configure Max Old Space Size | `advanced.autoConfigureMemory` | Automatically configure Node.js memory limits | `false` |
|
||||
|
||||
### Experimental
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ---------------- | ---------------------------- | ----------------------------------------------------------------------------------- | ------- |
|
||||
| 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` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -119,9 +119,17 @@ For example:
|
||||
|
||||
Approval modes allow the policy engine to apply different sets of rules based on
|
||||
the CLI's operational mode. A rule can be associated with one or more modes
|
||||
(e.g., `yolo`, `autoEdit`). The rule will only be active if the CLI is running
|
||||
in one of its specified modes. If a rule has no modes specified, it is always
|
||||
active.
|
||||
(e.g., `yolo`, `autoEdit`, `plan`). The rule will only be active if the CLI is
|
||||
running in one of its specified modes. If a rule has no modes specified, it is
|
||||
always active.
|
||||
|
||||
- `default`: The standard interactive mode where most write tools require
|
||||
confirmation.
|
||||
- `autoEdit`: Optimized for automated code editing; some write tools may be
|
||||
auto-approved.
|
||||
- `plan`: A strict, read-only mode for research and design. See [Customizing
|
||||
Plan Mode Policies].
|
||||
- `yolo`: A mode where all tools are auto-approved (use with extreme caution).
|
||||
|
||||
## Rule matching
|
||||
|
||||
@@ -303,3 +311,5 @@ out-of-the-box experience.
|
||||
- In **`yolo`** mode, a high-priority rule allows all tools.
|
||||
- In **`autoEdit`** mode, rules allow certain write operations to happen without
|
||||
prompting.
|
||||
|
||||
[Customizing Plan Mode Policies]: /docs/cli/plan-mode.md#customizing-policies
|
||||
|
||||
@@ -179,9 +179,6 @@ precedence.
|
||||
|
||||
### Settings
|
||||
|
||||
_Note: This is an experimental feature. We do not yet recommend extension
|
||||
authors introduce settings as part of their core flows._
|
||||
|
||||
Extensions can define settings that the user will be prompted to provide upon
|
||||
installation. This is useful for things like API keys, URLs, or other
|
||||
configuration that the extension needs to function.
|
||||
|
||||
@@ -220,6 +220,10 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Hide helpful tips in the UI
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.showShortcutsHint`** (boolean):
|
||||
- **Description:** Show the "? for shortcuts" hint above the input.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`ui.hideBanner`** (boolean):
|
||||
- **Description:** Hide the application banner
|
||||
- **Default:** `false`
|
||||
@@ -443,6 +447,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"model": "gemini-2.5-flash"
|
||||
}
|
||||
},
|
||||
"gemini-3-flash-base": {
|
||||
"extends": "base",
|
||||
"modelConfig": {
|
||||
"model": "gemini-3-flash-preview"
|
||||
}
|
||||
},
|
||||
"classifier": {
|
||||
"extends": "base",
|
||||
"modelConfig": {
|
||||
@@ -498,7 +508,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
}
|
||||
},
|
||||
"web-search": {
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"extends": "gemini-3-flash-base",
|
||||
"modelConfig": {
|
||||
"generateContentConfig": {
|
||||
"tools": [
|
||||
@@ -510,7 +520,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
}
|
||||
},
|
||||
"web-fetch": {
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"extends": "gemini-3-flash-base",
|
||||
"modelConfig": {
|
||||
"generateContentConfig": {
|
||||
"tools": [
|
||||
@@ -522,25 +532,25 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
}
|
||||
},
|
||||
"web-fetch-fallback": {
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"extends": "gemini-3-flash-base",
|
||||
"modelConfig": {}
|
||||
},
|
||||
"loop-detection": {
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"extends": "gemini-3-flash-base",
|
||||
"modelConfig": {}
|
||||
},
|
||||
"loop-detection-double-check": {
|
||||
"extends": "base",
|
||||
"modelConfig": {
|
||||
"model": "gemini-2.5-pro"
|
||||
"model": "gemini-3-pro-preview"
|
||||
}
|
||||
},
|
||||
"llm-edit-fixer": {
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"extends": "gemini-3-flash-base",
|
||||
"modelConfig": {}
|
||||
},
|
||||
"next-speaker-checker": {
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"extends": "gemini-3-flash-base",
|
||||
"modelConfig": {}
|
||||
},
|
||||
"chat-compression-3-pro": {
|
||||
@@ -570,7 +580,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"chat-compression-default": {
|
||||
"modelConfig": {
|
||||
"model": "gemini-2.5-pro"
|
||||
"model": "gemini-3-pro-preview"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -848,6 +858,28 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
#### `experimental`
|
||||
|
||||
- **`experimental.toolOutputMasking.enabled`** (boolean):
|
||||
- **Description:** Enables tool output masking to save tokens.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.toolOutputMasking.toolProtectionThreshold`** (number):
|
||||
- **Description:** Minimum number of tokens to protect from masking (most
|
||||
recent tool outputs).
|
||||
- **Default:** `50000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.toolOutputMasking.minPrunableTokensThreshold`** (number):
|
||||
- **Description:** Minimum prunable tokens required to trigger a masking pass.
|
||||
- **Default:** `30000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.toolOutputMasking.protectLatestTurn`** (boolean):
|
||||
- **Description:** Ensures the absolute latest turn is never masked,
|
||||
regardless of token count.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.enableAgents`** (boolean):
|
||||
- **Description:** Enable local and remote subagents. Warning: Experimental
|
||||
feature, uses YOLO mode for subagents
|
||||
@@ -864,6 +896,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.extensionRegistry`** (boolean):
|
||||
- **Description:** Enable extension registry explore UI.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.extensionReloading`** (boolean):
|
||||
- **Description:** Enables extension loading/unloading within the CLI session.
|
||||
- **Default:** `false`
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Ask User Tool
|
||||
|
||||
The `ask_user` tool allows the agent to ask you one or more questions to gather
|
||||
preferences, clarify requirements, or make decisions. It supports multiple
|
||||
question types including multiple-choice, free-form text, and Yes/No
|
||||
confirmation.
|
||||
|
||||
## `ask_user` (Ask User)
|
||||
|
||||
- **Tool name:** `ask_user`
|
||||
- **Display name:** Ask User
|
||||
- **File:** `ask-user.ts`
|
||||
- **Parameters:**
|
||||
- `questions` (array of objects, required): A list of 1 to 4 questions to ask.
|
||||
Each question object has the following properties:
|
||||
- `question` (string, required): The complete question text.
|
||||
- `header` (string, required): A short label (max 16 chars) displayed as a
|
||||
chip/tag (e.g., "Auth", "Database").
|
||||
- `type` (string, optional): The type of question. Defaults to `'choice'`.
|
||||
- `'choice'`: Multiple-choice with options (supports multi-select).
|
||||
- `'text'`: Free-form text input.
|
||||
- `'yesno'`: Yes/No confirmation.
|
||||
- `options` (array of objects, optional): Required for `'choice'` type. 2-4
|
||||
selectable options.
|
||||
- `label` (string, required): Display text (1-5 words).
|
||||
- `description` (string, required): Brief explanation.
|
||||
- `multiSelect` (boolean, optional): For `'choice'` type, allows selecting
|
||||
multiple options.
|
||||
- `placeholder` (string, optional): Hint text for input fields.
|
||||
|
||||
- **Behavior:**
|
||||
- Presents an interactive dialog to the user with the specified questions.
|
||||
- Pauses execution until the user provides answers or dismisses the dialog.
|
||||
- Returns the user's answers to the model.
|
||||
|
||||
- **Output (`llmContent`):** A JSON string containing the user's answers,
|
||||
indexed by question position (e.g.,
|
||||
`{"answers":{"0": "Option A", "1": "Some text"}}`).
|
||||
|
||||
- **Confirmation:** Yes. The tool inherently involves user interaction.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Multiple Choice Question
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"header": "Database",
|
||||
"question": "Which database would you like to use?",
|
||||
"type": "choice",
|
||||
"options": [
|
||||
{
|
||||
"label": "PostgreSQL",
|
||||
"description": "Powerful, open source object-relational database system."
|
||||
},
|
||||
{
|
||||
"label": "SQLite",
|
||||
"description": "C-library that implements a SQL database engine."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Text Input Question
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"header": "Project Name",
|
||||
"question": "What is the name of your new project?",
|
||||
"type": "text",
|
||||
"placeholder": "e.g., my-awesome-app"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Yes/No Question
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"header": "Deploy",
|
||||
"question": "Do you want to deploy the application now?",
|
||||
"type": "yesno"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -86,6 +86,9 @@ Gemini CLI's built-in tools can be broadly categorized as follows:
|
||||
information across sessions.
|
||||
- **[Todo Tool](./todos.md) (`write_todos`):** For managing subtasks of complex
|
||||
requests.
|
||||
- **[Planning Tools](./planning.md):** For entering and exiting Plan Mode.
|
||||
- **[Ask User Tool](./ask-user.md) (`ask_user`):** For gathering user input and
|
||||
making decisions.
|
||||
|
||||
Additionally, these tools incorporate:
|
||||
|
||||
|
||||
@@ -739,21 +739,10 @@ The MCP integration tracks several states:
|
||||
cautiously and only for servers you completely control
|
||||
- **Access tokens:** Be security-aware when configuring environment variables
|
||||
containing API keys or tokens
|
||||
- **Environment variable redaction:** By default, the Gemini CLI redacts
|
||||
sensitive environment variables (such as `GEMINI_API_KEY`, `GOOGLE_API_KEY`,
|
||||
and variables matching patterns like `*TOKEN*`, `*SECRET*`, `*PASSWORD*`) when
|
||||
spawning MCP servers using the `stdio` transport. This prevents unintended
|
||||
exposure of your credentials to third-party servers.
|
||||
- **Explicit environment variables:** If you need to pass a specific environment
|
||||
variable to an MCP server, you should define it explicitly in the `env`
|
||||
property of the server configuration in `settings.json`.
|
||||
- **Sandbox compatibility:** When using sandboxing, ensure MCP servers are
|
||||
available within the sandbox environment.
|
||||
available within the sandbox environment
|
||||
- **Private data:** Using broadly scoped personal access tokens can lead to
|
||||
information leakage between repositories.
|
||||
- **Untrusted servers:** Be extremely cautious when adding MCP servers from
|
||||
untrusted or third-party sources. Malicious servers could attempt to
|
||||
exfiltrate data or perform unauthorized actions through the tools they expose.
|
||||
|
||||
### Performance and resource management
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Gemini CLI planning tools
|
||||
|
||||
Planning tools allow the Gemini model to switch into a safe, read-only "Plan
|
||||
Mode" for researching and planning complex changes, and to signal the
|
||||
finalization of a plan to the user.
|
||||
|
||||
## 1. `enter_plan_mode` (EnterPlanMode)
|
||||
|
||||
`enter_plan_mode` switches the CLI to Plan Mode. This tool is typically called
|
||||
by the agent when you ask it to "start a plan" using natural language. In this
|
||||
mode, the agent is restricted to read-only tools to allow for safe exploration
|
||||
and planning.
|
||||
|
||||
- **Tool name:** `enter_plan_mode`
|
||||
- **Display name:** Enter Plan Mode
|
||||
- **File:** `enter-plan-mode.ts`
|
||||
- **Parameters:**
|
||||
- `reason` (string, optional): A short reason explaining why the agent is
|
||||
entering plan mode (e.g., "Starting a complex feature implementation").
|
||||
- **Behavior:**
|
||||
- Switches the CLI's approval mode to `PLAN`.
|
||||
- Notifies the user that the agent has entered Plan Mode.
|
||||
- **Output (`llmContent`):** A message indicating the switch, e.g.,
|
||||
`Switching to Plan mode.`
|
||||
- **Confirmation:** Yes. The user is prompted to confirm entering Plan Mode.
|
||||
|
||||
## 2. `exit_plan_mode` (ExitPlanMode)
|
||||
|
||||
`exit_plan_mode` signals that the planning phase is complete. It presents the
|
||||
finalized plan to the user and requests approval to start the implementation.
|
||||
|
||||
- **Tool name:** `exit_plan_mode`
|
||||
- **Display name:** Exit Plan Mode
|
||||
- **File:** `exit-plan-mode.ts`
|
||||
- **Parameters:**
|
||||
- `plan_path` (string, required): The path to the finalized Markdown plan
|
||||
file. This file MUST be located within the project's temporary plans
|
||||
directory (e.g., `~/.gemini/tmp/<project>/plans/`).
|
||||
- **Behavior:**
|
||||
- Validates that the `plan_path` is within the allowed directory and that the
|
||||
file exists and has content.
|
||||
- Presents the plan to the user for review.
|
||||
- If the user approves the plan:
|
||||
- Switches the CLI's approval mode to the user's chosen approval mode (
|
||||
`DEFAULT` or `AUTO_EDIT`).
|
||||
- Marks the plan as approved for implementation.
|
||||
- If the user rejects the plan:
|
||||
- Stays in Plan Mode.
|
||||
- Returns user feedback to the model to refine the plan.
|
||||
- **Output (`llmContent`):**
|
||||
- On approval: A message indicating the plan was approved and the new approval
|
||||
mode.
|
||||
- On rejection: A message containing the user's feedback.
|
||||
- **Confirmation:** Yes. Shows the finalized plan and asks for user approval to
|
||||
proceed with implementation.
|
||||
+3
-2
@@ -23,6 +23,7 @@ const __dirname = path.dirname(__filename);
|
||||
|
||||
// Determine the monorepo root (assuming eslint.config.js is at the root)
|
||||
const projectRoot = __dirname;
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
@@ -267,8 +268,8 @@ export default tseslint.config(
|
||||
].join('\n'),
|
||||
patterns: {
|
||||
year: {
|
||||
pattern: '202[5-6]',
|
||||
defaultValue: '2026',
|
||||
pattern: `202[5-${currentYear.toString().slice(-1)}]`,
|
||||
defaultValue: currentYear.toString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -114,6 +114,27 @@ npm run test:all_evals
|
||||
This command sets the `RUN_EVALS` environment variable to `1`, which enables the
|
||||
`USUALLY_PASSES` tests.
|
||||
|
||||
### All Evals (All Models)
|
||||
|
||||
To run the full evaluation suite across all supported models and generate a
|
||||
local markdown report (mirroring the nightly CI workflow):
|
||||
|
||||
```bash
|
||||
npm run test:all_evals_all_models
|
||||
```
|
||||
|
||||
This script will:
|
||||
1. Build the project.
|
||||
2. Run `test:all_evals` for each model in the nightly rotation.
|
||||
3. Collect logs and aggregate them using `scripts/aggregate_evals.js`.
|
||||
4. Generate a `local_evals_summary.md` file with the results.
|
||||
|
||||
You can also filter by test name and specify the number of attempts:
|
||||
|
||||
```bash
|
||||
npm run test:all_evals_all_models -- "my-test-pattern" --attempts 3
|
||||
```
|
||||
|
||||
## Reporting
|
||||
|
||||
Results for evaluations are available on GitHub Actions:
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('Edits location eval', () => {
|
||||
/**
|
||||
* Ensure that Gemini CLI always updates existing test files, if present,
|
||||
* instead of creating a new one.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should update existing test file instead of creating a new one',
|
||||
files: {
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
name: 'test-location-repro',
|
||||
version: '1.0.0',
|
||||
scripts: {
|
||||
test: 'vitest run',
|
||||
},
|
||||
devDependencies: {
|
||||
vitest: '^1.0.0',
|
||||
typescript: '^5.0.0',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'src/math.ts': `
|
||||
export function add(a: number, b: number): number {
|
||||
return a + b;
|
||||
}
|
||||
|
||||
export function subtract(a: number, b: number): number {
|
||||
return a - b;
|
||||
}
|
||||
|
||||
export function multiply(a: number, b: number): number {
|
||||
return a + b;
|
||||
}
|
||||
`,
|
||||
'src/math.test.ts': `
|
||||
import { expect, test } from 'vitest';
|
||||
import { add, subtract } from './math';
|
||||
|
||||
test('add adds two numbers', () => {
|
||||
expect(add(2, 3)).toBe(5);
|
||||
});
|
||||
|
||||
test('subtract subtracts two numbers', () => {
|
||||
expect(subtract(5, 3)).toBe(2);
|
||||
});
|
||||
`,
|
||||
'src/utils.ts': `
|
||||
export function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
`,
|
||||
'src/utils.test.ts': `
|
||||
import { expect, test } from 'vitest';
|
||||
import { capitalize } from './utils';
|
||||
|
||||
test('capitalize capitalizes the first letter', () => {
|
||||
expect(capitalize('hello')).toBe('Hello');
|
||||
});
|
||||
`,
|
||||
},
|
||||
prompt: 'Fix the bug in src/math.ts. Do not run the code.',
|
||||
timeout: 180000,
|
||||
assert: async (rig) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const replaceCalls = toolLogs.filter(
|
||||
(t) => t.toolRequest.name === 'replace',
|
||||
);
|
||||
const writeFileCalls = toolLogs.filter(
|
||||
(t) => t.toolRequest.name === 'write_file',
|
||||
);
|
||||
|
||||
expect(replaceCalls.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
writeFileCalls.some((file) =>
|
||||
file.toolRequest.args.includes('.test.ts'),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
const targetFiles = replaceCalls.map((t) => {
|
||||
try {
|
||||
return JSON.parse(t.toolRequest.args).file_path;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('DEBUG: targetFiles', targetFiles);
|
||||
|
||||
expect(
|
||||
new Set(targetFiles).size,
|
||||
'Expected only two files changed',
|
||||
).greaterThanOrEqual(2);
|
||||
expect(targetFiles.some((f) => f?.endsWith('src/math.ts'))).toBe(true);
|
||||
expect(targetFiles.some((f) => f?.endsWith('src/math.test.ts'))).toBe(
|
||||
true,
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
/**
|
||||
* Evals to verify that the agent uses search tools efficiently (frugally)
|
||||
* by utilizing limiting parameters like `total_max_matches` and `max_matches_per_file`.
|
||||
* This ensures the agent doesn't flood the context window with unnecessary search results.
|
||||
*/
|
||||
describe('Frugal Search', () => {
|
||||
const getGrepParams = (call: any): any => {
|
||||
let args = call.toolRequest.args;
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
args = JSON.parse(args);
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
return args;
|
||||
};
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should use targeted search with limit',
|
||||
prompt: 'find me a sample usage of path.resolve() in the codebase',
|
||||
files: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'test-project',
|
||||
version: '1.0.0',
|
||||
main: 'dist/index.js',
|
||||
scripts: {
|
||||
build: 'tsc',
|
||||
test: 'vitest',
|
||||
},
|
||||
dependencies: {
|
||||
typescript: '^5.0.0',
|
||||
'@types/node': '^20.0.0',
|
||||
vitest: '^1.0.0',
|
||||
},
|
||||
}),
|
||||
'src/index.ts': `
|
||||
import { App } from './app.ts';
|
||||
|
||||
const app = new App();
|
||||
app.start();
|
||||
`,
|
||||
'src/app.ts': `
|
||||
import * as path from 'path';
|
||||
import { UserController } from './controllers/user.ts';
|
||||
|
||||
export class App {
|
||||
constructor() {
|
||||
console.log('App initialized');
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
const userController = new UserController();
|
||||
console.log('Static path:', path.resolve(__dirname, '../public'));
|
||||
}
|
||||
}
|
||||
`,
|
||||
'src/utils.ts': `
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export function resolvePath(p: string): string {
|
||||
return path.resolve(process.cwd(), p);
|
||||
}
|
||||
|
||||
export function ensureDir(dirPath: string): void {
|
||||
const absolutePath = path.resolve(dirPath);
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
fs.mkdirSync(absolutePath, { recursive: true });
|
||||
}
|
||||
}
|
||||
`,
|
||||
'src/config.ts': `
|
||||
import * as path from 'path';
|
||||
|
||||
export const config = {
|
||||
dbPath: path.resolve(process.cwd(), 'data/db.sqlite'),
|
||||
logLevel: 'info',
|
||||
};
|
||||
`,
|
||||
'src/controllers/user.ts': `
|
||||
import * as path from 'path';
|
||||
|
||||
export class UserController {
|
||||
public getUsers(): any[] {
|
||||
console.log('Loading users from:', path.resolve('data/users.json'));
|
||||
return [{ id: 1, name: 'Alice' }];
|
||||
}
|
||||
}
|
||||
`,
|
||||
'tests/app.test.ts': `
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('App', () => {
|
||||
it('should resolve paths', () => {
|
||||
const p = path.resolve('test');
|
||||
expect(p).toBeDefined();
|
||||
});
|
||||
});
|
||||
`,
|
||||
},
|
||||
assert: async (rig) => {
|
||||
const toolCalls = rig.readToolLogs();
|
||||
const grepCalls = toolCalls.filter(
|
||||
(call) => call.toolRequest.name === 'grep_search',
|
||||
);
|
||||
|
||||
expect(grepCalls.length).toBeGreaterThan(0);
|
||||
|
||||
const grepParams = grepCalls.map(getGrepParams);
|
||||
|
||||
const hasTotalMaxLimit = grepParams.some(
|
||||
(p) => p.total_max_matches !== undefined && p.total_max_matches <= 100,
|
||||
);
|
||||
expect(
|
||||
hasTotalMaxLimit,
|
||||
`Expected agent to use a small total_max_matches (<= 100) for a sample usage request. Actual values: ${JSON.stringify(
|
||||
grepParams.map((p) => p.total_max_matches),
|
||||
)}`,
|
||||
).toBe(true);
|
||||
|
||||
const hasMaxMatchesPerFileLimit = grepParams.some(
|
||||
(p) =>
|
||||
p.max_matches_per_file !== undefined && p.max_matches_per_file <= 5,
|
||||
);
|
||||
expect(
|
||||
hasMaxMatchesPerFileLimit,
|
||||
`Expected agent to use a small max_matches_per_file (<= 5) for a sample usage request. Actual values: ${JSON.stringify(
|
||||
grepParams.map((p) => p.max_matches_per_file),
|
||||
)}`,
|
||||
).toBe(true);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -42,11 +42,12 @@ When asked for my favorite fruit, always say "Cherry".
|
||||
</project_context>
|
||||
|
||||
What is my favorite fruit? Tell me just the name of the fruit.`,
|
||||
assert: async (_rig, result) => {
|
||||
assertModelHasOutput(result);
|
||||
expect(result).toMatch(/Cherry/i);
|
||||
expect(result).not.toMatch(/Apple/i);
|
||||
expect(result).not.toMatch(/Banana/i);
|
||||
assert: async (rig) => {
|
||||
const stdout = rig._lastRunStdout!;
|
||||
assertModelHasOutput(stdout);
|
||||
expect(stdout).toMatch(/Cherry/i);
|
||||
expect(stdout).not.toMatch(/Apple/i);
|
||||
expect(stdout).not.toMatch(/Banana/i);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -80,11 +81,12 @@ Provide the answer as an XML block like this:
|
||||
<extension>Instruction ...</extension>
|
||||
<project>Instruction ...</project>
|
||||
</results>`,
|
||||
assert: async (_rig, result) => {
|
||||
assertModelHasOutput(result);
|
||||
expect(result).toMatch(/<global>.*Instruction A/i);
|
||||
expect(result).toMatch(/<extension>.*Instruction B/i);
|
||||
expect(result).toMatch(/<project>.*Instruction C/i);
|
||||
assert: async (rig) => {
|
||||
const stdout = rig._lastRunStdout!;
|
||||
assertModelHasOutput(stdout);
|
||||
expect(stdout).toMatch(/<global>.*Instruction A/i);
|
||||
expect(stdout).toMatch(/<extension>.*Instruction B/i);
|
||||
expect(stdout).toMatch(/<project>.*Instruction C/i);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -107,11 +109,12 @@ Set the theme to "Light".
|
||||
Set the theme to "Dark".
|
||||
</extension_context>
|
||||
|
||||
What theme should I use?`,
|
||||
assert: async (_rig, result) => {
|
||||
assertModelHasOutput(result);
|
||||
expect(result).toMatch(/Dark/i);
|
||||
expect(result).not.toMatch(/Light/i);
|
||||
What theme should I use? Tell me just the name of the theme.`,
|
||||
assert: async (rig) => {
|
||||
const stdout = rig._lastRunStdout!;
|
||||
assertModelHasOutput(stdout);
|
||||
expect(stdout).toMatch(/Dark/i);
|
||||
expect(stdout).not.toMatch(/Light/i);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
+11
-11
@@ -14,7 +14,7 @@ import {
|
||||
describe('save_memory', () => {
|
||||
const TEST_PREFIX = 'Save memory test: ';
|
||||
const rememberingFavoriteColor = "Agent remembers user's favorite color";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingFavoriteColor,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -36,7 +36,7 @@ describe('save_memory', () => {
|
||||
},
|
||||
});
|
||||
const rememberingCommandRestrictions = 'Agent remembers command restrictions';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingCommandRestrictions,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -57,7 +57,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const rememberingWorkflow = 'Agent remembers workflow preferences';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingWorkflow,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -79,7 +79,7 @@ describe('save_memory', () => {
|
||||
|
||||
const ignoringTemporaryInformation =
|
||||
'Agent ignores temporary conversation details';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: ignoringTemporaryInformation,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -104,7 +104,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const rememberingPetName = "Agent remembers user's pet's name";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingPetName,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -125,7 +125,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const rememberingCommandAlias = 'Agent remembers custom command aliases';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingCommandAlias,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -147,7 +147,7 @@ describe('save_memory', () => {
|
||||
|
||||
const ignoringDbSchemaLocation =
|
||||
"Agent ignores workspace's database schema location";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: ignoringDbSchemaLocation,
|
||||
params: {
|
||||
settings: {
|
||||
@@ -178,7 +178,7 @@ describe('save_memory', () => {
|
||||
|
||||
const rememberingCodingStyle =
|
||||
"Agent remembers user's coding style preference";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingCodingStyle,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -200,7 +200,7 @@ describe('save_memory', () => {
|
||||
|
||||
const ignoringBuildArtifactLocation =
|
||||
'Agent ignores workspace build artifact location';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: ignoringBuildArtifactLocation,
|
||||
params: {
|
||||
settings: {
|
||||
@@ -230,7 +230,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const ignoringMainEntryPoint = "Agent ignores workspace's main entry point";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: ignoringMainEntryPoint,
|
||||
params: {
|
||||
settings: {
|
||||
@@ -260,7 +260,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const rememberingBirthday = "Agent remembers user's birthday";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingBirthday,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
|
||||
@@ -11,6 +11,7 @@ import * as os from 'node:os';
|
||||
import { RipGrepTool } from '../packages/core/src/tools/ripGrep.js';
|
||||
import { Config } from '../packages/core/src/config/config.js';
|
||||
import { WorkspaceContext } from '../packages/core/src/utils/workspaceContext.js';
|
||||
import { createMockMessageBus } from '../packages/core/src/test-utils/mock-message-bus.js';
|
||||
|
||||
// Mock Config to provide necessary context
|
||||
class MockConfig {
|
||||
@@ -66,7 +67,7 @@ describe('ripgrep-real-direct', () => {
|
||||
await fs.writeFile(path.join(tempDir, 'file3.txt'), 'goodbye moon\n');
|
||||
|
||||
const config = new MockConfig(tempDir) as unknown as Config;
|
||||
tool = new RipGrepTool(config);
|
||||
tool = new RipGrepTool(config, createMockMessageBus());
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -108,4 +109,24 @@ describe('ripgrep-real-direct', () => {
|
||||
expect(result.llmContent).toContain('script.js');
|
||||
expect(result.llmContent).not.toContain('file1.txt');
|
||||
});
|
||||
|
||||
it('should support context parameters', async () => {
|
||||
// Create a file with multiple lines
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, 'context.txt'),
|
||||
'line1\nline2\nline3 match\nline4\nline5\n',
|
||||
);
|
||||
|
||||
const invocation = tool.build({
|
||||
pattern: 'match',
|
||||
context: 1,
|
||||
});
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 1 match');
|
||||
expect(result.llmContent).toContain('context.txt');
|
||||
expect(result.llmContent).toContain('L2- line2');
|
||||
expect(result.llmContent).toContain('L3: line3 match');
|
||||
expect(result.llmContent).toContain('L4- line4');
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+308
-1124
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"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.29.0-nightly.20260203.71f46f116"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.30.0-nightly.20260210.a2174751d"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
@@ -43,6 +43,7 @@
|
||||
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",
|
||||
"test:always_passing_evals": "vitest run --config evals/vitest.config.ts",
|
||||
"test:all_evals": "cross-env RUN_EVALS=1 vitest run --config evals/vitest.config.ts",
|
||||
"test:all_evals_all_models": "node scripts/run_local_evals.js",
|
||||
"test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none",
|
||||
"test:integration:all": "npm run test:integration:sandbox:none && npm run test:integration:sandbox:docker && npm run test:integration:sandbox:podman",
|
||||
"test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests",
|
||||
@@ -64,7 +65,7 @@
|
||||
"pre-commit": "node scripts/pre-commit.js"
|
||||
},
|
||||
"overrides": {
|
||||
"ink": "npm:@jrichman/ink@6.4.8",
|
||||
"ink": "npm:@jrichman/ink@6.4.10",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"cliui": {
|
||||
"wrap-ansi": "7.0.0"
|
||||
@@ -126,7 +127,7 @@
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.8",
|
||||
"ink": "npm:@jrichman/ink@6.4.10",
|
||||
"latest-version": "^9.0.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"simple-git": "^3.28.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.29.0-nightly.20260203.71f46f116",
|
||||
"version": "0.30.0-nightly.20260210.a2174751d",
|
||||
"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.29.0-nightly.20260203.71f46f116"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.30.0-nightly.20260210.a2174751d"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.12.0",
|
||||
@@ -34,10 +34,11 @@
|
||||
"@google/genai": "1.30.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
"@types/update-notifier": "^6.0.8",
|
||||
"ansi-escapes": "^7.3.0",
|
||||
"ansi-regex": "^6.2.2",
|
||||
"chalk": "^4.1.2",
|
||||
"cli-spinners": "^2.9.2",
|
||||
"clipboardy": "^5.0.0",
|
||||
"color-convert": "^2.0.1",
|
||||
"command-exists": "^1.2.9",
|
||||
"comment-json": "^4.2.5",
|
||||
"diff": "^8.0.3",
|
||||
@@ -46,7 +47,7 @@
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.4.8",
|
||||
"ink": "npm:@jrichman/ink@6.4.10",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
@@ -56,7 +57,6 @@
|
||||
"prompts": "^2.4.2",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"react": "^19.2.0",
|
||||
"read-package-up": "^11.0.0",
|
||||
"shell-quote": "^1.8.3",
|
||||
"simple-git": "^3.28.0",
|
||||
"string-width": "^8.1.0",
|
||||
@@ -65,29 +65,21 @@
|
||||
"tar": "^7.5.2",
|
||||
"tinygradient": "^1.1.5",
|
||||
"undici": "^7.10.0",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"ws": "^8.16.0",
|
||||
"yargs": "^17.7.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/runtime": "^7.27.6",
|
||||
"@google/gemini-cli-test-utils": "file:../test-utils",
|
||||
"@types/archiver": "^6.0.3",
|
||||
"@types/command-exists": "^1.2.3",
|
||||
"@types/dotenv": "^6.1.1",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/node": "^20.11.24",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/tar": "^6.1.13",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"archiver": "^7.0.1",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"pretty-format": "^30.0.2",
|
||||
"react-dom": "^19.2.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
|
||||
@@ -128,13 +128,6 @@ async function addMcpServer(
|
||||
|
||||
settings.setValue(settingsScope, 'mcpServers', mcpServers);
|
||||
|
||||
if (transport === 'stdio') {
|
||||
debugLogger.warn(
|
||||
'Security Warning: Running MCP servers with stdio transport can expose inherited environment variables. ' +
|
||||
'While the Gemini CLI redacts common API keys and secrets by default, you should only run servers from trusted sources.',
|
||||
);
|
||||
}
|
||||
|
||||
if (isExistingServer) {
|
||||
debugLogger.log(`MCP server "${name}" updated in ${scope} settings.`);
|
||||
} else {
|
||||
|
||||
@@ -1867,10 +1867,11 @@ describe('loadCliConfig with includeDirectories', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should combine and resolve paths from settings and CLI arguments', async () => {
|
||||
it.skip('should combine and resolve paths from settings and CLI arguments', async () => {
|
||||
const mockCwd = path.resolve(path.sep, 'home', 'user', 'project');
|
||||
process.argv = [
|
||||
'node',
|
||||
|
||||
'script.js',
|
||||
'--include-directories',
|
||||
`${path.resolve(path.sep, 'cli', 'path1')},${path.join(mockCwd, 'cli', 'path2')}`,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
vi,
|
||||
afterEach,
|
||||
} from 'vitest';
|
||||
|
||||
import { createExtension } from '../test-utils/createExtension.js';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
|
||||
|
||||
@@ -67,6 +67,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
loadAgentsFromDirectory: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ agents: [], errors: [] }),
|
||||
logExtensionInstallEvent: vi.fn().mockResolvedValue(undefined),
|
||||
logExtensionUpdateEvent: vi.fn().mockResolvedValue(undefined),
|
||||
logExtensionUninstall: vi.fn().mockResolvedValue(undefined),
|
||||
logExtensionEnable: vi.fn().mockResolvedValue(undefined),
|
||||
logExtensionDisable: vi.fn().mockResolvedValue(undefined),
|
||||
Config: vi.fn().mockImplementation(() => ({
|
||||
getEnableExtensionReloading: vi.fn().mockReturnValue(true),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -186,6 +186,9 @@ describe('SettingsSchema', () => {
|
||||
expect(getSettingsSchema().ui.properties.hideTips.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.showShortcutsHint.showInDialog,
|
||||
).toBe(true);
|
||||
expect(getSettingsSchema().ui.properties.hideBanner.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
@@ -224,7 +227,7 @@ describe('SettingsSchema', () => {
|
||||
expect(
|
||||
getSettingsSchema().advanced.properties.autoConfigureMemory
|
||||
.showInDialog,
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should infer Settings type correctly', () => {
|
||||
@@ -328,6 +331,28 @@ describe('SettingsSchema', () => {
|
||||
).toBe('Enable debug logging of keystrokes to the console.');
|
||||
});
|
||||
|
||||
it('should have showShortcutsHint setting in schema', () => {
|
||||
expect(getSettingsSchema().ui.properties.showShortcutsHint).toBeDefined();
|
||||
expect(getSettingsSchema().ui.properties.showShortcutsHint.type).toBe(
|
||||
'boolean',
|
||||
);
|
||||
expect(getSettingsSchema().ui.properties.showShortcutsHint.category).toBe(
|
||||
'UI',
|
||||
);
|
||||
expect(getSettingsSchema().ui.properties.showShortcutsHint.default).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.showShortcutsHint.requiresRestart,
|
||||
).toBe(false);
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.showShortcutsHint.showInDialog,
|
||||
).toBe(true);
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.showShortcutsHint.description,
|
||||
).toBe('Show the "? for shortcuts" hint above the input.');
|
||||
});
|
||||
|
||||
it('should have enableAgents setting in schema', () => {
|
||||
const setting = getSettingsSchema().experimental.properties.enableAgents;
|
||||
expect(setting).toBeDefined();
|
||||
|
||||
@@ -462,6 +462,15 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Hide helpful tips in the UI',
|
||||
showInDialog: true,
|
||||
},
|
||||
showShortcutsHint: {
|
||||
type: 'boolean',
|
||||
label: 'Show Shortcuts Hint',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description: 'Show the "? for shortcuts" hint above the input.',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideBanner: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Banner',
|
||||
@@ -1413,7 +1422,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Automatically configure Node.js memory limits',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
dnsResolutionOrder: {
|
||||
type: 'string',
|
||||
@@ -1462,7 +1471,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Tool Output Masking',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
ignoreInDocs: true,
|
||||
ignoreInDocs: false,
|
||||
default: {},
|
||||
description:
|
||||
'Advanced settings for tool output masking to manage context window efficiency.',
|
||||
@@ -1473,9 +1482,9 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Enable Tool Output Masking',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
default: true,
|
||||
description: 'Enables tool output masking to save tokens.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
toolProtectionThreshold: {
|
||||
type: 'number',
|
||||
@@ -1537,6 +1546,15 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Enable requesting and fetching of extension settings.',
|
||||
showInDialog: false,
|
||||
},
|
||||
extensionRegistry: {
|
||||
type: 'boolean',
|
||||
label: 'Extension Registry Explore UI',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable extension registry explore UI.',
|
||||
showInDialog: false,
|
||||
},
|
||||
extensionReloading: {
|
||||
type: 'boolean',
|
||||
label: 'Extension Reloading',
|
||||
|
||||
@@ -238,18 +238,15 @@ vi.mock('./validateNonInterActiveAuth.js', () => ({
|
||||
}));
|
||||
|
||||
describe('gemini.tsx main function', () => {
|
||||
let originalEnvGeminiSandbox: string | undefined;
|
||||
let originalEnvSandbox: string | undefined;
|
||||
let originalIsTTY: boolean | undefined;
|
||||
let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
|
||||
[];
|
||||
|
||||
beforeEach(() => {
|
||||
// Store and clear sandbox-related env variables to ensure a consistent test environment
|
||||
originalEnvGeminiSandbox = process.env['GEMINI_SANDBOX'];
|
||||
originalEnvSandbox = process.env['SANDBOX'];
|
||||
delete process.env['GEMINI_SANDBOX'];
|
||||
delete process.env['SANDBOX'];
|
||||
vi.stubEnv('GEMINI_SANDBOX', '');
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
vi.stubEnv('SHPOOL_SESSION_NAME', '');
|
||||
|
||||
initialUnhandledRejectionListeners =
|
||||
process.listeners('unhandledRejection');
|
||||
@@ -260,18 +257,6 @@ describe('gemini.tsx main function', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original env variables
|
||||
if (originalEnvGeminiSandbox !== undefined) {
|
||||
process.env['GEMINI_SANDBOX'] = originalEnvGeminiSandbox;
|
||||
} else {
|
||||
delete process.env['GEMINI_SANDBOX'];
|
||||
}
|
||||
if (originalEnvSandbox !== undefined) {
|
||||
process.env['SANDBOX'] = originalEnvSandbox;
|
||||
} else {
|
||||
delete process.env['SANDBOX'];
|
||||
}
|
||||
|
||||
const currentListeners = process.listeners('unhandledRejection');
|
||||
currentListeners.forEach((listener) => {
|
||||
if (!initialUnhandledRejectionListeners.includes(listener)) {
|
||||
@@ -282,6 +267,7 @@ describe('gemini.tsx main function', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(process.stdin as any).isTTY = originalIsTTY;
|
||||
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -1209,7 +1195,12 @@ describe('startInteractiveUI', () => {
|
||||
registerTelemetryConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('SHPOOL_SESSION_NAME', '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -1308,7 +1299,7 @@ describe('startInteractiveUI', () => {
|
||||
|
||||
// Verify all startup tasks were called
|
||||
expect(getVersion).toHaveBeenCalledTimes(1);
|
||||
expect(registerCleanup).toHaveBeenCalledTimes(3);
|
||||
expect(registerCleanup).toHaveBeenCalledTimes(4);
|
||||
|
||||
// Verify cleanup handler is registered with unmount function
|
||||
const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0];
|
||||
|
||||
+30
-16
@@ -57,8 +57,8 @@ import {
|
||||
writeToStderr,
|
||||
disableMouseEvents,
|
||||
enableMouseEvents,
|
||||
enterAlternateScreen,
|
||||
disableLineWrapping,
|
||||
enableLineWrapping,
|
||||
shouldEnterAlternateScreen,
|
||||
startupProfiler,
|
||||
ExitCodes,
|
||||
@@ -89,6 +89,7 @@ import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
||||
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
||||
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
|
||||
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
||||
import { useTerminalSize } from './ui/hooks/useTerminalSize.js';
|
||||
import {
|
||||
relaunchAppInChildProcess,
|
||||
relaunchOnExitCode,
|
||||
@@ -214,9 +215,13 @@ export async function startInteractiveUI(
|
||||
|
||||
const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio();
|
||||
|
||||
const isShpool = !!process.env['SHPOOL_SESSION_NAME'];
|
||||
|
||||
// Create wrapper component to use hooks inside render
|
||||
const AppWrapper = () => {
|
||||
useKittyKeyboardProtocol();
|
||||
const { columns, rows } = useTerminalSize();
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider
|
||||
@@ -234,6 +239,7 @@ export async function startInteractiveUI(
|
||||
<SessionStatsProvider>
|
||||
<VimModeProvider settings={settings}>
|
||||
<AppContainer
|
||||
key={`${columns}-${rows}`}
|
||||
config={config}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
@@ -250,6 +256,17 @@ export async function startInteractiveUI(
|
||||
);
|
||||
};
|
||||
|
||||
if (isShpool) {
|
||||
// Wait a moment for shpool to stabilize terminal size and state.
|
||||
// shpool is a persistence tool that restores terminal state by replaying it.
|
||||
// This delay gives shpool time to finish its restoration replay and send
|
||||
// the actual terminal size (often via an immediate SIGWINCH) before we
|
||||
// render the first TUI frame. Without this, the first frame may be
|
||||
// garbled or rendered at an incorrect size, which disabling incremental
|
||||
// rendering alone cannot fix for the initial frame.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const instance = render(
|
||||
process.env['DEBUG'] ? (
|
||||
<React.StrictMode>
|
||||
@@ -273,10 +290,19 @@ export async function startInteractiveUI(
|
||||
patchConsole: false,
|
||||
alternateBuffer: useAlternateBuffer,
|
||||
incrementalRendering:
|
||||
settings.merged.ui.incrementalRendering !== false && useAlternateBuffer,
|
||||
settings.merged.ui.incrementalRendering !== false &&
|
||||
useAlternateBuffer &&
|
||||
!isShpool,
|
||||
},
|
||||
);
|
||||
|
||||
if (useAlternateBuffer) {
|
||||
disableLineWrapping();
|
||||
registerCleanup(() => {
|
||||
enableLineWrapping();
|
||||
});
|
||||
}
|
||||
|
||||
checkForUpdates(settings)
|
||||
.then((info) => {
|
||||
handleAutoUpdate(info, settings, config.getProjectRoot());
|
||||
@@ -519,10 +545,10 @@ export async function main() {
|
||||
adminControlsListner.setConfig(config);
|
||||
|
||||
if (config.isInteractive() && settings.merged.general.devtools) {
|
||||
const { registerActivityLogger } = await import(
|
||||
const { setupInitialActivityLogger } = await import(
|
||||
'./utils/devtoolsService.js'
|
||||
);
|
||||
await registerActivityLogger(config);
|
||||
await setupInitialActivityLogger(config);
|
||||
}
|
||||
|
||||
// Register config for telemetry shutdown
|
||||
@@ -590,18 +616,6 @@ export async function main() {
|
||||
// input showing up in the output.
|
||||
process.stdin.setRawMode(true);
|
||||
|
||||
if (
|
||||
shouldEnterAlternateScreen(
|
||||
isAlternateBufferEnabled(settings),
|
||||
config.getScreenReader(),
|
||||
)
|
||||
) {
|
||||
enterAlternateScreen();
|
||||
disableLineWrapping();
|
||||
|
||||
// Ink will cleanup so there is no need for us to manually cleanup.
|
||||
}
|
||||
|
||||
// This cleanup isn't strictly needed but may help in certain situations.
|
||||
process.on('SIGTERM', () => {
|
||||
process.stdin.setRawMode(wasRaw);
|
||||
|
||||
@@ -38,9 +38,9 @@ import type { LoadedSettings } from './config/settings.js';
|
||||
// Mock core modules
|
||||
vi.mock('./ui/hooks/atCommandProcessor.js');
|
||||
|
||||
const mockRegisterActivityLogger = vi.hoisted(() => vi.fn());
|
||||
const mockSetupInitialActivityLogger = vi.hoisted(() => vi.fn());
|
||||
vi.mock('./utils/devtoolsService.js', () => ({
|
||||
registerActivityLogger: mockRegisterActivityLogger,
|
||||
setupInitialActivityLogger: mockSetupInitialActivityLogger,
|
||||
}));
|
||||
|
||||
const mockCoreEvents = vi.hoisted(() => ({
|
||||
@@ -286,7 +286,7 @@ describe('runNonInteractive', () => {
|
||||
prompt_id: 'prompt-id-activity-logger',
|
||||
});
|
||||
|
||||
expect(mockRegisterActivityLogger).toHaveBeenCalledWith(mockConfig);
|
||||
expect(mockSetupInitialActivityLogger).toHaveBeenCalledWith(mockConfig);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
@@ -309,7 +309,7 @@ describe('runNonInteractive', () => {
|
||||
prompt_id: 'prompt-id-activity-logger-off',
|
||||
});
|
||||
|
||||
expect(mockRegisterActivityLogger).not.toHaveBeenCalled();
|
||||
expect(mockSetupInitialActivityLogger).not.toHaveBeenCalled();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
|
||||
@@ -72,10 +72,10 @@ export async function runNonInteractive({
|
||||
});
|
||||
|
||||
if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) {
|
||||
const { registerActivityLogger } = await import(
|
||||
const { setupInitialActivityLogger } = await import(
|
||||
'./utils/devtoolsService.js'
|
||||
);
|
||||
await registerActivityLogger(config);
|
||||
await setupInitialActivityLogger(config);
|
||||
}
|
||||
|
||||
const { stdout: workingStdout } = createWorkingStdio();
|
||||
|
||||
@@ -44,7 +44,6 @@ vi.mock('../ui/utils/terminalUtils.js', () => ({
|
||||
isLowColorDepth: vi.fn(() => false),
|
||||
getColorDepth: vi.fn(() => 24),
|
||||
isITerm2: vi.fn(() => false),
|
||||
shouldUseEmoji: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
// Wrapper around ink-testing-library's render that ensures act() is called
|
||||
|
||||
@@ -84,7 +84,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
import ansiEscapes from 'ansi-escapes';
|
||||
import { type LoadedSettings, mergeSettings } from '../config/settings.js';
|
||||
import { mergeSettings, type LoadedSettings } from '../config/settings.js';
|
||||
import type { InitializationResult } from '../core/initializer.js';
|
||||
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
||||
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
|
||||
@@ -92,6 +92,7 @@ import {
|
||||
UIActionsContext,
|
||||
type UIActions,
|
||||
} from './contexts/UIActionsContext.js';
|
||||
import { KeypressProvider } from './contexts/KeypressContext.js';
|
||||
|
||||
// Mock useStdout to capture terminal title writes
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
@@ -133,7 +134,6 @@ vi.mock('./hooks/useGeminiStream.js');
|
||||
vi.mock('./hooks/vim.js');
|
||||
vi.mock('./hooks/useFocus.js');
|
||||
vi.mock('./hooks/useBracketedPaste.js');
|
||||
vi.mock('./hooks/useKeypress.js');
|
||||
vi.mock('./hooks/useLoadingIndicator.js');
|
||||
vi.mock('./hooks/useFolderTrust.js');
|
||||
vi.mock('./hooks/useIdeTrustListener.js');
|
||||
@@ -197,7 +197,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||
import { useLogger } from './hooks/useLogger.js';
|
||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
||||
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
||||
import { useKeypress } from './hooks/useKeypress.js';
|
||||
import { measureElement } from 'ink';
|
||||
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
||||
import {
|
||||
@@ -232,13 +232,15 @@ describe('AppContainer State Management', () => {
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
} = {}) => (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
version={version}
|
||||
initializationResult={initResult}
|
||||
startupWarnings={startupWarnings}
|
||||
resumedSessionData={resumedSessionData}
|
||||
/>
|
||||
<KeypressProvider config={config}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
version={version}
|
||||
initializationResult={initResult}
|
||||
startupWarnings={startupWarnings}
|
||||
resumedSessionData={resumedSessionData}
|
||||
/>
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
|
||||
@@ -268,7 +270,6 @@ describe('AppContainer State Management', () => {
|
||||
const mockedUseTextBuffer = useTextBuffer as Mock;
|
||||
const mockedUseLogger = useLogger as Mock;
|
||||
const mockedUseLoadingIndicator = useLoadingIndicator as Mock;
|
||||
const mockedUseKeypress = useKeypress as Mock;
|
||||
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
|
||||
const mockedUseHookDisplayState = useHookDisplayState as Mock;
|
||||
const mockedUseTerminalTheme = useTerminalTheme as Mock;
|
||||
@@ -1770,47 +1771,36 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => {
|
||||
let handleGlobalKeypress: (key: Key) => boolean;
|
||||
let mockHandleSlashCommand: Mock;
|
||||
let mockCancelOngoingRequest: Mock;
|
||||
let rerender: () => void;
|
||||
let unmount: () => void;
|
||||
let stdin: ReturnType<typeof render>['stdin'];
|
||||
|
||||
// Helper function to reduce boilerplate in tests
|
||||
const setupKeypressTest = async () => {
|
||||
const renderResult = renderAppContainer();
|
||||
stdin = renderResult.stdin;
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
|
||||
rerender = () => renderResult.rerender(getAppContainer());
|
||||
rerender = () => {
|
||||
renderResult.rerender(getAppContainer());
|
||||
};
|
||||
unmount = renderResult.unmount;
|
||||
};
|
||||
|
||||
const pressKey = (key: Partial<Key>, times = 1) => {
|
||||
const pressKey = (sequence: string, times = 1) => {
|
||||
for (let i = 0; i < times; i++) {
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 'c',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
...key,
|
||||
} as Key);
|
||||
stdin.write(sequence);
|
||||
});
|
||||
rerender();
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Capture the keypress handler from the AppContainer
|
||||
mockedUseKeypress.mockImplementation(
|
||||
(callback: (key: Key) => boolean) => {
|
||||
handleGlobalKeypress = callback;
|
||||
},
|
||||
);
|
||||
|
||||
// Mock slash command handler
|
||||
mockHandleSlashCommand = vi.fn();
|
||||
mockedUseSlashCommandProcessor.mockReturnValue({
|
||||
@@ -1855,7 +1845,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'c', ctrl: true });
|
||||
pressKey('\x03'); // Ctrl+C
|
||||
|
||||
expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(1);
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
@@ -1865,7 +1855,7 @@ describe('AppContainer State Management', () => {
|
||||
it('should quit on second press', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'c', ctrl: true }, 2);
|
||||
pressKey('\x03', 2); // Ctrl+C
|
||||
|
||||
expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(2);
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||
@@ -1880,7 +1870,7 @@ describe('AppContainer State Management', () => {
|
||||
it('should reset press count after a timeout', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'c', ctrl: true });
|
||||
pressKey('\x03'); // Ctrl+C
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
|
||||
// Advance timer past the reset threshold
|
||||
@@ -1888,7 +1878,7 @@ describe('AppContainer State Management', () => {
|
||||
vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1);
|
||||
});
|
||||
|
||||
pressKey({ name: 'c', ctrl: true });
|
||||
pressKey('\x03'); // Ctrl+C
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
@@ -1898,7 +1888,7 @@ describe('AppContainer State Management', () => {
|
||||
it('should quit on second press if buffer is empty', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'd', ctrl: true }, 2);
|
||||
pressKey('\x04', 2); // Ctrl+D
|
||||
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||
'/quit',
|
||||
@@ -1909,7 +1899,7 @@ describe('AppContainer State Management', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT quit if buffer is not empty (bubbles from InputPrompt)', async () => {
|
||||
it('should NOT quit if buffer is not empty', async () => {
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: 'some text',
|
||||
setText: vi.fn(),
|
||||
@@ -1919,30 +1909,12 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
await setupKeypressTest();
|
||||
|
||||
// Capture return value
|
||||
let result = true;
|
||||
const originalPressKey = (key: Partial<Key>) => {
|
||||
act(() => {
|
||||
result = handleGlobalKeypress({
|
||||
name: 'd',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
...key,
|
||||
} as Key);
|
||||
});
|
||||
rerender();
|
||||
};
|
||||
pressKey('\x04'); // Ctrl+D
|
||||
|
||||
originalPressKey({ name: 'd', ctrl: true });
|
||||
|
||||
// AppContainer's handler should return true if it reaches it
|
||||
expect(result).toBe(true);
|
||||
// But it should only be called once, so count is 1, not quitting yet.
|
||||
// Should only be called once, so count is 1, not quitting yet.
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
|
||||
originalPressKey({ name: 'd', ctrl: true });
|
||||
pressKey('\x04'); // Ctrl+D
|
||||
// Now count is 2, it should quit.
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||
'/quit',
|
||||
@@ -1956,7 +1928,7 @@ describe('AppContainer State Management', () => {
|
||||
it('should reset press count after a timeout', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'd', ctrl: true });
|
||||
pressKey('\x04'); // Ctrl+D
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
|
||||
// Advance timer past the reset threshold
|
||||
@@ -1964,7 +1936,7 @@ describe('AppContainer State Management', () => {
|
||||
vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1);
|
||||
});
|
||||
|
||||
pressKey({ name: 'd', ctrl: true });
|
||||
pressKey('\x04'); // Ctrl+D
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
@@ -1982,7 +1954,7 @@ describe('AppContainer State Management', () => {
|
||||
it('should focus shell input on Tab', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'tab', shift: false });
|
||||
pressKey('\t');
|
||||
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
unmount();
|
||||
@@ -1992,11 +1964,11 @@ describe('AppContainer State Management', () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
// Focus first
|
||||
pressKey({ name: 'tab', shift: false });
|
||||
pressKey('\t');
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
|
||||
// Unfocus via Shift+Tab
|
||||
pressKey({ name: 'tab', shift: true });
|
||||
pressKey('\x1b[Z');
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
@@ -2015,13 +1987,7 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Focus it
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 'tab',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
} as Key);
|
||||
renderResult.stdin.write('\t');
|
||||
});
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
|
||||
@@ -2056,7 +2022,7 @@ describe('AppContainer State Management', () => {
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
|
||||
// Press Tab
|
||||
pressKey({ name: 'tab', shift: false });
|
||||
pressKey('\t');
|
||||
|
||||
// Should be focused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
@@ -2084,7 +2050,7 @@ describe('AppContainer State Management', () => {
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
|
||||
// Press Ctrl+B
|
||||
pressKey({ name: 'b', ctrl: true });
|
||||
pressKey('\x02');
|
||||
|
||||
// Should have toggled (closed) the shell
|
||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||
@@ -2113,7 +2079,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
// Press Ctrl+B
|
||||
pressKey({ name: 'b', ctrl: true });
|
||||
pressKey('\x02');
|
||||
|
||||
// Should have toggled (shown) the shell
|
||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||
@@ -2126,11 +2092,14 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('Copy Mode (CTRL+S)', () => {
|
||||
let handleGlobalKeypress: (key: Key) => boolean;
|
||||
let rerender: () => void;
|
||||
let unmount: () => void;
|
||||
let stdin: ReturnType<typeof render>['stdin'];
|
||||
|
||||
const setupCopyModeTest = async (isAlternateMode = false) => {
|
||||
const setupCopyModeTest = async (
|
||||
isAlternateMode = false,
|
||||
childHandler?: Mock,
|
||||
) => {
|
||||
// Update settings for this test run
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
const testSettings = {
|
||||
@@ -2144,23 +2113,39 @@ describe('AppContainer State Management', () => {
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const renderResult = renderAppContainer({ settings: testSettings });
|
||||
function TestChild() {
|
||||
useKeypress(childHandler || (() => {}), {
|
||||
isActive: !!childHandler,
|
||||
priority: true,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const getTree = (settings: LoadedSettings) => (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider config={mockConfig}>
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>
|
||||
<TestChild />
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
|
||||
const renderResult = render(getTree(testSettings));
|
||||
stdin = renderResult.stdin;
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
|
||||
rerender = () =>
|
||||
renderResult.rerender(getAppContainer({ settings: testSettings }));
|
||||
rerender = () => renderResult.rerender(getTree(testSettings));
|
||||
unmount = renderResult.unmount;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.mockStdout.write.mockClear();
|
||||
mockedUseKeypress.mockImplementation(
|
||||
(callback: (key: Key) => boolean) => {
|
||||
handleGlobalKeypress = callback;
|
||||
},
|
||||
);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
@@ -2186,15 +2171,7 @@ describe('AppContainer State Management', () => {
|
||||
mocks.mockStdout.write.mockClear(); // Clear initial enable call
|
||||
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 's',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x13',
|
||||
});
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
});
|
||||
rerender();
|
||||
|
||||
@@ -2213,30 +2190,14 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Turn it on (disable mouse)
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 's',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x13',
|
||||
});
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
});
|
||||
rerender();
|
||||
expect(disableMouseEvents).toHaveBeenCalled();
|
||||
|
||||
// Turn it off (enable mouse)
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 'any', // Any key should exit copy mode
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
insertable: true,
|
||||
sequence: 'a',
|
||||
});
|
||||
stdin.write('a'); // Any key should exit copy mode
|
||||
});
|
||||
rerender();
|
||||
|
||||
@@ -2249,15 +2210,7 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Enter copy mode
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 's',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x13',
|
||||
});
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
});
|
||||
rerender();
|
||||
|
||||
@@ -2265,15 +2218,7 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Press any other key
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 'a',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
insertable: true,
|
||||
sequence: 'a',
|
||||
});
|
||||
stdin.write('a');
|
||||
});
|
||||
rerender();
|
||||
|
||||
@@ -2281,6 +2226,37 @@ describe('AppContainer State Management', () => {
|
||||
expect(enableMouseEvents).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should have higher priority than other priority listeners when enabled', async () => {
|
||||
// 1. Initial state with a child component's priority listener (already subscribed)
|
||||
// It should NOT handle Ctrl+S so we can enter copy mode.
|
||||
const childHandler = vi.fn().mockReturnValue(false);
|
||||
await setupCopyModeTest(true, childHandler);
|
||||
|
||||
// 2. Enter copy mode
|
||||
act(() => {
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
});
|
||||
rerender();
|
||||
|
||||
// 3. Verify we are in copy mode
|
||||
expect(disableMouseEvents).toHaveBeenCalled();
|
||||
|
||||
// 4. Press any key
|
||||
childHandler.mockClear();
|
||||
// Now childHandler should return true for other keys, simulating a greedy listener
|
||||
childHandler.mockReturnValue(true);
|
||||
|
||||
act(() => {
|
||||
stdin.write('a');
|
||||
});
|
||||
rerender();
|
||||
|
||||
// 5. Verify that the exit handler took priority and childHandler was NOT called
|
||||
expect(childHandler).not.toHaveBeenCalled();
|
||||
expect(enableMouseEvents).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,6 +101,7 @@ import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
|
||||
import { type InitializationResult } from '../core/initializer.js';
|
||||
import { useFocus } from './hooks/useFocus.js';
|
||||
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
||||
import { KeypressPriority } from './contexts/KeypressContext.js';
|
||||
import { keyMatchers, Command } from './keyMatchers.js';
|
||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
|
||||
@@ -363,7 +364,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
(async () => {
|
||||
// Note: the program will not work if this fails so let errors be
|
||||
// handled by the global catch.
|
||||
await config.initialize();
|
||||
if (!config.isInitialized()) {
|
||||
await config.initialize();
|
||||
}
|
||||
setConfigInitialized(true);
|
||||
startupProfiler.flush(config);
|
||||
|
||||
@@ -1464,17 +1467,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
if (result.userSelection === 'yes') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
handleSlashCommand('/ide install');
|
||||
settings.setValue(
|
||||
SettingScope.User,
|
||||
'hasSeenIdeIntegrationNudge',
|
||||
true,
|
||||
);
|
||||
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
|
||||
} else if (result.userSelection === 'dismiss') {
|
||||
settings.setValue(
|
||||
SettingScope.User,
|
||||
'hasSeenIdeIntegrationNudge',
|
||||
true,
|
||||
);
|
||||
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
|
||||
}
|
||||
setIdePromptAnswered(true);
|
||||
},
|
||||
@@ -1489,13 +1484,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const handleGlobalKeypress = useCallback(
|
||||
(key: Key): boolean => {
|
||||
if (copyModeEnabled) {
|
||||
setCopyModeEnabled(false);
|
||||
enableMouseEvents();
|
||||
// We don't want to process any other keys if we're in copy mode.
|
||||
return true;
|
||||
}
|
||||
|
||||
// Debug log keystrokes if enabled
|
||||
if (settings.merged.general.debugKeystrokeLogging) {
|
||||
debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key));
|
||||
@@ -1526,7 +1514,21 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) {
|
||||
setShowErrorDetails((prev) => !prev);
|
||||
if (settings.merged.general.devtools) {
|
||||
void (async () => {
|
||||
const { toggleDevToolsPanel } = await import(
|
||||
'../utils/devtoolsService.js'
|
||||
);
|
||||
await toggleDevToolsPanel(
|
||||
config,
|
||||
showErrorDetails,
|
||||
() => setShowErrorDetails((prev) => !prev),
|
||||
() => setShowErrorDetails(true),
|
||||
);
|
||||
})();
|
||||
} else {
|
||||
setShowErrorDetails((prev) => !prev);
|
||||
}
|
||||
return true;
|
||||
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
|
||||
const undoMessage = isITerm2()
|
||||
@@ -1649,7 +1651,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
settings.merged.general.debugKeystrokeLogging,
|
||||
refreshStatic,
|
||||
setCopyModeEnabled,
|
||||
copyModeEnabled,
|
||||
isAlternateBuffer,
|
||||
backgroundCurrentShell,
|
||||
toggleBackgroundShell,
|
||||
@@ -1659,11 +1660,27 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
lastOutputTimeRef,
|
||||
tabFocusTimeoutRef,
|
||||
showTransientMessage,
|
||||
settings.merged.general.devtools,
|
||||
showErrorDetails,
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
|
||||
|
||||
useKeypress(
|
||||
() => {
|
||||
setCopyModeEnabled(false);
|
||||
enableMouseEvents();
|
||||
return true;
|
||||
},
|
||||
{
|
||||
isActive: copyModeEnabled,
|
||||
// We need to receive keypresses first so they do not bubble to other
|
||||
// handlers.
|
||||
priority: KeypressPriority.Critical,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Respect hideWindowTitle settings
|
||||
if (settings.merged.ui.hideWindowTitle) return;
|
||||
|
||||
@@ -110,6 +110,7 @@ describe('ApiAuthDialog', () => {
|
||||
keypressHandler({
|
||||
name: keyName,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence,
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
||||
import { QuittingDisplay } from './QuittingDisplay.js';
|
||||
@@ -16,18 +15,15 @@ import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { ToolStatusIndicator, ToolInfo } from './messages/ToolShared.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
||||
|
||||
export const AlternateBufferQuittingDisplay = () => {
|
||||
const { version } = useAppContext();
|
||||
const uiState = useUIState();
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
|
||||
const confirmingTool = useConfirmingTool();
|
||||
const showPromptedTool =
|
||||
config.isEventDrivenSchedulerEnabled() && confirmingTool !== null;
|
||||
const inlineThinkingMode = getInlineThinkingMode(settings);
|
||||
|
||||
// We render the entire chat history and header here to ensure that the
|
||||
// conversation history is visible to the user after the app quits and the
|
||||
@@ -51,7 +47,6 @@ export const AlternateBufferQuittingDisplay = () => {
|
||||
item={h}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
inlineThinkingMode={inlineThinkingMode}
|
||||
/>
|
||||
))}
|
||||
{uiState.pendingHistoryItems.map((item, i) => (
|
||||
@@ -64,7 +59,6 @@ export const AlternateBufferQuittingDisplay = () => {
|
||||
isFocused={false}
|
||||
activeShellPtyId={uiState.activePtyId}
|
||||
embeddedShellFocused={uiState.embeddedShellFocused}
|
||||
inlineThinkingMode={inlineThinkingMode}
|
||||
/>
|
||||
))}
|
||||
{showPromptedTool && (
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
@@ -21,6 +21,14 @@ const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
|
||||
};
|
||||
|
||||
describe('AskUserDialog', () => {
|
||||
// Ensure keystrokes appear spaced in time to avoid bufferFastReturn
|
||||
// converting Enter into Shift+Enter during synchronous test execution.
|
||||
let mockTime: number;
|
||||
beforeEach(() => {
|
||||
mockTime = 0;
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => (mockTime += 50));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -158,6 +166,57 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('supports multi-line input for "Other" option in choice questions', async () => {
|
||||
const authQuestionWithOther: Question[] = [
|
||||
{
|
||||
question: 'Which authentication method?',
|
||||
header: 'Auth',
|
||||
options: [{ label: 'OAuth 2.0', description: '' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = vi.fn();
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={authQuestionWithOther}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
// Navigate to "Other" option
|
||||
writeKey(stdin, '\x1b[B'); // Down to "Other"
|
||||
|
||||
// Type first line
|
||||
for (const char of 'Line 1') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
// Insert newline using \ + Enter (handled by bufferBackslashEnter)
|
||||
writeKey(stdin, '\\');
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
// Type second line
|
||||
for (const char of 'Line 2') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Line 1');
|
||||
expect(lastFrame()).toContain('Line 2');
|
||||
});
|
||||
|
||||
// Press Enter to submit
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({ '0': 'Line 1\nLine 2' });
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ useAlternateBuffer: true, expectedArrows: false },
|
||||
{ useAlternateBuffer: false, expectedArrows: true },
|
||||
@@ -763,7 +822,7 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not submit empty text', () => {
|
||||
it('submits empty text as unanswered', async () => {
|
||||
const textQuestion: Question[] = [
|
||||
{
|
||||
question: 'Enter the class name:',
|
||||
@@ -785,8 +844,9 @@ describe('AskUserDialog', () => {
|
||||
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
// onSubmit should not be called for empty text
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
|
||||
it('clears text on Ctrl+C', async () => {
|
||||
|
||||
@@ -93,6 +93,7 @@ type AskUserDialogAction =
|
||||
payload: {
|
||||
index: number;
|
||||
answer: string;
|
||||
submit?: boolean;
|
||||
};
|
||||
}
|
||||
| { type: 'SET_EDITING_CUSTOM'; payload: { isEditing: boolean } }
|
||||
@@ -114,7 +115,7 @@ function askUserDialogReducerLogic(
|
||||
|
||||
switch (action.type) {
|
||||
case 'SET_ANSWER': {
|
||||
const { index, answer } = action.payload;
|
||||
const { index, answer, submit } = action.payload;
|
||||
const hasAnswer =
|
||||
answer !== undefined && answer !== null && answer.trim() !== '';
|
||||
const newAnswers = { ...state.answers };
|
||||
@@ -128,6 +129,7 @@ function askUserDialogReducerLogic(
|
||||
return {
|
||||
...state,
|
||||
answers: newAnswers,
|
||||
submitted: submit ? true : state.submitted,
|
||||
};
|
||||
}
|
||||
case 'SET_EDITING_CUSTOM': {
|
||||
@@ -283,8 +285,8 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
|
||||
const buffer = useTextBuffer({
|
||||
initialText: initialAnswer,
|
||||
viewport: { width: Math.max(1, bufferWidth), height: 1 },
|
||||
singleLine: true,
|
||||
viewport: { width: Math.max(1, bufferWidth), height: 3 },
|
||||
singleLine: false,
|
||||
});
|
||||
|
||||
const { text: textValue } = buffer;
|
||||
@@ -317,9 +319,7 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(val: string) => {
|
||||
if (val.trim()) {
|
||||
onAnswer(val.trim());
|
||||
}
|
||||
onAnswer(val.trim());
|
||||
},
|
||||
[onAnswer],
|
||||
);
|
||||
@@ -361,7 +361,7 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Text color={theme.text.accent}>{'> '}</Text>
|
||||
<Text color={theme.status.success}>{'> '}</Text>
|
||||
<TextInput
|
||||
buffer={buffer}
|
||||
placeholder={placeholder}
|
||||
@@ -561,8 +561,8 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
|
||||
const customBuffer = useTextBuffer({
|
||||
initialText: initialCustomText,
|
||||
viewport: { width: Math.max(1, bufferWidth), height: 1 },
|
||||
singleLine: true,
|
||||
viewport: { width: Math.max(1, bufferWidth), height: 3 },
|
||||
singleLine: false,
|
||||
});
|
||||
|
||||
const customOptionText = customBuffer.text;
|
||||
@@ -838,7 +838,9 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
<Box flexDirection="row">
|
||||
{showCheck && (
|
||||
<Text
|
||||
color={isChecked ? theme.text.accent : theme.text.secondary}
|
||||
color={
|
||||
isChecked ? theme.status.success : theme.text.secondary
|
||||
}
|
||||
>
|
||||
[{isChecked ? 'x' : ' '}]
|
||||
</Text>
|
||||
@@ -848,9 +850,22 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
buffer={customBuffer}
|
||||
placeholder={placeholder}
|
||||
focus={context.isSelected}
|
||||
onSubmit={() => handleSelect(optionItem)}
|
||||
onSubmit={(val) => {
|
||||
if (question.multiSelect) {
|
||||
const fullAnswer = buildAnswerString(
|
||||
selectedIndices,
|
||||
true,
|
||||
val,
|
||||
);
|
||||
if (fullAnswer) {
|
||||
onAnswer(fullAnswer);
|
||||
}
|
||||
} else if (val.trim()) {
|
||||
onAnswer(val.trim());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isChecked && !question.multiSelect && (
|
||||
{isChecked && !question.multiSelect && !context.isSelected && (
|
||||
<Text color={theme.status.success}> ✓</Text>
|
||||
)}
|
||||
</Box>
|
||||
@@ -870,7 +885,9 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
<Box flexDirection="row">
|
||||
{showCheck && (
|
||||
<Text
|
||||
color={isChecked ? theme.text.accent : theme.text.secondary}
|
||||
color={
|
||||
isChecked ? theme.status.success : theme.text.secondary
|
||||
}
|
||||
>
|
||||
[{isChecked ? 'x' : ' '}]
|
||||
</Text>
|
||||
@@ -1008,21 +1025,27 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
(answer: string) => {
|
||||
if (submitted) return;
|
||||
|
||||
dispatch({
|
||||
type: 'SET_ANSWER',
|
||||
payload: {
|
||||
index: currentQuestionIndex,
|
||||
answer,
|
||||
},
|
||||
});
|
||||
|
||||
if (questions.length > 1) {
|
||||
dispatch({
|
||||
type: 'SET_ANSWER',
|
||||
payload: {
|
||||
index: currentQuestionIndex,
|
||||
answer,
|
||||
},
|
||||
});
|
||||
goToNextTab();
|
||||
} else {
|
||||
dispatch({ type: 'SUBMIT' });
|
||||
dispatch({
|
||||
type: 'SET_ANSWER',
|
||||
payload: {
|
||||
index: currentQuestionIndex,
|
||||
answer,
|
||||
submit: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[currentQuestionIndex, questions.length, submitted, goToNextTab],
|
||||
[currentQuestionIndex, questions, submitted, goToNextTab],
|
||||
);
|
||||
|
||||
const handleReviewSubmit = useCallback(() => {
|
||||
|
||||
@@ -650,6 +650,19 @@ describe('Composer', () => {
|
||||
});
|
||||
|
||||
describe('Shortcuts Hint', () => {
|
||||
it('hides shortcuts hint when showShortcutsHint setting is false', () => {
|
||||
const uiState = createMockUIState();
|
||||
const settings = createMockSettings({
|
||||
ui: {
|
||||
showShortcutsHint: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
it('hides shortcuts hint when a action is required (e.g. dialog is open)', () => {
|
||||
const uiState = createMockUIState({
|
||||
customDialog: (
|
||||
|
||||
@@ -133,7 +133,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
flexDirection="column"
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
>
|
||||
{!hasPendingActionRequired && <ShortcutsHint />}
|
||||
{settings.merged.ui.showShortcutsHint &&
|
||||
!hasPendingActionRequired && <ShortcutsHint />}
|
||||
</Box>
|
||||
</Box>
|
||||
{uiState.shortcutsHelpVisible && <ShortcutsHelp />}
|
||||
|
||||
@@ -24,8 +24,8 @@ export const ContextUsageDisplay = ({
|
||||
|
||||
return (
|
||||
<Text color={theme.text.secondary}>
|
||||
({percentageLeft}
|
||||
{label})
|
||||
{percentageLeft}
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -47,6 +47,13 @@ const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
|
||||
act(() => {
|
||||
stdin.write(key);
|
||||
});
|
||||
// Advance timers to simulate time passing between keystrokes.
|
||||
// This avoids bufferFastReturn converting Enter to Shift+Enter.
|
||||
if (vi.isFakeTimers()) {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
describe('ExitPlanModeDialog', () => {
|
||||
@@ -234,7 +241,6 @@ Implement a comprehensive authentication system with multiple providers.
|
||||
// Navigate to feedback option
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\r'); // Select to focus input
|
||||
|
||||
// Type feedback
|
||||
for (const char of 'Add tests') {
|
||||
@@ -512,7 +518,6 @@ Implement a comprehensive authentication system with multiple providers.
|
||||
// Navigate to feedback option and start typing
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\r'); // Select to focus input
|
||||
|
||||
// Type some feedback
|
||||
for (const char of 'test') {
|
||||
|
||||
@@ -128,7 +128,7 @@ describe('<Footer />', () => {
|
||||
}),
|
||||
});
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\(\d+% context left\)/);
|
||||
expect(lastFrame()).toMatch(/\d+% context left/);
|
||||
});
|
||||
|
||||
it('displays the usage indicator when usage is low', () => {
|
||||
@@ -207,7 +207,7 @@ describe('<Footer />', () => {
|
||||
}),
|
||||
});
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\(\d+%\)/);
|
||||
expect(lastFrame()).toMatch(/\d+%/);
|
||||
});
|
||||
|
||||
describe('sandbox and trust info', () => {
|
||||
@@ -352,9 +352,8 @@ describe('<Footer />', () => {
|
||||
}),
|
||||
});
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).not.toMatch(/\(\d+% context left\)/);
|
||||
expect(lastFrame()).not.toMatch(/\d+% context left/);
|
||||
});
|
||||
|
||||
it('shows the context percentage when hideContextPercentage is false', () => {
|
||||
const { lastFrame } = renderWithProviders(<Footer />, {
|
||||
width: 120,
|
||||
@@ -368,9 +367,8 @@ describe('<Footer />', () => {
|
||||
}),
|
||||
});
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\(\d+% context left\)/);
|
||||
expect(lastFrame()).toMatch(/\d+% context left/);
|
||||
});
|
||||
|
||||
it('renders complete footer in narrow terminal (baseline narrow)', () => {
|
||||
const { lastFrame } = renderWithProviders(<Footer />, {
|
||||
width: 79,
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
|
||||
import process from 'node:process';
|
||||
import { ThemedGradient } from './ThemedGradient.js';
|
||||
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
|
||||
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
||||
import { QuotaDisplay } from './QuotaDisplay.js';
|
||||
@@ -41,7 +40,6 @@ export const Footer: React.FC = () => {
|
||||
errorCount,
|
||||
showErrorDetails,
|
||||
promptTokenCount,
|
||||
nightly,
|
||||
isTrustedFolder,
|
||||
terminalWidth,
|
||||
quotaStats,
|
||||
@@ -55,7 +53,6 @@ export const Footer: React.FC = () => {
|
||||
errorCount: uiState.errorCount,
|
||||
showErrorDetails: uiState.showErrorDetails,
|
||||
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
|
||||
nightly: uiState.nightly,
|
||||
isTrustedFolder: uiState.isTrustedFolder,
|
||||
terminalWidth: uiState.terminalWidth,
|
||||
quotaStats: uiState.quota.stats,
|
||||
@@ -90,20 +87,14 @@ export const Footer: React.FC = () => {
|
||||
{displayVimMode && (
|
||||
<Text color={theme.text.secondary}>[{displayVimMode}] </Text>
|
||||
)}
|
||||
{!hideCWD &&
|
||||
(nightly ? (
|
||||
<ThemedGradient>
|
||||
{displayPath}
|
||||
{branchName && <Text> ({branchName}*)</Text>}
|
||||
</ThemedGradient>
|
||||
) : (
|
||||
<Text color={theme.text.link}>
|
||||
{displayPath}
|
||||
{branchName && (
|
||||
<Text color={theme.text.secondary}> ({branchName}*)</Text>
|
||||
)}
|
||||
</Text>
|
||||
))}
|
||||
{!hideCWD && (
|
||||
<Text color={theme.text.primary}>
|
||||
{displayPath}
|
||||
{branchName && (
|
||||
<Text color={theme.text.secondary}> ({branchName}*)</Text>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{debugMode && (
|
||||
<Text color={theme.status.error}>
|
||||
{' ' + (debugMessage || '--debug')}
|
||||
@@ -149,9 +140,9 @@ export const Footer: React.FC = () => {
|
||||
{!hideModelInfo && (
|
||||
<Box alignItems="center" justifyContent="flex-end">
|
||||
<Box alignItems="center">
|
||||
<Text color={theme.text.accent}>
|
||||
<Text color={theme.text.primary}>
|
||||
<Text color={theme.text.secondary}>/model </Text>
|
||||
{getDisplayString(model)}
|
||||
<Text color={theme.text.secondary}> /model</Text>
|
||||
{!hideContextPercentage && (
|
||||
<>
|
||||
{' '}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { CliSpinner } from './CliSpinner.js';
|
||||
import type { SpinnerName } from 'cli-spinners';
|
||||
@@ -15,6 +16,10 @@ import {
|
||||
SCREEN_READER_RESPONDING,
|
||||
} from '../textConstants.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { Colors } from '../colors.js';
|
||||
import tinygradient from 'tinygradient';
|
||||
|
||||
const COLOR_CYCLE_DURATION_MS = 4000;
|
||||
|
||||
interface GeminiRespondingSpinnerProps {
|
||||
/**
|
||||
@@ -37,13 +42,16 @@ export const GeminiRespondingSpinner: React.FC<
|
||||
altText={SCREEN_READER_RESPONDING}
|
||||
/>
|
||||
);
|
||||
} else if (nonRespondingDisplay) {
|
||||
}
|
||||
|
||||
if (nonRespondingDisplay) {
|
||||
return isScreenReaderEnabled ? (
|
||||
<Text>{SCREEN_READER_LOADING}</Text>
|
||||
) : (
|
||||
<Text color={theme.text.primary}>{nonRespondingDisplay}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -57,10 +65,39 @@ export const GeminiSpinner: React.FC<GeminiSpinnerProps> = ({
|
||||
altText,
|
||||
}) => {
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
const [time, setTime] = useState(0);
|
||||
|
||||
const googleGradient = useMemo(() => {
|
||||
const brandColors = [
|
||||
Colors.AccentPurple,
|
||||
Colors.AccentBlue,
|
||||
Colors.AccentCyan,
|
||||
Colors.AccentGreen,
|
||||
Colors.AccentYellow,
|
||||
Colors.AccentRed,
|
||||
];
|
||||
return tinygradient([...brandColors, brandColors[0]]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isScreenReaderEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setTime((prevTime) => prevTime + 30);
|
||||
}, 30); // ~33fps for smooth color transitions
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isScreenReaderEnabled]);
|
||||
|
||||
const progress = (time % COLOR_CYCLE_DURATION_MS) / COLOR_CYCLE_DURATION_MS;
|
||||
const currentColor = googleGradient.rgbAt(progress).toHexString();
|
||||
|
||||
return isScreenReaderEnabled ? (
|
||||
<Text>{altText}</Text>
|
||||
) : (
|
||||
<Text color={theme.text.primary}>
|
||||
<Text color={currentColor}>
|
||||
<CliSpinner type={spinnerType} />
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
} from '@google/gemini-cli-core';
|
||||
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./messages/ToolGroupMessage.js', () => ({
|
||||
@@ -240,14 +241,15 @@ describe('<HistoryItemDisplay />', () => {
|
||||
thought: { subject: 'Thinking', description: 'test' },
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HistoryItemDisplay
|
||||
{...baseItem}
|
||||
item={item}
|
||||
inlineThinkingMode="full"
|
||||
/>,
|
||||
<HistoryItemDisplay {...baseItem} item={item} />,
|
||||
{
|
||||
settings: createMockSettings({
|
||||
merged: { ui: { inlineThinkingMode: 'full' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Thinking');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('does not render thinking item when disabled', () => {
|
||||
@@ -257,11 +259,12 @@ describe('<HistoryItemDisplay />', () => {
|
||||
thought: { subject: 'Thinking', description: 'test' },
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HistoryItemDisplay
|
||||
{...baseItem}
|
||||
item={item}
|
||||
inlineThinkingMode="off"
|
||||
/>,
|
||||
<HistoryItemDisplay {...baseItem} item={item} />,
|
||||
{
|
||||
settings: createMockSettings({
|
||||
merged: { ui: { inlineThinkingMode: 'off' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastFrame()).toBe('');
|
||||
|
||||
@@ -35,7 +35,8 @@ import { ChatList } from './views/ChatList.js';
|
||||
import { HooksList } from './views/HooksList.js';
|
||||
import { ModelMessage } from './messages/ModelMessage.js';
|
||||
import { ThinkingMessage } from './messages/ThinkingMessage.js';
|
||||
import type { InlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
||||
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
|
||||
interface HistoryItemDisplayProps {
|
||||
item: HistoryItem;
|
||||
@@ -47,7 +48,6 @@ interface HistoryItemDisplayProps {
|
||||
activeShellPtyId?: number | null;
|
||||
embeddedShellFocused?: boolean;
|
||||
availableTerminalHeightGemini?: number;
|
||||
inlineThinkingMode?: InlineThinkingMode;
|
||||
}
|
||||
|
||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
@@ -60,18 +60,16 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
activeShellPtyId,
|
||||
embeddedShellFocused,
|
||||
availableTerminalHeightGemini,
|
||||
inlineThinkingMode = 'off',
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const inlineThinkingMode = getInlineThinkingMode(settings);
|
||||
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" key={itemForDisplay.id} width={terminalWidth}>
|
||||
{/* Render standard message types */}
|
||||
{itemForDisplay.type === 'thinking' && inlineThinkingMode !== 'off' && (
|
||||
<ThinkingMessage
|
||||
thought={itemForDisplay.thought}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
<ThinkingMessage thought={itemForDisplay.thought} />
|
||||
)}
|
||||
{itemForDisplay.type === 'user' && (
|
||||
<UserMessage text={itemForDisplay.text} width={terminalWidth} />
|
||||
|
||||
@@ -281,7 +281,10 @@ describe('InputPrompt', () => {
|
||||
navigateDown: vi.fn(),
|
||||
handleSubmit: vi.fn(),
|
||||
};
|
||||
mockedUseInputHistory.mockReturnValue(mockInputHistory);
|
||||
mockedUseInputHistory.mockImplementation(({ onSubmit }) => {
|
||||
mockInputHistory.handleSubmit = vi.fn((val) => onSubmit(val));
|
||||
return mockInputHistory;
|
||||
});
|
||||
|
||||
mockReverseSearchCompletion = {
|
||||
suggestions: [],
|
||||
@@ -4093,7 +4096,7 @@ describe('InputPrompt', () => {
|
||||
beforeEach(() => {
|
||||
props.userMessages = ['first message', 'second message'];
|
||||
// Mock useInputHistory to actually call onChange
|
||||
mockedUseInputHistory.mockImplementation(({ onChange }) => ({
|
||||
mockedUseInputHistory.mockImplementation(({ onChange, onSubmit }) => ({
|
||||
navigateUp: () => {
|
||||
onChange('second message', 'start');
|
||||
return true;
|
||||
@@ -4102,7 +4105,7 @@ describe('InputPrompt', () => {
|
||||
onChange('first message', 'end');
|
||||
return true;
|
||||
},
|
||||
handleSubmit: vi.fn(),
|
||||
handleSubmit: vi.fn((val) => onSubmit(val)),
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -4293,6 +4296,30 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
describe('shortcuts help visibility', () => {
|
||||
it('opens shortcuts help with ? on empty prompt even when showShortcutsHint is false', async () => {
|
||||
const setShortcutsHelpVisible = vi.fn();
|
||||
const settings = createMockSettings({
|
||||
ui: { showShortcutsHint: false },
|
||||
});
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
settings,
|
||||
uiActions: { setShortcutsHelpVisible },
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('?');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setShortcutsHelpVisible).toHaveBeenCalledWith(true);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'terminal paste event occurs',
|
||||
|
||||
@@ -56,7 +56,10 @@ import {
|
||||
} from '../utils/commandUtils.js';
|
||||
import * as path from 'node:path';
|
||||
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
|
||||
import { DEFAULT_BACKGROUND_OPACITY } from '../constants.js';
|
||||
import {
|
||||
DEFAULT_BACKGROUND_OPACITY,
|
||||
DEFAULT_INPUT_BACKGROUND_OPACITY,
|
||||
} from '../constants.js';
|
||||
import { getSafeLowColorBackground } from '../themes/color-utils.js';
|
||||
import { isLowColorDepth } from '../utils/terminalUtils.js';
|
||||
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
|
||||
@@ -334,31 +337,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(submittedValue: string) => {
|
||||
const trimmedMessage = submittedValue.trim();
|
||||
const isSlash = isSlashCommand(trimmedMessage);
|
||||
|
||||
const isShell = shellModeActive;
|
||||
if (
|
||||
(isSlash || isShell) &&
|
||||
streamingState === StreamingState.Responding
|
||||
) {
|
||||
setQueueErrorMessage(
|
||||
`${isShell ? 'Shell' : 'Slash'} commands cannot be queued`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
handleSubmitAndClear(trimmedMessage);
|
||||
},
|
||||
[
|
||||
handleSubmitAndClear,
|
||||
shellModeActive,
|
||||
streamingState,
|
||||
setQueueErrorMessage,
|
||||
],
|
||||
);
|
||||
|
||||
const customSetTextAndResetCompletionSignal = useCallback(
|
||||
(newText: string, cursorPosition?: 'start' | 'end' | number) => {
|
||||
buffer.setText(newText, cursorPosition);
|
||||
@@ -378,6 +356,26 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
onChange: customSetTextAndResetCompletionSignal,
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(submittedValue: string) => {
|
||||
const trimmedMessage = submittedValue.trim();
|
||||
const isSlash = isSlashCommand(trimmedMessage);
|
||||
|
||||
const isShell = shellModeActive;
|
||||
if (
|
||||
(isSlash || isShell) &&
|
||||
streamingState === StreamingState.Responding
|
||||
) {
|
||||
setQueueErrorMessage(
|
||||
`${isShell ? 'Shell' : 'Slash'} commands cannot be queued`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
inputHistory.handleSubmit(trimmedMessage);
|
||||
},
|
||||
[inputHistory, shellModeActive, streamingState, setQueueErrorMessage],
|
||||
);
|
||||
|
||||
// Effect to reset completion if history navigation just occurred and set the text
|
||||
useEffect(() => {
|
||||
if (suppressCompletion) {
|
||||
@@ -855,7 +853,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
showSuggestions && activeSuggestionIndex > -1
|
||||
? suggestions[activeSuggestionIndex].value
|
||||
: buffer.text;
|
||||
handleSubmitAndClear(textToSubmit);
|
||||
handleSubmit(textToSubmit);
|
||||
resetState();
|
||||
setActive(false);
|
||||
return true;
|
||||
@@ -1152,7 +1150,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setShellModeActive,
|
||||
onClearScreen,
|
||||
inputHistory,
|
||||
handleSubmitAndClear,
|
||||
handleSubmit,
|
||||
shellHistory,
|
||||
reverseSearchCompletion,
|
||||
@@ -1405,12 +1402,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
/>
|
||||
) : null}
|
||||
<HalfLinePaddedBox
|
||||
backgroundBaseColor={
|
||||
isShellFocused && !isEmbeddedShellFocused
|
||||
? theme.border.focused
|
||||
: theme.border.default
|
||||
backgroundBaseColor={theme.text.secondary}
|
||||
backgroundOpacity={
|
||||
showCursor
|
||||
? DEFAULT_INPUT_BACKGROUND_OPACITY
|
||||
: DEFAULT_BACKGROUND_OPACITY
|
||||
}
|
||||
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
|
||||
useBackgroundColor={useBackgroundColor}
|
||||
>
|
||||
<Box
|
||||
|
||||
@@ -12,7 +12,6 @@ import { StreamingContext } from '../contexts/StreamingContext.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { vi } from 'vitest';
|
||||
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
||||
import * as terminalUtils from '../utils/terminalUtils.js';
|
||||
|
||||
// Mock GeminiRespondingSpinner
|
||||
vi.mock('./GeminiRespondingSpinner.js', () => ({
|
||||
@@ -35,12 +34,7 @@ vi.mock('../hooks/useTerminalSize.js', () => ({
|
||||
useTerminalSize: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/terminalUtils.js', () => ({
|
||||
shouldUseEmoji: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
|
||||
const shouldUseEmojiMock = vi.mocked(terminalUtils.shouldUseEmoji);
|
||||
|
||||
const renderWithContext = (
|
||||
ui: React.ReactElement,
|
||||
@@ -63,12 +57,12 @@ describe('<LoadingIndicator />', () => {
|
||||
elapsedTime: 5,
|
||||
};
|
||||
|
||||
it('should not render when streamingState is Idle and no loading phrase or thought', () => {
|
||||
it('should render blank when streamingState is Idle and no loading phrase or thought', () => {
|
||||
const { lastFrame } = renderWithContext(
|
||||
<LoadingIndicator elapsedTime={5} />,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
expect(lastFrame()?.trim()).toBe('');
|
||||
});
|
||||
|
||||
it('should render spinner, phrase, and time when streamingState is Responding', () => {
|
||||
@@ -152,7 +146,7 @@ describe('<LoadingIndicator />', () => {
|
||||
<LoadingIndicator elapsedTime={5} />,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
expect(lastFrame()).toBe(''); // Initial: Idle (no loading phrase)
|
||||
expect(lastFrame()?.trim()).toBe(''); // Initial: Idle (no loading phrase)
|
||||
|
||||
// Transition to Responding
|
||||
rerender(
|
||||
@@ -189,7 +183,7 @@ describe('<LoadingIndicator />', () => {
|
||||
<LoadingIndicator elapsedTime={5} />
|
||||
</StreamingContext.Provider>,
|
||||
);
|
||||
expect(lastFrame()).toBe(''); // Idle with no loading phrase
|
||||
expect(lastFrame()?.trim()).toBe(''); // Idle with no loading phrase and no spinner
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -230,26 +224,6 @@ describe('<LoadingIndicator />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should use ASCII fallback thought indicator when emoji is unavailable', () => {
|
||||
shouldUseEmojiMock.mockReturnValue(false);
|
||||
const props = {
|
||||
thought: {
|
||||
subject: 'Thinking with fallback',
|
||||
description: 'details',
|
||||
},
|
||||
elapsedTime: 5,
|
||||
};
|
||||
const { lastFrame, unmount } = renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('o Thinking with fallback');
|
||||
expect(output).not.toContain('💬');
|
||||
shouldUseEmojiMock.mockReturnValue(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should prioritize thought.subject over currentLoadingPhrase', () => {
|
||||
const props = {
|
||||
thought: {
|
||||
|
||||
@@ -15,7 +15,6 @@ import { formatDuration } from '../utils/formatters.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
|
||||
import { shouldUseEmoji } from '../utils/terminalUtils.js';
|
||||
|
||||
interface LoadingIndicatorProps {
|
||||
currentLoadingPhrase?: string;
|
||||
@@ -59,9 +58,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
const hasThoughtIndicator =
|
||||
currentLoadingPhrase !== INTERACTIVE_SHELL_WAITING_PHRASE &&
|
||||
Boolean(thought?.subject?.trim());
|
||||
const thinkingIndicator = hasThoughtIndicator
|
||||
? `${shouldUseEmoji() ? '💬' : 'o'} `
|
||||
: '';
|
||||
const thinkingIndicator = hasThoughtIndicator ? '💬 ' : '';
|
||||
|
||||
const cancelAndTimerContent =
|
||||
showCancelAndTimer &&
|
||||
@@ -82,7 +79,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
/>
|
||||
</Box>
|
||||
{primaryText && (
|
||||
<Text color={theme.text.accent} wrap="truncate-end">
|
||||
<Text color={theme.text.primary} italic wrap="truncate-end">
|
||||
{thinkingIndicator}
|
||||
{primaryText}
|
||||
</Text>
|
||||
@@ -116,7 +113,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
/>
|
||||
</Box>
|
||||
{primaryText && (
|
||||
<Text color={theme.text.accent} wrap="truncate-end">
|
||||
<Text color={theme.text.primary} italic wrap="truncate-end">
|
||||
{thinkingIndicator}
|
||||
{primaryText}
|
||||
</Text>
|
||||
|
||||
@@ -89,6 +89,8 @@ describe('MainContent', () => {
|
||||
historyRemountKey: 0,
|
||||
bannerData: { defaultText: '', warningText: '' },
|
||||
bannerVisible: false,
|
||||
copyModeEnabled: false,
|
||||
terminalWidth: 100,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -173,6 +175,7 @@ describe('MainContent', () => {
|
||||
vi.mocked(useAlternateBuffer).mockReturnValue(isAlternateBuffer);
|
||||
const ptyId = 123;
|
||||
const uiState = {
|
||||
...defaultMockUiState,
|
||||
history: [],
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Box, Static } from 'ink';
|
||||
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useAppContext } from '../contexts/AppContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import {
|
||||
@@ -21,7 +20,6 @@ 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';
|
||||
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
||||
|
||||
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
|
||||
const MemoizedAppHeader = memo(AppHeader);
|
||||
@@ -33,7 +31,6 @@ const MemoizedAppHeader = memo(AppHeader);
|
||||
export const MainContent = () => {
|
||||
const { version } = useAppContext();
|
||||
const uiState = useUIState();
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
@@ -56,8 +53,6 @@ export const MainContent = () => {
|
||||
availableTerminalHeight,
|
||||
} = uiState;
|
||||
|
||||
const inlineThinkingMode = getInlineThinkingMode(settings);
|
||||
|
||||
const historyItems = useMemo(
|
||||
() =>
|
||||
uiState.history.map((h) => (
|
||||
@@ -69,7 +64,6 @@ export const MainContent = () => {
|
||||
item={h}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
inlineThinkingMode={inlineThinkingMode}
|
||||
/>
|
||||
)),
|
||||
[
|
||||
@@ -77,7 +71,6 @@ export const MainContent = () => {
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
uiState.slashCommands,
|
||||
inlineThinkingMode,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -99,7 +92,6 @@ export const MainContent = () => {
|
||||
isFocused={!uiState.isEditorDialogOpen}
|
||||
activeShellPtyId={uiState.activePtyId}
|
||||
embeddedShellFocused={uiState.embeddedShellFocused}
|
||||
inlineThinkingMode={inlineThinkingMode}
|
||||
/>
|
||||
))}
|
||||
{showConfirmationQueue && confirmingTool && (
|
||||
@@ -113,7 +105,6 @@ export const MainContent = () => {
|
||||
isAlternateBuffer,
|
||||
availableTerminalHeight,
|
||||
mainAreaWidth,
|
||||
inlineThinkingMode,
|
||||
uiState.isEditorDialogOpen,
|
||||
uiState.activePtyId,
|
||||
uiState.embeddedShellFocused,
|
||||
@@ -145,20 +136,13 @@ export const MainContent = () => {
|
||||
item={item.item}
|
||||
isPending={false}
|
||||
commands={uiState.slashCommands}
|
||||
inlineThinkingMode={inlineThinkingMode}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return pendingItems;
|
||||
}
|
||||
},
|
||||
[
|
||||
version,
|
||||
mainAreaWidth,
|
||||
uiState.slashCommands,
|
||||
inlineThinkingMode,
|
||||
pendingItems,
|
||||
],
|
||||
[version, mainAreaWidth, uiState.slashCommands, pendingItems],
|
||||
);
|
||||
|
||||
if (isAlternateBuffer) {
|
||||
|
||||
@@ -6,18 +6,14 @@
|
||||
|
||||
import { Box } from 'ink';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
||||
|
||||
export const QuittingDisplay = () => {
|
||||
const uiState = useUIState();
|
||||
const settings = useSettings();
|
||||
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
|
||||
|
||||
const availableTerminalHeight = terminalHeight;
|
||||
const inlineThinkingMode = getInlineThinkingMode(settings);
|
||||
|
||||
if (!uiState.quittingMessages) {
|
||||
return null;
|
||||
@@ -34,7 +30,6 @@ export const QuittingDisplay = () => {
|
||||
terminalWidth={terminalWidth}
|
||||
item={item}
|
||||
isPending={false}
|
||||
inlineThinkingMode={inlineThinkingMode}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -54,14 +54,3 @@ exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`
|
||||
│ ● 2. tail -f log.txt (PID: 1002) │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > selects the current process and closes the list when Ctrl+L is pressed in list view 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm sta... (PID: 1001) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
|
||||
│ │
|
||||
│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel): │
|
||||
│ │
|
||||
│ ● 1. npm start (PID: 1001) │
|
||||
│ 2. tail -f log.txt (PID: 1002) │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
@@ -21,7 +21,7 @@ Files to Modify
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Type your feedback... ✓
|
||||
● 3. Type your feedback...
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
@@ -47,7 +47,7 @@ Files to Modify
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Add tests ✓
|
||||
● 3. Add tests
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
@@ -127,7 +127,7 @@ Files to Modify
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Type your feedback... ✓
|
||||
● 3. Type your feedback...
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
@@ -153,7 +153,7 @@ Files to Modify
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Add tests ✓
|
||||
● 3. Add tests
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<Footer /> > displays "Limit reached" message when remaining is 0 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) gemini-pro /model Limit reached"`;
|
||||
exports[`<Footer /> > displays "Limit reached" message when remaining is 0 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro Limit reached"`;
|
||||
|
||||
exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) gemini-pro /model 15%"`;
|
||||
exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 15%"`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `" ...s/to/make/it/long no sandbox gemini-pro /model (100%)"`;
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `" ...s/to/make/it/long no sandbox /model gemini-pro 100%"`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) gemini-pro /model (100% context left)"`;
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 100% context left"`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `" no sandbox (see /docs)"`;
|
||||
|
||||
@@ -14,4 +14,4 @@ exports[`<Footer /> > footer configuration filtering (golden snapshots) > render
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `" ...directories/to/make/it/long no sandbox (see /docs)"`;
|
||||
|
||||
exports[`<Footer /> > hides the usage indicator when usage is not near limit 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) gemini-pro /model"`;
|
||||
exports[`<Footer /> > hides the usage indicator when usage is not near limit 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro"`;
|
||||
|
||||
@@ -385,3 +385,9 @@ exports[`<HistoryItemDisplay /> > renders InfoMessage for "info" type with multi
|
||||
⚡ Line 2
|
||||
⚡ Line 3"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > thinking items > renders thinking item when enabled 1`] = `
|
||||
" Thinking
|
||||
│ test
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -107,9 +107,9 @@ ShowMoreLines"
|
||||
exports[`MainContent > does not constrain height in alternate buffer mode 1`] = `
|
||||
"ScrollableList
|
||||
AppHeader
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> Hello
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
✦ Hi there
|
||||
ShowMoreLines
|
||||
"
|
||||
|
||||
@@ -13,84 +13,66 @@ describe('ThinkingMessage', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ThinkingMessage
|
||||
thought={{ subject: 'Planning', description: 'test' }}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Planning');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('uses description when subject is empty', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ThinkingMessage
|
||||
thought={{ subject: '', description: 'Processing details' }}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Processing details');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders full mode with left vertical rule and full text', () => {
|
||||
it('renders full mode with left border and full text', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ThinkingMessage
|
||||
thought={{
|
||||
subject: 'Planning',
|
||||
description: 'I am planning the solution.',
|
||||
}}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('│');
|
||||
expect(lastFrame()).not.toContain('┌');
|
||||
expect(lastFrame()).not.toContain('┐');
|
||||
expect(lastFrame()).not.toContain('└');
|
||||
expect(lastFrame()).not.toContain('┘');
|
||||
expect(lastFrame()).toContain('Planning');
|
||||
expect(lastFrame()).toContain('I am planning the solution.');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('starts left rule below the bold summary line in full mode', () => {
|
||||
it('indents summary line correctly', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ThinkingMessage
|
||||
thought={{
|
||||
subject: 'Summary line',
|
||||
description: 'First body line',
|
||||
}}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
const lines = (lastFrame() ?? '').split('\n');
|
||||
expect(lines[0] ?? '').toContain('Summary line');
|
||||
expect(lines[0] ?? '').not.toContain('│');
|
||||
expect(lines.slice(1).join('\n')).toContain('│');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('normalizes escaped newline tokens so literal \\n\\n is not shown', () => {
|
||||
it('normalizes escaped newline tokens', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ThinkingMessage
|
||||
thought={{
|
||||
subject: 'Matching the Blocks',
|
||||
description: '\\n\\n',
|
||||
description: '\\n\\nSome more text',
|
||||
}}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Matching the Blocks');
|
||||
expect(lastFrame()).not.toContain('\\n\\n');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders empty state gracefully', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ThinkingMessage
|
||||
thought={{ subject: '', description: '' }}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
<ThinkingMessage thought={{ subject: '', description: '' }} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('Planning');
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,163 +9,72 @@ import { useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { ThoughtSummary } from '@google/gemini-cli-core';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { normalizeEscapedNewlines } from '../../utils/textUtils.js';
|
||||
|
||||
interface ThinkingMessageProps {
|
||||
thought: ThoughtSummary;
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
const THINKING_LEFT_PADDING = 1;
|
||||
|
||||
function splitGraphemes(value: string): string[] {
|
||||
if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {
|
||||
const segmenter = new Intl.Segmenter(undefined, {
|
||||
granularity: 'grapheme',
|
||||
});
|
||||
return Array.from(segmenter.segment(value), (segment) => segment.segment);
|
||||
}
|
||||
|
||||
return Array.from(value);
|
||||
}
|
||||
|
||||
function normalizeEscapedNewlines(value: string): string {
|
||||
return value.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n');
|
||||
}
|
||||
|
||||
function normalizeThoughtLines(thought: ThoughtSummary): string[] {
|
||||
const subject = normalizeEscapedNewlines(thought.subject).trim();
|
||||
const description = normalizeEscapedNewlines(thought.description).trim();
|
||||
|
||||
if (!subject && !description) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!subject) {
|
||||
return description
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const bodyLines = description
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
return [subject, ...bodyLines];
|
||||
}
|
||||
|
||||
function graphemeLength(value: string): number {
|
||||
return splitGraphemes(value).length;
|
||||
}
|
||||
|
||||
function chunkToWidth(value: string, width: number): string[] {
|
||||
if (width <= 0) {
|
||||
return [''];
|
||||
}
|
||||
|
||||
const graphemes = splitGraphemes(value);
|
||||
if (graphemes.length === 0) {
|
||||
return [''];
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
for (let index = 0; index < graphemes.length; index += width) {
|
||||
chunks.push(graphemes.slice(index, index + width).join(''));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function wrapLineToWidth(line: string, width: number): string[] {
|
||||
if (width <= 0) {
|
||||
return [''];
|
||||
}
|
||||
|
||||
const normalized = line.trim();
|
||||
if (!normalized) {
|
||||
return [''];
|
||||
}
|
||||
|
||||
const words = normalized.split(/\s+/);
|
||||
const wrapped: string[] = [];
|
||||
let current = '';
|
||||
|
||||
for (const word of words) {
|
||||
const wordChunks = chunkToWidth(word, width);
|
||||
|
||||
for (const wordChunk of wordChunks) {
|
||||
if (!current) {
|
||||
current = wordChunk;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (graphemeLength(current) + 1 + graphemeLength(wordChunk) <= width) {
|
||||
current = `${current} ${wordChunk}`;
|
||||
} else {
|
||||
wrapped.push(current);
|
||||
current = wordChunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
wrapped.push(current);
|
||||
}
|
||||
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a model's thought as a distinct bubble.
|
||||
* Leverages Ink layout for wrapping and borders.
|
||||
*/
|
||||
export const ThinkingMessage: React.FC<ThinkingMessageProps> = ({
|
||||
thought,
|
||||
terminalWidth,
|
||||
}) => {
|
||||
const fullLines = useMemo(() => normalizeThoughtLines(thought), [thought]);
|
||||
const fullSummaryDisplayLines = useMemo(() => {
|
||||
const contentWidth = Math.max(terminalWidth - THINKING_LEFT_PADDING - 2, 1);
|
||||
return fullLines.length > 0
|
||||
? wrapLineToWidth(fullLines[0], contentWidth)
|
||||
: [];
|
||||
}, [fullLines, terminalWidth]);
|
||||
const fullBodyDisplayLines = useMemo(() => {
|
||||
const contentWidth = Math.max(terminalWidth - THINKING_LEFT_PADDING - 2, 1);
|
||||
return fullLines
|
||||
.slice(1)
|
||||
.flatMap((line) => wrapLineToWidth(line, contentWidth));
|
||||
}, [fullLines, terminalWidth]);
|
||||
const { summary, body } = useMemo(() => {
|
||||
const subject = normalizeEscapedNewlines(thought.subject).trim();
|
||||
const description = normalizeEscapedNewlines(thought.description).trim();
|
||||
|
||||
if (
|
||||
fullSummaryDisplayLines.length === 0 &&
|
||||
fullBodyDisplayLines.length === 0
|
||||
) {
|
||||
if (!subject && !description) {
|
||||
return { summary: '', body: '' };
|
||||
}
|
||||
|
||||
if (!subject) {
|
||||
const lines = description
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
return {
|
||||
summary: lines[0] || '',
|
||||
body: lines.slice(1).join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
summary: subject,
|
||||
body: description,
|
||||
};
|
||||
}, [thought]);
|
||||
|
||||
if (!summary && !body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
marginBottom={1}
|
||||
paddingLeft={THINKING_LEFT_PADDING}
|
||||
flexDirection="column"
|
||||
>
|
||||
{fullSummaryDisplayLines.map((line, index) => (
|
||||
<Box key={`summary-line-row-${index}`} flexDirection="row">
|
||||
<Box width={2}>
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
<Text color={theme.text.primary} bold italic wrap="truncate-end">
|
||||
{line}
|
||||
<Box width="100%" marginBottom={1} paddingLeft={1} flexDirection="column">
|
||||
{summary && (
|
||||
<Box paddingLeft={2}>
|
||||
<Text color={theme.text.primary} bold italic>
|
||||
{summary}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
{fullBodyDisplayLines.map((line, index) => (
|
||||
<Box key={`body-line-row-${index}`} flexDirection="row">
|
||||
<Box width={2}>
|
||||
<Text color={theme.border.default}>│ </Text>
|
||||
</Box>
|
||||
<Text color={theme.text.secondary} italic wrap="truncate-end">
|
||||
{line}
|
||||
)}
|
||||
{body && (
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderLeft
|
||||
borderRight={false}
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderColor={theme.border.default}
|
||||
paddingLeft={1}
|
||||
>
|
||||
<Text color={theme.text.secondary} italic>
|
||||
{body}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -247,7 +247,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
*/
|
||||
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
|
||||
<Box
|
||||
height={1}
|
||||
height={0}
|
||||
width={contentWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
|
||||
@@ -52,7 +52,7 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text, width }) => {
|
||||
|
||||
return (
|
||||
<HalfLinePaddedBox
|
||||
backgroundBaseColor={theme.border.default}
|
||||
backgroundBaseColor={theme.text.secondary}
|
||||
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
|
||||
useBackgroundColor={useBackgroundColor}
|
||||
>
|
||||
|
||||
@@ -28,7 +28,7 @@ export const UserShellMessage: React.FC<UserShellMessageProps> = ({
|
||||
|
||||
return (
|
||||
<HalfLinePaddedBox
|
||||
backgroundBaseColor={theme.border.default}
|
||||
backgroundBaseColor={theme.text.secondary}
|
||||
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
|
||||
useBackgroundColor={useBackgroundColor}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ThinkingMessage > indents summary line correctly 1`] = `
|
||||
" Summary line
|
||||
│ First body line
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ThinkingMessage > normalizes escaped newline tokens 1`] = `
|
||||
" Matching the Blocks
|
||||
│ Some more text
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ThinkingMessage > renders full mode with left border and full text 1`] = `
|
||||
" Planning
|
||||
│ I am planning the solution.
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ThinkingMessage > renders subject line 1`] = `
|
||||
" Planning
|
||||
│ test
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ThinkingMessage > uses description when subject is empty 1`] = `
|
||||
" Processing details
|
||||
"
|
||||
`;
|
||||
@@ -14,6 +14,12 @@ import { MouseProvider } from '../../contexts/MouseContext.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
|
||||
vi.mock('../../contexts/UIStateContext.js', () => ({
|
||||
useUIState: vi.fn(() => ({
|
||||
copyModeEnabled: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock useStdout to provide a fixed size for testing
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ink')>();
|
||||
|
||||
@@ -13,6 +13,7 @@ import chalk from 'chalk';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import type { TextBuffer } from './text-buffer.js';
|
||||
import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js';
|
||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||
|
||||
export interface TextInputProps {
|
||||
buffer: TextBuffer;
|
||||
@@ -45,7 +46,7 @@ export function TextInput({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key.name === 'return' && onSubmit) {
|
||||
if (keyMatchers[Command.SUBMIT](key) && onSubmit) {
|
||||
onSubmit(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { UIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
vi.mock('../../contexts/UIStateContext.js', () => ({
|
||||
useUIState: vi.fn(() => ({
|
||||
copyModeEnabled: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
@@ -324,4 +331,39 @@ describe('<VirtualizedList />', () => {
|
||||
|
||||
expect(ref.current?.getScrollState().scrollTop).toBe(4);
|
||||
});
|
||||
|
||||
it('renders correctly in copyModeEnabled when scrolled', async () => {
|
||||
const { useUIState } = await import('../../contexts/UIStateContext.js');
|
||||
vi.mocked(useUIState).mockReturnValue({
|
||||
copyModeEnabled: true,
|
||||
} as Partial<UIState> as UIState);
|
||||
|
||||
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
|
||||
// Use copy mode
|
||||
const { lastFrame } = render(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
data={longData}
|
||||
renderItem={({ item }) => (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
)}
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 1}
|
||||
initialScrollIndex={50}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
// Item 50 should be visible
|
||||
expect(lastFrame()).toContain('Item 50');
|
||||
// And surrounding items
|
||||
expect(lastFrame()).toContain('Item 59');
|
||||
// But far away items should not be (ensures we are actually scrolled)
|
||||
expect(lastFrame()).not.toContain('Item 0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import type React from 'react';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
import { type DOMElement, measureElement, Box } from 'ink';
|
||||
|
||||
@@ -78,6 +79,7 @@ function VirtualizedList<T>(
|
||||
initialScrollIndex,
|
||||
initialScrollOffsetInIndex,
|
||||
} = props;
|
||||
const { copyModeEnabled } = useUIState();
|
||||
const dataRef = useRef(data);
|
||||
useEffect(() => {
|
||||
dataRef.current = data;
|
||||
@@ -474,16 +476,21 @@ function VirtualizedList<T>(
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
overflowY="scroll"
|
||||
overflowY={copyModeEnabled ? 'hidden' : 'scroll'}
|
||||
overflowX="hidden"
|
||||
scrollTop={scrollTop}
|
||||
scrollTop={copyModeEnabled ? 0 : scrollTop}
|
||||
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
paddingRight={1}
|
||||
paddingRight={copyModeEnabled ? 0 : 1}
|
||||
>
|
||||
<Box flexShrink={0} width="100%" flexDirection="column">
|
||||
<Box
|
||||
flexShrink={0}
|
||||
width="100%"
|
||||
flexDirection="column"
|
||||
marginTop={copyModeEnabled ? -scrollTop : 0}
|
||||
>
|
||||
<Box height={topSpacerHeight} flexShrink={0} />
|
||||
{renderedItems}
|
||||
<Box height={bottomSpacerHeight} flexShrink={0} />
|
||||
|
||||
@@ -34,7 +34,9 @@ export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
|
||||
export const SHELL_ACTION_REQUIRED_TITLE_DELAY_MS = 30000;
|
||||
export const SHELL_SILENT_WORKING_TITLE_DELAY_MS = 120000;
|
||||
|
||||
export const DEFAULT_BACKGROUND_OPACITY = 0.08;
|
||||
export const DEFAULT_BACKGROUND_OPACITY = 0.16;
|
||||
export const DEFAULT_INPUT_BACKGROUND_OPACITY = 0.24;
|
||||
export const DEFAULT_BORDER_OPACITY = 0.2;
|
||||
|
||||
export const KEYBOARD_SHORTCUTS_URL =
|
||||
'https://geminicli.com/docs/cli/keyboard-shortcuts/';
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { debugLogger, type Config } from '@google/gemini-cli-core';
|
||||
import { useStdin } from 'ink';
|
||||
import { MultiMap } from 'mnemonist';
|
||||
import type React from 'react';
|
||||
import {
|
||||
createContext,
|
||||
@@ -26,6 +27,13 @@ export const ESC_TIMEOUT = 50;
|
||||
export const PASTE_TIMEOUT = 30_000;
|
||||
export const FAST_RETURN_TIMEOUT = 30;
|
||||
|
||||
export enum KeypressPriority {
|
||||
Low = -100,
|
||||
Normal = 0,
|
||||
High = 100,
|
||||
Critical = 200,
|
||||
}
|
||||
|
||||
// Parse the key itself
|
||||
const KEY_INFO_MAP: Record<
|
||||
string,
|
||||
@@ -645,7 +653,10 @@ export interface Key {
|
||||
export type KeypressHandler = (key: Key) => boolean | void;
|
||||
|
||||
interface KeypressContextValue {
|
||||
subscribe: (handler: KeypressHandler, priority?: boolean) => void;
|
||||
subscribe: (
|
||||
handler: KeypressHandler,
|
||||
priority?: KeypressPriority | boolean,
|
||||
) => void;
|
||||
unsubscribe: (handler: KeypressHandler) => void;
|
||||
}
|
||||
|
||||
@@ -674,44 +685,75 @@ export function KeypressProvider({
|
||||
}) {
|
||||
const { stdin, setRawMode } = useStdin();
|
||||
|
||||
const prioritySubscribers = useRef<Set<KeypressHandler>>(new Set()).current;
|
||||
const normalSubscribers = useRef<Set<KeypressHandler>>(new Set()).current;
|
||||
const subscribersToPriority = useRef<Map<KeypressHandler, number>>(
|
||||
new Map(),
|
||||
).current;
|
||||
const subscribers = useRef(
|
||||
new MultiMap<number, KeypressHandler>(Set),
|
||||
).current;
|
||||
const sortedPriorities = useRef<number[]>([]);
|
||||
|
||||
const subscribe = useCallback(
|
||||
(handler: KeypressHandler, priority = false) => {
|
||||
const set = priority ? prioritySubscribers : normalSubscribers;
|
||||
set.add(handler);
|
||||
(
|
||||
handler: KeypressHandler,
|
||||
priority: KeypressPriority | boolean = KeypressPriority.Normal,
|
||||
) => {
|
||||
const p =
|
||||
typeof priority === 'boolean'
|
||||
? priority
|
||||
? KeypressPriority.High
|
||||
: KeypressPriority.Normal
|
||||
: priority;
|
||||
|
||||
subscribersToPriority.set(handler, p);
|
||||
const hadPriority = subscribers.has(p);
|
||||
subscribers.set(p, handler);
|
||||
|
||||
if (!hadPriority) {
|
||||
// Cache sorted priorities only when a new priority level is added
|
||||
sortedPriorities.current = Array.from(subscribers.keys()).sort(
|
||||
(a, b) => b - a,
|
||||
);
|
||||
}
|
||||
},
|
||||
[prioritySubscribers, normalSubscribers],
|
||||
[subscribers, subscribersToPriority],
|
||||
);
|
||||
|
||||
const unsubscribe = useCallback(
|
||||
(handler: KeypressHandler) => {
|
||||
prioritySubscribers.delete(handler);
|
||||
normalSubscribers.delete(handler);
|
||||
const p = subscribersToPriority.get(handler);
|
||||
if (p !== undefined) {
|
||||
subscribers.remove(p, handler);
|
||||
subscribersToPriority.delete(handler);
|
||||
|
||||
if (!subscribers.has(p)) {
|
||||
// Cache sorted priorities only when a priority level is completely removed
|
||||
sortedPriorities.current = Array.from(subscribers.keys()).sort(
|
||||
(a, b) => b - a,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[prioritySubscribers, normalSubscribers],
|
||||
[subscribers, subscribersToPriority],
|
||||
);
|
||||
|
||||
const broadcast = useCallback(
|
||||
(key: Key) => {
|
||||
// Process priority subscribers first, in reverse order (stack behavior: last subscribed is first to handle)
|
||||
const priorityHandlers = Array.from(prioritySubscribers).reverse();
|
||||
for (const handler of priorityHandlers) {
|
||||
if (handler(key) === true) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Use cached sorted priorities to avoid sorting on every keypress
|
||||
for (const p of sortedPriorities.current) {
|
||||
const set = subscribers.get(p);
|
||||
if (!set) continue;
|
||||
|
||||
// Then process normal subscribers, also in reverse order
|
||||
const normalHandlers = Array.from(normalSubscribers).reverse();
|
||||
for (const handler of normalHandlers) {
|
||||
if (handler(key) === true) {
|
||||
return;
|
||||
// Within a priority level, use stack behavior (last subscribed is first to handle)
|
||||
const handlers = Array.from(set).reverse();
|
||||
for (const handler of handlers) {
|
||||
if (handler(key) === true) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[prioritySubscribers, normalSubscribers],
|
||||
[subscribers],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -121,7 +121,7 @@ export interface UIState {
|
||||
showEscapePrompt: boolean;
|
||||
shortcutsHelpVisible: boolean;
|
||||
elapsedTime: number;
|
||||
currentLoadingPhrase: string;
|
||||
currentLoadingPhrase: string | undefined;
|
||||
historyRemountKey: number;
|
||||
activeHooks: ActiveHook[];
|
||||
messageQueue: string[];
|
||||
|
||||
@@ -31,6 +31,7 @@ function consoleMessagesReducer(
|
||||
state: ConsoleMessageItem[],
|
||||
action: Action,
|
||||
): ConsoleMessageItem[] {
|
||||
const MAX_CONSOLE_MESSAGES = 1000;
|
||||
switch (action.type) {
|
||||
case 'ADD_MESSAGES': {
|
||||
const newMessages = [...state];
|
||||
@@ -51,6 +52,12 @@ function consoleMessagesReducer(
|
||||
newMessages.push({ ...queuedMessage, count: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
// Limit the number of messages to prevent memory issues
|
||||
if (newMessages.length > MAX_CONSOLE_MESSAGES) {
|
||||
return newMessages.slice(newMessages.length - MAX_CONSOLE_MESSAGES);
|
||||
}
|
||||
|
||||
return newMessages;
|
||||
}
|
||||
case 'CLEAR':
|
||||
@@ -91,9 +98,17 @@ export function useConsoleMessages(): UseConsoleMessagesReturn {
|
||||
|
||||
useEffect(() => {
|
||||
const handleConsoleLog = (payload: ConsoleLogPayload) => {
|
||||
let content = payload.content;
|
||||
const MAX_CONSOLE_MSG_LENGTH = 10000;
|
||||
if (content.length > MAX_CONSOLE_MSG_LENGTH) {
|
||||
content =
|
||||
content.slice(0, MAX_CONSOLE_MSG_LENGTH) +
|
||||
`... [Truncated ${content.length - MAX_CONSOLE_MSG_LENGTH} characters]`;
|
||||
}
|
||||
|
||||
handleNewMessage({
|
||||
type: payload.type,
|
||||
content: payload.content,
|
||||
content,
|
||||
count: 1,
|
||||
});
|
||||
};
|
||||
@@ -102,10 +117,18 @@ export function useConsoleMessages(): UseConsoleMessagesReturn {
|
||||
isStderr: boolean;
|
||||
chunk: Uint8Array | string;
|
||||
}) => {
|
||||
const content =
|
||||
let content =
|
||||
typeof payload.chunk === 'string'
|
||||
? payload.chunk
|
||||
: new TextDecoder().decode(payload.chunk);
|
||||
|
||||
const MAX_OUTPUT_CHUNK_LENGTH = 10000;
|
||||
if (content.length > MAX_OUTPUT_CHUNK_LENGTH) {
|
||||
content =
|
||||
content.slice(0, MAX_OUTPUT_CHUNK_LENGTH) +
|
||||
`... [Truncated ${content.length - MAX_OUTPUT_CHUNK_LENGTH} characters]`;
|
||||
}
|
||||
|
||||
// It would be nice if we could show stderr as 'warn' but unfortunately
|
||||
// we log non warning info to stderr before the app starts so that would
|
||||
// be misleading.
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import type { KeypressHandler, Key } from '../contexts/KeypressContext.js';
|
||||
import { useKeypressContext } from '../contexts/KeypressContext.js';
|
||||
import {
|
||||
useKeypressContext,
|
||||
type KeypressHandler,
|
||||
type Key,
|
||||
type KeypressPriority,
|
||||
} from '../contexts/KeypressContext.js';
|
||||
|
||||
export type { Key };
|
||||
|
||||
@@ -16,11 +20,14 @@ export type { Key };
|
||||
* @param onKeypress - The callback function to execute on each keypress.
|
||||
* @param options - Options to control the hook's behavior.
|
||||
* @param options.isActive - Whether the hook should be actively listening for input.
|
||||
* @param options.priority - Whether the hook should have priority over normal subscribers.
|
||||
* @param options.priority - Priority level (integer or KeypressPriority enum) or boolean for backward compatibility.
|
||||
*/
|
||||
export function useKeypress(
|
||||
onKeypress: KeypressHandler,
|
||||
{ isActive, priority }: { isActive: boolean; priority?: boolean },
|
||||
{
|
||||
isActive,
|
||||
priority,
|
||||
}: { isActive: boolean; priority?: KeypressPriority | boolean },
|
||||
) {
|
||||
const { subscribe, unsubscribe } = useKeypressContext();
|
||||
|
||||
|
||||
@@ -76,9 +76,7 @@ describe('useLoadingIndicator', () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
|
||||
const { result } = renderLoadingIndicatorHook(StreamingState.Idle);
|
||||
expect(result.current.elapsedTime).toBe(0);
|
||||
expect(WITTY_LOADING_PHRASES).toContain(
|
||||
result.current.currentLoadingPhrase,
|
||||
);
|
||||
expect(result.current.currentLoadingPhrase).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should show interactive shell waiting phrase when shouldShowFocusHint is true', async () => {
|
||||
@@ -198,9 +196,7 @@ describe('useLoadingIndicator', () => {
|
||||
});
|
||||
|
||||
expect(result.current.elapsedTime).toBe(0);
|
||||
expect(WITTY_LOADING_PHRASES).toContain(
|
||||
result.current.currentLoadingPhrase,
|
||||
);
|
||||
expect(result.current.currentLoadingPhrase).toBeUndefined();
|
||||
|
||||
// Timer should not advance
|
||||
await act(async () => {
|
||||
|
||||
@@ -45,12 +45,12 @@ describe('usePhraseCycler', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize with a witty phrase when not active and not waiting', () => {
|
||||
it('should initialize with an empty string when not active and not waiting', () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
|
||||
const { lastFrame } = render(
|
||||
<TestComponent isActive={false} isWaiting={false} />,
|
||||
);
|
||||
expect(WITTY_LOADING_PHRASES).toContain(lastFrame());
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
|
||||
it('should show "Waiting for user confirmation..." when isWaiting is true', async () => {
|
||||
@@ -195,7 +195,7 @@ describe('usePhraseCycler', () => {
|
||||
});
|
||||
expect(customPhrases).toContain(lastFrame()); // Should be one of the custom phrases
|
||||
|
||||
// Deactivate -> resets to first phrase in sequence
|
||||
// Deactivate -> resets to undefined (empty string in output)
|
||||
rerender(
|
||||
<TestComponent
|
||||
isActive={false}
|
||||
@@ -206,8 +206,8 @@ describe('usePhraseCycler', () => {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
// The phrase should be the first phrase after reset
|
||||
expect(customPhrases).toContain(lastFrame());
|
||||
// The phrase should be empty after reset
|
||||
expect(lastFrame()).toBe('');
|
||||
|
||||
// Activate again -> this will show a tip on first activation, then cycle from where mock is
|
||||
rerender(
|
||||
|
||||
@@ -31,9 +31,9 @@ export const usePhraseCycler = (
|
||||
? customPhrases
|
||||
: WITTY_LOADING_PHRASES;
|
||||
|
||||
const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState(
|
||||
loadingPhrases[0],
|
||||
);
|
||||
const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState<
|
||||
string | undefined
|
||||
>(isActive ? loadingPhrases[0] : undefined);
|
||||
|
||||
const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hasShownFirstRequestTipRef = useRef(false);
|
||||
@@ -56,7 +56,7 @@ export const usePhraseCycler = (
|
||||
}
|
||||
|
||||
if (!isActive) {
|
||||
setCurrentLoadingPhrase(loadingPhrases[0]);
|
||||
setCurrentLoadingPhrase(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,9 @@ export const DefaultAppLayout: React.FC = () => {
|
||||
flexDirection="column"
|
||||
width={uiState.terminalWidth}
|
||||
height={isAlternateBuffer ? terminalHeight : undefined}
|
||||
paddingBottom={isAlternateBuffer ? 1 : undefined}
|
||||
paddingBottom={
|
||||
isAlternateBuffer && !uiState.copyModeEnabled ? 1 : undefined
|
||||
}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
overflow="hidden"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from './color-utils.js';
|
||||
|
||||
import type { CustomTheme } from '@google/gemini-cli-core';
|
||||
import { DEFAULT_BORDER_OPACITY } from '../constants.js';
|
||||
|
||||
export type { CustomTheme };
|
||||
|
||||
@@ -136,7 +137,11 @@ export class Theme {
|
||||
},
|
||||
},
|
||||
border: {
|
||||
default: this.colors.Gray,
|
||||
default: interpolateColor(
|
||||
this.colors.Background,
|
||||
this.colors.Gray,
|
||||
DEFAULT_BORDER_OPACITY,
|
||||
),
|
||||
focused: this.colors.AccentBlue,
|
||||
},
|
||||
ui: {
|
||||
@@ -401,7 +406,13 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
|
||||
},
|
||||
},
|
||||
border: {
|
||||
default: customTheme.border?.default ?? colors.Gray,
|
||||
default:
|
||||
customTheme.border?.default ??
|
||||
interpolateColor(
|
||||
colors.Background,
|
||||
colors.Gray,
|
||||
DEFAULT_BORDER_OPACITY,
|
||||
),
|
||||
focused: customTheme.border?.focused ?? colors.AccentBlue,
|
||||
},
|
||||
ui: {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { getPlainTextLength } from './InlineMarkdownRenderer.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('getPlainTextLength', () => {
|
||||
it.each([
|
||||
['**Primary Go', 12],
|
||||
['*Primary Go', 11],
|
||||
['**Primary Go**', 10],
|
||||
['*Primary Go*', 10],
|
||||
['**', 2],
|
||||
['*', 1],
|
||||
['compile-time**', 14],
|
||||
])(
|
||||
'should measure markdown text length correctly for "%s"',
|
||||
(input, expected) => {
|
||||
expect(getPlainTextLength(input)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -7,7 +7,6 @@
|
||||
import React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import stringWidth from 'string-width';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
// Constants for Markdown parsing
|
||||
@@ -171,19 +170,3 @@ const RenderInlineInternal: React.FC<RenderInlineProps> = ({
|
||||
};
|
||||
|
||||
export const RenderInline = React.memo(RenderInlineInternal);
|
||||
|
||||
/**
|
||||
* Utility function to get the plain text length of a string with markdown formatting
|
||||
* This is useful for calculating column widths in tables
|
||||
*/
|
||||
export const getPlainTextLength = (text: string): number => {
|
||||
const cleanText = text
|
||||
.replace(/\*\*(.*?)\*\*/g, '$1')
|
||||
.replace(/\*(.+?)\*/g, '$1')
|
||||
.replace(/_(.*?)_/g, '$1')
|
||||
.replace(/~~(.*?)~~/g, '$1')
|
||||
.replace(/`(.*?)`/g, '$1')
|
||||
.replace(/<u>(.*?)<\/u>/g, '$1')
|
||||
.replace(/.*\[(.*?)\]\(.*\)/g, '$1');
|
||||
return stringWidth(cleanText);
|
||||
};
|
||||
|
||||
@@ -61,4 +61,288 @@ describe('TableRenderer', () => {
|
||||
expect(output).toContain('Data 3.4');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('wraps long cell content correctly', () => {
|
||||
const headers = ['Col 1', 'Col 2', 'Col 3'];
|
||||
const rows = [
|
||||
[
|
||||
'Short',
|
||||
'This is a very long cell content that should wrap to multiple lines',
|
||||
'Short',
|
||||
],
|
||||
];
|
||||
const terminalWidth = 50;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TableRenderer
|
||||
headers={headers}
|
||||
rows={rows}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('This is a very');
|
||||
expect(output).toContain('long cell');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('wraps all long columns correctly', () => {
|
||||
const headers = ['Col 1', 'Col 2', 'Col 3'];
|
||||
const rows = [
|
||||
[
|
||||
'This is a very long text that needs wrapping in column 1',
|
||||
'This is also a very long text that needs wrapping in column 2',
|
||||
'And this is the third long text that needs wrapping in column 3',
|
||||
],
|
||||
];
|
||||
const terminalWidth = 60;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TableRenderer
|
||||
headers={headers}
|
||||
rows={rows}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('wrapping in');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('wraps mixed long and short columns correctly', () => {
|
||||
const headers = ['Short', 'Long', 'Medium'];
|
||||
const rows = [
|
||||
[
|
||||
'Tiny',
|
||||
'This is a very long text that definitely needs to wrap to the next line',
|
||||
'Not so long',
|
||||
],
|
||||
];
|
||||
const terminalWidth = 50;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TableRenderer
|
||||
headers={headers}
|
||||
rows={rows}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Tiny');
|
||||
expect(output).toContain('definitely needs');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
// The snapshot looks weird but checked on VS Code terminal and it looks fine
|
||||
it('wraps columns with punctuation correctly', () => {
|
||||
const headers = ['Punctuation 1', 'Punctuation 2', 'Punctuation 3'];
|
||||
const rows = [
|
||||
[
|
||||
'Start. Stop. Comma, separated. Exclamation! Question? hyphen-ated',
|
||||
'Semi; colon: Pipe| Slash/ Backslash\\',
|
||||
'At@ Hash# Dollar$ Percent% Caret^ Ampersand& Asterisk*',
|
||||
],
|
||||
];
|
||||
const terminalWidth = 60;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TableRenderer
|
||||
headers={headers}
|
||||
rows={rows}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Start. Stop.');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('strips bold markers from headers and renders them correctly', () => {
|
||||
const headers = ['**Bold Header**', 'Normal Header', '**Another Bold**'];
|
||||
const rows = [['Data 1', 'Data 2', 'Data 3']];
|
||||
const terminalWidth = 50;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TableRenderer
|
||||
headers={headers}
|
||||
rows={rows}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
// The output should NOT contain the literal '**'
|
||||
expect(output).not.toContain('**Bold Header**');
|
||||
expect(output).toContain('Bold Header');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('handles wrapped bold headers without showing markers', () => {
|
||||
const headers = [
|
||||
'**Very Long Bold Header That Will Wrap**',
|
||||
'Short',
|
||||
'**Another Long Header**',
|
||||
];
|
||||
const rows = [['Data 1', 'Data 2', 'Data 3']];
|
||||
const terminalWidth = 40;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TableRenderer
|
||||
headers={headers}
|
||||
rows={rows}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
// Markers should be gone
|
||||
expect(output).not.toContain('**');
|
||||
expect(output).toContain('Very Long');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders a complex table with mixed content lengths correctly', () => {
|
||||
const headers = [
|
||||
'Comprehensive Architectural Specification for the Distributed Infrastructure Layer',
|
||||
'Implementation Details for the High-Throughput Asynchronous Message Processing Pipeline with Extended Scalability Features and Redundancy Protocols',
|
||||
'Longitudinal Performance Analysis Across Multi-Regional Cloud Deployment Clusters',
|
||||
'Strategic Security Framework for Mitigating Sophisticated Cross-Site Scripting Vulnerabilities',
|
||||
'Key',
|
||||
'Status',
|
||||
'Version',
|
||||
'Owner',
|
||||
];
|
||||
const rows = [
|
||||
[
|
||||
'The primary architecture utilizes a decoupled microservices approach, leveraging container orchestration for scalability and fault tolerance in high-load scenarios.\n\nThis layer provides the fundamental building blocks for service discovery, load balancing, and inter-service communication via highly efficient protocol buffers.\n\nAdvanced telemetry and logging integrations allow for real-time monitoring of system health and rapid identification of bottlenecks within the service mesh.',
|
||||
'Each message is processed through a series of specialized workers that handle data transformation, validation, and persistent storage using a persistent queue.\n\nThe pipeline features built-in retry mechanisms with exponential backoff to ensure message delivery integrity even during transient network or service failures.\n\nHorizontal autoscaling is triggered automatically based on the depth of the processing queue, ensuring consistent performance during unexpected traffic spikes.',
|
||||
'Historical data indicates a significant reduction in tail latency when utilizing edge computing nodes closer to the geographic location of the end-user base.\n\nMonitoring tools have captured a steady increase in throughput efficiency since the introduction of the vectorized query engine in the primary data warehouse.\n\nResource utilization metrics demonstrate that the transition to serverless compute for intermittent tasks has resulted in a thirty percent cost optimization.',
|
||||
'A multi-layered defense strategy incorporates content security policies, input sanitization libraries, and regular automated penetration testing routines.\n\nDevelopers are required to undergo mandatory security training focusing on the OWASP Top Ten to ensure that security is integrated into the initial design phase.\n\nThe implementation of a robust Identity and Access Management system ensures that the principle of least privilege is strictly enforced across all environments.',
|
||||
'INF',
|
||||
'Active',
|
||||
'v2.4',
|
||||
'J. Doe',
|
||||
],
|
||||
];
|
||||
|
||||
const terminalWidth = 160;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TableRenderer
|
||||
headers={headers}
|
||||
rows={rows}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
{ width: terminalWidth },
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Comprehensive Architectural');
|
||||
expect(output).toContain('protocol buffers');
|
||||
expect(output).toContain('exponential backoff');
|
||||
expect(output).toContain('vectorized query engine');
|
||||
expect(output).toContain('OWASP Top Ten');
|
||||
expect(output).toContain('INF');
|
||||
expect(output).toContain('Active');
|
||||
expect(output).toContain('v2.4');
|
||||
// "J. Doe" might wrap due to column width constraints
|
||||
expect(output).toContain('J.');
|
||||
expect(output).toContain('Doe');
|
||||
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'handles non-ASCII characters (emojis and Asian scripts) correctly',
|
||||
headers: ['Emoji 😃', 'Asian 汉字', 'Mixed 🚀 Text'],
|
||||
rows: [
|
||||
['Start 🌟 End', '你好世界', 'Rocket 🚀 Man'],
|
||||
['Thumbs 👍 Up', 'こんにちは', 'Fire 🔥'],
|
||||
],
|
||||
terminalWidth: 60,
|
||||
expected: ['Emoji 😃', 'Asian 汉字', '你好世界'],
|
||||
},
|
||||
{
|
||||
name: 'renders a table with only emojis and text correctly',
|
||||
headers: ['Happy 😀', 'Rocket 🚀', 'Heart ❤️'],
|
||||
rows: [
|
||||
['Smile 😃', 'Fire 🔥', 'Love 💖'],
|
||||
['Cool 😎', 'Star ⭐', 'Blue 💙'],
|
||||
],
|
||||
terminalWidth: 60,
|
||||
expected: ['Happy 😀', 'Smile 😃', 'Fire 🔥'],
|
||||
},
|
||||
{
|
||||
name: 'renders a table with only Asian characters and text correctly',
|
||||
headers: ['Chinese 中文', 'Japanese 日本語', 'Korean 한국어'],
|
||||
rows: [
|
||||
['你好', 'こんにちは', '안녕하세요'],
|
||||
['世界', '世界', '세계'],
|
||||
],
|
||||
terminalWidth: 60,
|
||||
expected: ['Chinese 中文', '你好', 'こんにちは'],
|
||||
},
|
||||
{
|
||||
name: 'renders a table with mixed emojis, Asian characters, and text correctly',
|
||||
headers: ['Mixed 😃 中文', 'Complex 🚀 日本語', 'Text 📝 한국어'],
|
||||
rows: [
|
||||
['你好 😃', 'こんにちは 🚀', '안녕하세요 📝'],
|
||||
['World 🌍', 'Code 💻', 'Pizza 🍕'],
|
||||
],
|
||||
terminalWidth: 80,
|
||||
expected: ['Mixed 😃 中文', '你好 😃', 'こんにちは 🚀'],
|
||||
},
|
||||
])('$name', ({ headers, rows, terminalWidth, expected }) => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TableRenderer
|
||||
headers={headers}
|
||||
rows={rows}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
{ width: terminalWidth },
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expected.forEach((text) => {
|
||||
expect(output).toContain(text);
|
||||
});
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'renders correctly when headers are empty but rows have data',
|
||||
headers: [] as string[],
|
||||
rows: [['Data 1', 'Data 2']],
|
||||
expected: ['Data 1', 'Data 2'],
|
||||
},
|
||||
{
|
||||
name: 'renders correctly when there are more headers than columns in rows',
|
||||
headers: ['Header 1', 'Header 2', 'Header 3'],
|
||||
rows: [['Data 1', 'Data 2']],
|
||||
expected: ['Header 1', 'Header 2', 'Header 3', 'Data 1', 'Data 2'],
|
||||
},
|
||||
])('$name', ({ headers, rows, expected }) => {
|
||||
const terminalWidth = 50;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TableRenderer
|
||||
headers={headers}
|
||||
rows={rows}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expected.forEach((text) => {
|
||||
expect(output).toContain(text);
|
||||
});
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,19 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import {
|
||||
type StyledChar,
|
||||
toStyledCharacters,
|
||||
styledCharsToString,
|
||||
styledCharsWidth,
|
||||
wordBreakStyledChars,
|
||||
wrapStyledChars,
|
||||
widestLineFromStyledChars,
|
||||
} from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { RenderInline, getPlainTextLength } from './InlineMarkdownRenderer.js';
|
||||
import { RenderInline } from './InlineMarkdownRenderer.js';
|
||||
|
||||
interface TableRendererProps {
|
||||
headers: string[];
|
||||
@@ -15,6 +24,25 @@ interface TableRendererProps {
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
const MIN_COLUMN_WIDTH = 5;
|
||||
const COLUMN_PADDING = 2;
|
||||
const TABLE_MARGIN = 2;
|
||||
|
||||
const calculateWidths = (styledChars: StyledChar[]) => {
|
||||
const contentWidth = styledCharsWidth(styledChars);
|
||||
|
||||
const words: StyledChar[][] = wordBreakStyledChars(styledChars);
|
||||
const maxWordWidth = widestLineFromStyledChars(words);
|
||||
|
||||
return { contentWidth, maxWordWidth };
|
||||
};
|
||||
|
||||
// Used to reduce redundant parsing and cache the widths for each line
|
||||
interface ProcessedLine {
|
||||
text: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom table renderer for markdown tables
|
||||
* We implement our own instead of using ink-table due to module compatibility issues
|
||||
@@ -24,89 +52,163 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
rows,
|
||||
terminalWidth,
|
||||
}) => {
|
||||
// Calculate column widths using actual display width after markdown processing
|
||||
const columnWidths = headers.map((header, index) => {
|
||||
const headerWidth = getPlainTextLength(header);
|
||||
const maxRowWidth = Math.max(
|
||||
...rows.map((row) => getPlainTextLength(row[index] || '')),
|
||||
// Clean headers: remove bold markers since we already render headers as bold
|
||||
// and having them can break wrapping when the markers are split across lines.
|
||||
const cleanedHeaders = useMemo(
|
||||
() => headers.map((header) => header.replace(/\*\*(.*?)\*\*/g, '$1')),
|
||||
[headers],
|
||||
);
|
||||
|
||||
const styledHeaders = useMemo(
|
||||
() => cleanedHeaders.map((header) => toStyledCharacters(header)),
|
||||
[cleanedHeaders],
|
||||
);
|
||||
|
||||
const styledRows = useMemo(
|
||||
() => rows.map((row) => row.map((cell) => toStyledCharacters(cell))),
|
||||
[rows],
|
||||
);
|
||||
|
||||
const { wrappedHeaders, wrappedRows, adjustedWidths } = useMemo(() => {
|
||||
const numColumns = styledRows.reduce(
|
||||
(max, row) => Math.max(max, row.length),
|
||||
styledHeaders.length,
|
||||
);
|
||||
return Math.max(headerWidth, maxRowWidth) + 2; // Add padding
|
||||
});
|
||||
|
||||
// Ensure table fits within terminal width
|
||||
// We calculate scale based on content width vs available width (terminal - borders)
|
||||
// First, extract content widths by removing the 2-char padding.
|
||||
const contentWidths = columnWidths.map((width) => Math.max(0, width - 2));
|
||||
const totalContentWidth = contentWidths.reduce(
|
||||
(sum, width) => sum + width,
|
||||
0,
|
||||
);
|
||||
// --- Define Constraints per Column ---
|
||||
const constraints = Array.from({ length: numColumns }).map(
|
||||
(_, colIndex) => {
|
||||
const headerStyledChars = styledHeaders[colIndex] || [];
|
||||
let { contentWidth: maxContentWidth, maxWordWidth } =
|
||||
calculateWidths(headerStyledChars);
|
||||
|
||||
// Fixed overhead includes padding (2 per column) and separators (1 per column + 1 final).
|
||||
const fixedOverhead = headers.length * 2 + (headers.length + 1);
|
||||
styledRows.forEach((row) => {
|
||||
const cellStyledChars = row[colIndex] || [];
|
||||
const { contentWidth: cellWidth, maxWordWidth: cellWordWidth } =
|
||||
calculateWidths(cellStyledChars);
|
||||
|
||||
// Subtract 1 from available width to avoid edge-case wrapping on some terminals
|
||||
const availableWidth = Math.max(0, terminalWidth - fixedOverhead - 1);
|
||||
maxContentWidth = Math.max(maxContentWidth, cellWidth);
|
||||
maxWordWidth = Math.max(maxWordWidth, cellWordWidth);
|
||||
});
|
||||
|
||||
const scaleFactor =
|
||||
totalContentWidth > availableWidth ? availableWidth / totalContentWidth : 1;
|
||||
const adjustedWidths = contentWidths.map(
|
||||
(width) => Math.floor(width * scaleFactor) + 2,
|
||||
);
|
||||
const minWidth = maxWordWidth;
|
||||
const maxWidth = Math.max(minWidth, maxContentWidth);
|
||||
|
||||
// Helper function to render a cell with proper width
|
||||
const renderCell = (
|
||||
content: string,
|
||||
width: number,
|
||||
isHeader = false,
|
||||
): React.ReactNode => {
|
||||
const contentWidth = Math.max(0, width - 2);
|
||||
const displayWidth = getPlainTextLength(content);
|
||||
return { minWidth, maxWidth };
|
||||
},
|
||||
);
|
||||
|
||||
let cellContent = content;
|
||||
if (displayWidth > contentWidth) {
|
||||
if (contentWidth <= 3) {
|
||||
// Just truncate by character count
|
||||
cellContent = content.substring(
|
||||
0,
|
||||
Math.min(content.length, contentWidth),
|
||||
);
|
||||
} else {
|
||||
// Truncate preserving markdown formatting using binary search
|
||||
let left = 0;
|
||||
let right = content.length;
|
||||
let bestTruncated = content;
|
||||
// --- Calculate Available Space ---
|
||||
// Fixed overhead: borders (n+1) + padding (2n)
|
||||
const fixedOverhead = numColumns + 1 + numColumns * COLUMN_PADDING;
|
||||
const availableWidth = Math.max(
|
||||
0,
|
||||
terminalWidth - fixedOverhead - TABLE_MARGIN,
|
||||
);
|
||||
|
||||
// Binary search to find the optimal truncation point
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
const candidate = content.substring(0, mid);
|
||||
const candidateWidth = getPlainTextLength(candidate);
|
||||
// --- Allocation Algorithm ---
|
||||
const totalMinWidth = constraints.reduce((sum, c) => sum + c.minWidth, 0);
|
||||
let finalContentWidths: number[];
|
||||
|
||||
if (candidateWidth <= contentWidth - 1) {
|
||||
bestTruncated = candidate;
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid - 1;
|
||||
}
|
||||
if (totalMinWidth > availableWidth) {
|
||||
// We must scale all the columns except the ones that are very short(<=5 characters)
|
||||
const shortColumns = constraints.filter(
|
||||
(c) => c.maxWidth <= MIN_COLUMN_WIDTH,
|
||||
);
|
||||
const totalShortColumnWidth = shortColumns.reduce(
|
||||
(sum, c) => sum + c.minWidth,
|
||||
0,
|
||||
);
|
||||
|
||||
const finalTotalShortColumnWidth =
|
||||
totalShortColumnWidth >= availableWidth ? 0 : totalShortColumnWidth;
|
||||
|
||||
const scale =
|
||||
(availableWidth - finalTotalShortColumnWidth) /
|
||||
(totalMinWidth - finalTotalShortColumnWidth);
|
||||
finalContentWidths = constraints.map((c) => {
|
||||
if (c.maxWidth <= MIN_COLUMN_WIDTH && finalTotalShortColumnWidth > 0) {
|
||||
return c.minWidth;
|
||||
}
|
||||
return Math.floor(c.minWidth * scale);
|
||||
});
|
||||
} else {
|
||||
const surplus = availableWidth - totalMinWidth;
|
||||
const totalGrowthNeed = constraints.reduce(
|
||||
(sum, c) => sum + (c.maxWidth - c.minWidth),
|
||||
0,
|
||||
);
|
||||
|
||||
cellContent = bestTruncated + '…';
|
||||
if (totalGrowthNeed === 0) {
|
||||
finalContentWidths = constraints.map((c) => c.minWidth);
|
||||
} else {
|
||||
finalContentWidths = constraints.map((c) => {
|
||||
const growthNeed = c.maxWidth - c.minWidth;
|
||||
const share = growthNeed / totalGrowthNeed;
|
||||
const extra = Math.floor(surplus * share);
|
||||
return Math.min(c.maxWidth, c.minWidth + extra);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate exact padding needed
|
||||
const actualDisplayWidth = getPlainTextLength(cellContent);
|
||||
const paddingNeeded = Math.max(0, contentWidth - actualDisplayWidth);
|
||||
// --- Pre-wrap and Optimize Widths ---
|
||||
const actualColumnWidths = new Array(numColumns).fill(0);
|
||||
|
||||
const wrapAndProcessRow = (row: StyledChar[][]) => {
|
||||
const rowResult: ProcessedLine[][] = [];
|
||||
// Ensure we iterate up to numColumns, filling with empty cells if needed
|
||||
for (let colIndex = 0; colIndex < numColumns; colIndex++) {
|
||||
const cellStyledChars = row[colIndex] || [];
|
||||
const allocatedWidth = finalContentWidths[colIndex];
|
||||
const contentWidth = Math.max(1, allocatedWidth);
|
||||
|
||||
const wrappedStyledLines = wrapStyledChars(
|
||||
cellStyledChars,
|
||||
contentWidth,
|
||||
);
|
||||
|
||||
const maxLineWidth = widestLineFromStyledChars(wrappedStyledLines);
|
||||
actualColumnWidths[colIndex] = Math.max(
|
||||
actualColumnWidths[colIndex],
|
||||
maxLineWidth,
|
||||
);
|
||||
|
||||
const lines = wrappedStyledLines.map((line) => ({
|
||||
text: styledCharsToString(line),
|
||||
width: styledCharsWidth(line),
|
||||
}));
|
||||
rowResult.push(lines);
|
||||
}
|
||||
return rowResult;
|
||||
};
|
||||
|
||||
const wrappedHeaders = wrapAndProcessRow(styledHeaders);
|
||||
const wrappedRows = styledRows.map((row) => wrapAndProcessRow(row));
|
||||
|
||||
// Use the TIGHTEST widths that fit the wrapped content + padding
|
||||
const adjustedWidths = actualColumnWidths.map((w) => w + COLUMN_PADDING);
|
||||
|
||||
return { wrappedHeaders, wrappedRows, adjustedWidths };
|
||||
}, [styledHeaders, styledRows, terminalWidth]);
|
||||
// Helper function to render a cell with proper width
|
||||
const renderCell = (
|
||||
content: ProcessedLine,
|
||||
width: number,
|
||||
isHeader = false,
|
||||
): React.ReactNode => {
|
||||
const contentWidth = Math.max(0, width - COLUMN_PADDING);
|
||||
// Use pre-calculated width to avoid re-parsing
|
||||
const displayWidth = content.width;
|
||||
const paddingNeeded = Math.max(0, contentWidth - displayWidth);
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{isHeader ? (
|
||||
<Text bold color={theme.text.link}>
|
||||
<RenderInline text={cellContent} />
|
||||
<RenderInline text={content.text} />
|
||||
</Text>
|
||||
) : (
|
||||
<RenderInline text={cellContent} />
|
||||
<RenderInline text={content.text} />
|
||||
)}
|
||||
{' '.repeat(paddingNeeded)}
|
||||
</Text>
|
||||
@@ -128,11 +230,14 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
return <Text color={theme.border.default}>{border}</Text>;
|
||||
};
|
||||
|
||||
// Helper function to render a table row
|
||||
const renderRow = (cells: string[], isHeader = false): React.ReactNode => {
|
||||
// Helper function to render a single visual line of a row
|
||||
const renderVisualRow = (
|
||||
cells: ProcessedLine[],
|
||||
isHeader = false,
|
||||
): React.ReactNode => {
|
||||
const renderedCells = cells.map((cell, index) => {
|
||||
const width = adjustedWidths[index] || 0;
|
||||
return renderCell(cell || '', width, isHeader);
|
||||
return renderCell(cell, width, isHeader);
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -151,21 +256,46 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Handles the wrapping logic for a logical data row
|
||||
const renderDataRow = (
|
||||
wrappedCells: ProcessedLine[][],
|
||||
rowIndex?: number,
|
||||
isHeader = false,
|
||||
): React.ReactNode => {
|
||||
const key = isHeader ? 'header' : `${rowIndex}`;
|
||||
const maxHeight = Math.max(...wrappedCells.map((lines) => lines.length), 1);
|
||||
|
||||
const visualRows: React.ReactNode[] = [];
|
||||
for (let i = 0; i < maxHeight; i++) {
|
||||
const visualRowCells = wrappedCells.map(
|
||||
(lines) => lines[i] || { text: '', width: 0 },
|
||||
);
|
||||
visualRows.push(
|
||||
<React.Fragment key={`${key}-${i}`}>
|
||||
{renderVisualRow(visualRowCells, isHeader)}
|
||||
</React.Fragment>,
|
||||
);
|
||||
}
|
||||
|
||||
return <React.Fragment key={rowIndex}>{visualRows}</React.Fragment>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
{/* Top border */}
|
||||
{renderBorder('top')}
|
||||
|
||||
{/* Header row */}
|
||||
{renderRow(headers, true)}
|
||||
{/*
|
||||
Header row
|
||||
Keep the rowIndex as -1 to differentiate from data rows
|
||||
*/}
|
||||
{renderDataRow(wrappedHeaders, -1, true)}
|
||||
|
||||
{/* Middle border */}
|
||||
{renderBorder('middle')}
|
||||
|
||||
{/* Data rows */}
|
||||
{rows.map((row, index) => (
|
||||
<React.Fragment key={index}>{renderRow(row)}</React.Fragment>
|
||||
))}
|
||||
{wrappedRows.map((row, index) => renderDataRow(row, index))}
|
||||
|
||||
{/* Bottom border */}
|
||||
{renderBorder('bottom')}
|
||||
|
||||
@@ -1,5 +1,83 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`TableRenderer > 'handles non-ASCII characters (emojis …' 1`] = `
|
||||
"
|
||||
┌──────────────┬────────────┬───────────────┐
|
||||
│ Emoji 😃 │ Asian 汉字 │ Mixed 🚀 Text │
|
||||
├──────────────┼────────────┼───────────────┤
|
||||
│ Start 🌟 End │ 你好世界 │ Rocket 🚀 Man │
|
||||
│ Thumbs 👍 Up │ こんにちは │ Fire 🔥 │
|
||||
└──────────────┴────────────┴───────────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > 'renders a table with mixed emojis, As…' 1`] = `
|
||||
"
|
||||
┌───────────────┬───────────────────┬────────────────┐
|
||||
│ Mixed 😃 中文 │ Complex 🚀 日本語 │ Text 📝 한국어 │
|
||||
├───────────────┼───────────────────┼────────────────┤
|
||||
│ 你好 😃 │ こんにちは 🚀 │ 안녕하세요 📝 │
|
||||
│ World 🌍 │ Code 💻 │ Pizza 🍕 │
|
||||
└───────────────┴───────────────────┴────────────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > 'renders a table with only Asian chara…' 1`] = `
|
||||
"
|
||||
┌──────────────┬─────────────────┬───────────────┐
|
||||
│ Chinese 中文 │ Japanese 日本語 │ Korean 한국어 │
|
||||
├──────────────┼─────────────────┼───────────────┤
|
||||
│ 你好 │ こんにちは │ 안녕하세요 │
|
||||
│ 世界 │ 世界 │ 세계 │
|
||||
└──────────────┴─────────────────┴───────────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > 'renders a table with only emojis and …' 1`] = `
|
||||
"
|
||||
┌──────────┬───────────┬──────────┐
|
||||
│ Happy 😀 │ Rocket 🚀 │ Heart ❤️ │
|
||||
├──────────┼───────────┼──────────┤
|
||||
│ Smile 😃 │ Fire 🔥 │ Love 💖 │
|
||||
│ Cool 😎 │ Star ⭐ │ Blue 💙 │
|
||||
└──────────┴───────────┴──────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > 'renders correctly when headers are em…' 1`] = `
|
||||
"
|
||||
┌────────┬────────┐
|
||||
│ │ │
|
||||
├────────┼────────┤
|
||||
│ Data 1 │ Data 2 │
|
||||
└────────┴────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > 'renders correctly when there are more…' 1`] = `
|
||||
"
|
||||
┌──────────┬──────────┬──────────┐
|
||||
│ Header 1 │ Header 2 │ Header 3 │
|
||||
├──────────┼──────────┼──────────┤
|
||||
│ Data 1 │ Data 2 │ │
|
||||
└──────────┴──────────┴──────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > handles wrapped bold headers without showing markers 1`] = `
|
||||
"
|
||||
┌─────────────┬───────┬─────────┐
|
||||
│ Very Long │ Short │ Another │
|
||||
│ Bold Header │ │ Long │
|
||||
│ That Will │ │ Header │
|
||||
│ Wrap │ │ │
|
||||
├─────────────┼───────┼─────────┤
|
||||
│ Data 1 │ Data │ Data 3 │
|
||||
│ │ 2 │ │
|
||||
└─────────────┴───────┴─────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > renders a 3x3 table correctly 1`] = `
|
||||
"
|
||||
┌──────────────┬──────────────┬──────────────┐
|
||||
@@ -12,14 +90,117 @@ exports[`TableRenderer > renders a 3x3 table correctly 1`] = `
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > renders a table with long headers and 4 columns correctly 1`] = `
|
||||
exports[`TableRenderer > renders a complex table with mixed content lengths correctly 1`] = `
|
||||
"
|
||||
┌──────────────────┬──────────────────┬───────────────────┬──────────────────┐
|
||||
│ Very Long Colum… │ Very Long Colum… │ Very Long Column… │ Very Long Colum… │
|
||||
├──────────────────┼──────────────────┼───────────────────┼──────────────────┤
|
||||
│ Data 1.1 │ Data 1.2 │ Data 1.3 │ Data 1.4 │
|
||||
│ Data 2.1 │ Data 2.2 │ Data 2.3 │ Data 2.4 │
|
||||
│ Data 3.1 │ Data 3.2 │ Data 3.3 │ Data 3.4 │
|
||||
└──────────────────┴──────────────────┴───────────────────┴──────────────────┘
|
||||
┌─────────────────────────────┬──────────────────────────────┬─────────────────────────────┬──────────────────────────────┬─────┬────────┬─────────┬───────┐
|
||||
│ Comprehensive Architectural │ Implementation Details for │ Longitudinal Performance │ Strategic Security Framework │ Key │ Status │ Version │ Owner │
|
||||
│ Specification for the │ the High-Throughput │ Analysis Across │ for Mitigating Sophisticated │ │ │ │ │
|
||||
│ Distributed Infrastructure │ Asynchronous Message │ Multi-Regional Cloud │ Cross-Site Scripting │ │ │ │ │
|
||||
│ Layer │ Processing Pipeline with │ Deployment Clusters │ Vulnerabilities │ │ │ │ │
|
||||
│ │ Extended Scalability │ │ │ │ │ │ │
|
||||
│ │ Features and Redundancy │ │ │ │ │ │ │
|
||||
│ │ Protocols │ │ │ │ │ │ │
|
||||
├─────────────────────────────┼──────────────────────────────┼─────────────────────────────┼──────────────────────────────┼─────┼────────┼─────────┼───────┤
|
||||
│ The primary architecture │ Each message is processed │ Historical data indicates a │ A multi-layered defense │ INF │ Active │ v2.4 │ J. │
|
||||
│ utilizes a decoupled │ through a series of │ significant reduction in │ strategy incorporates │ │ │ │ Doe │
|
||||
│ microservices approach, │ specialized workers that │ tail latency when utilizing │ content security policies, │ │ │ │ │
|
||||
│ leveraging container │ handle data transformation, │ edge computing nodes closer │ input sanitization │ │ │ │ │
|
||||
│ orchestration for │ validation, and persistent │ to the geographic location │ libraries, and regular │ │ │ │ │
|
||||
│ scalability and fault │ storage using a persistent │ of the end-user base. │ automated penetration │ │ │ │ │
|
||||
│ tolerance in high-load │ queue. │ │ testing routines. │ │ │ │ │
|
||||
│ scenarios. │ │ Monitoring tools have │ │ │ │ │ │
|
||||
│ │ The pipeline features │ captured a steady increase │ Developers are required to │ │ │ │ │
|
||||
│ This layer provides the │ built-in retry mechanisms │ in throughput efficiency │ undergo mandatory security │ │ │ │ │
|
||||
│ fundamental building blocks │ with exponential backoff to │ since the introduction of │ training focusing on the │ │ │ │ │
|
||||
│ for service discovery, load │ ensure message delivery │ the vectorized query engine │ OWASP Top Ten to ensure that │ │ │ │ │
|
||||
│ balancing, and │ integrity even during │ in the primary data │ security is integrated into │ │ │ │ │
|
||||
│ inter-service communication │ transient network or service │ warehouse. │ the initial design phase. │ │ │ │ │
|
||||
│ via highly efficient │ failures. │ │ │ │ │ │ │
|
||||
│ protocol buffers. │ │ Resource utilization │ The implementation of a │ │ │ │ │
|
||||
│ │ Horizontal autoscaling is │ metrics demonstrate that │ robust Identity and Access │ │ │ │ │
|
||||
│ Advanced telemetry and │ triggered automatically │ the transition to │ Management system ensures │ │ │ │ │
|
||||
│ logging integrations allow │ based on the depth of the │ serverless compute for │ that the principle of least │ │ │ │ │
|
||||
│ for real-time monitoring of │ processing queue, ensuring │ intermittent tasks has │ privilege is strictly │ │ │ │ │
|
||||
│ system health and rapid │ consistent performance │ resulted in a thirty │ enforced across all │ │ │ │ │
|
||||
│ identification of │ during unexpected traffic │ percent cost optimization. │ environments. │ │ │ │ │
|
||||
│ bottlenecks within the │ spikes. │ │ │ │ │ │ │
|
||||
│ service mesh. │ │ │ │ │ │ │ │
|
||||
└─────────────────────────────┴──────────────────────────────┴─────────────────────────────┴──────────────────────────────┴─────┴────────┴─────────┴───────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > renders a table with long headers and 4 columns correctly 1`] = `
|
||||
"
|
||||
┌───────────────┬───────────────┬──────────────────┬──────────────────┐
|
||||
│ Very Long │ Very Long │ Very Long Column │ Very Long Column │
|
||||
│ Column Header │ Column Header │ Header Three │ Header Four │
|
||||
│ One │ Two │ │ │
|
||||
├───────────────┼───────────────┼──────────────────┼──────────────────┤
|
||||
│ Data 1.1 │ Data 1.2 │ Data 1.3 │ Data 1.4 │
|
||||
│ Data 2.1 │ Data 2.2 │ Data 2.3 │ Data 2.4 │
|
||||
│ Data 3.1 │ Data 3.2 │ Data 3.3 │ Data 3.4 │
|
||||
└───────────────┴───────────────┴──────────────────┴──────────────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > strips bold markers from headers and renders them correctly 1`] = `
|
||||
"
|
||||
┌─────────────┬───────────────┬──────────────┐
|
||||
│ Bold Header │ Normal Header │ Another Bold │
|
||||
├─────────────┼───────────────┼──────────────┤
|
||||
│ Data 1 │ Data 2 │ Data 3 │
|
||||
└─────────────┴───────────────┴──────────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > wraps all long columns correctly 1`] = `
|
||||
"
|
||||
┌────────────────┬────────────────┬─────────────────┐
|
||||
│ Col 1 │ Col 2 │ Col 3 │
|
||||
├────────────────┼────────────────┼─────────────────┤
|
||||
│ This is a very │ This is also a │ And this is the │
|
||||
│ long text that │ very long text │ third long text │
|
||||
│ needs wrapping │ that needs │ that needs │
|
||||
│ in column 1 │ wrapping in │ wrapping in │
|
||||
│ │ column 2 │ column 3 │
|
||||
└────────────────┴────────────────┴─────────────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > wraps columns with punctuation correctly 1`] = `
|
||||
"
|
||||
┌───────────────────┬───────────────┬─────────────────┐
|
||||
│ Punctuation 1 │ Punctuation 2 │ Punctuation 3 │
|
||||
├───────────────────┼───────────────┼─────────────────┤
|
||||
│ Start. Stop. │ Semi; colon: │ At@ Hash# │
|
||||
│ Comma, separated. │ Pipe| Slash/ │ Dollar$ │
|
||||
│ Exclamation! │ Backslash\\ │ Percent% Caret^ │
|
||||
│ Question? │ │ Ampersand& │
|
||||
│ hyphen-ated │ │ Asterisk* │
|
||||
└───────────────────┴───────────────┴─────────────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > wraps long cell content correctly 1`] = `
|
||||
"
|
||||
┌───────┬─────────────────────────────┬───────┐
|
||||
│ Col 1 │ Col 2 │ Col 3 │
|
||||
├───────┼─────────────────────────────┼───────┤
|
||||
│ Short │ This is a very long cell │ Short │
|
||||
│ │ content that should wrap to │ │
|
||||
│ │ multiple lines │ │
|
||||
└───────┴─────────────────────────────┴───────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > wraps mixed long and short columns correctly 1`] = `
|
||||
"
|
||||
┌───────┬──────────────────────────┬────────┐
|
||||
│ Short │ Long │ Medium │
|
||||
├───────┼──────────────────────────┼────────┤
|
||||
│ Tiny │ This is a very long text │ Not so │
|
||||
│ │ that definitely needs to │ long │
|
||||
│ │ wrap to the next line │ │
|
||||
└───────┴──────────────────────────┴────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -64,6 +64,14 @@ export class TerminalCapabilityManager {
|
||||
this.instance = undefined;
|
||||
}
|
||||
|
||||
private static cleanupOnExit(): void {
|
||||
// don't bother catching errors since if one write
|
||||
// fails, the other probably will too
|
||||
disableKittyKeyboardProtocol();
|
||||
disableModifyOtherKeys();
|
||||
disableBracketedPasteMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects terminal capabilities (Kitty protocol support, terminal name,
|
||||
* background color).
|
||||
@@ -77,16 +85,12 @@ export class TerminalCapabilityManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanupOnExit = () => {
|
||||
// don't bother catching errors since if one write
|
||||
// fails, the other probably will too
|
||||
disableKittyKeyboardProtocol();
|
||||
disableModifyOtherKeys();
|
||||
disableBracketedPasteMode();
|
||||
};
|
||||
process.on('exit', cleanupOnExit);
|
||||
process.on('SIGTERM', cleanupOnExit);
|
||||
process.on('SIGINT', cleanupOnExit);
|
||||
process.off('exit', TerminalCapabilityManager.cleanupOnExit);
|
||||
process.off('SIGTERM', TerminalCapabilityManager.cleanupOnExit);
|
||||
process.off('SIGINT', TerminalCapabilityManager.cleanupOnExit);
|
||||
process.on('exit', TerminalCapabilityManager.cleanupOnExit);
|
||||
process.on('SIGTERM', TerminalCapabilityManager.cleanupOnExit);
|
||||
process.on('SIGINT', TerminalCapabilityManager.cleanupOnExit);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const originalRawMode = process.stdin.isRaw;
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { isITerm2, resetITerm2Cache, shouldUseEmoji } from './terminalUtils.js';
|
||||
|
||||
describe('terminalUtils', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('TERM_PROGRAM', '');
|
||||
vi.stubEnv('LC_ALL', '');
|
||||
vi.stubEnv('LC_CTYPE', '');
|
||||
vi.stubEnv('LANG', '');
|
||||
vi.stubEnv('TERM', '');
|
||||
resetITerm2Cache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('isITerm2', () => {
|
||||
it('should detect iTerm2 via TERM_PROGRAM', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
|
||||
expect(isITerm2()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if not iTerm2', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
expect(isITerm2()).toBe(false);
|
||||
});
|
||||
|
||||
it('should cache the result', () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
|
||||
expect(isITerm2()).toBe(true);
|
||||
|
||||
// Change env but should still be true due to cache
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
expect(isITerm2()).toBe(true);
|
||||
|
||||
resetITerm2Cache();
|
||||
expect(isITerm2()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldUseEmoji', () => {
|
||||
it('should return true when UTF-8 is supported', () => {
|
||||
vi.stubEnv('LANG', 'en_US.UTF-8');
|
||||
expect(shouldUseEmoji()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when utf8 (no hyphen) is supported', () => {
|
||||
vi.stubEnv('LANG', 'en_US.utf8');
|
||||
expect(shouldUseEmoji()).toBe(true);
|
||||
});
|
||||
|
||||
it('should check LC_ALL first', () => {
|
||||
vi.stubEnv('LC_ALL', 'en_US.UTF-8');
|
||||
vi.stubEnv('LANG', 'C');
|
||||
expect(shouldUseEmoji()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when UTF-8 is not supported', () => {
|
||||
vi.stubEnv('LANG', 'C');
|
||||
expect(shouldUseEmoji()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false on linux console (TERM=linux)', () => {
|
||||
vi.stubEnv('LANG', 'en_US.UTF-8');
|
||||
vi.stubEnv('TERM', 'linux');
|
||||
expect(shouldUseEmoji()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -43,25 +43,3 @@ export function isITerm2(): boolean {
|
||||
export function resetITerm2Cache(): void {
|
||||
cachedIsITerm2 = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the terminal likely supports emoji.
|
||||
*/
|
||||
export function shouldUseEmoji(): boolean {
|
||||
const locale = (
|
||||
process.env['LC_ALL'] ||
|
||||
process.env['LC_CTYPE'] ||
|
||||
process.env['LANG'] ||
|
||||
''
|
||||
).toLowerCase();
|
||||
const supportsUtf8 = locale.includes('utf-8') || locale.includes('utf8');
|
||||
if (!supportsUtf8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (process.env['TERM'] === 'linux') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -143,6 +143,13 @@ export function sanitizeForDisplay(str: string, maxLength?: number): string {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes escaped newline characters (e.g., "\\n") into actual newline characters.
|
||||
*/
|
||||
export function normalizeEscapedNewlines(value: string): string {
|
||||
return value.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n');
|
||||
}
|
||||
|
||||
const stringWidthCache = new LRUCache<string, number>(
|
||||
LRU_BUFFER_PERF_CACHE_LIMIT,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ActivityLogger, type NetworkLog } from './activityLogger.js';
|
||||
import type { ConsoleLogPayload } from '@google/gemini-cli-core';
|
||||
|
||||
describe('ActivityLogger', () => {
|
||||
let logger: ActivityLogger;
|
||||
|
||||
beforeEach(() => {
|
||||
logger = ActivityLogger.getInstance();
|
||||
logger.clearBufferedLogs();
|
||||
});
|
||||
|
||||
it('buffers the last 10 requests with all their events grouped', () => {
|
||||
// Emit 15 requests, each with an initial + response event
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const initial: NetworkLog = {
|
||||
id: `req-${i}`,
|
||||
timestamp: i * 2,
|
||||
method: 'GET',
|
||||
url: 'http://example.com',
|
||||
headers: {},
|
||||
pending: true,
|
||||
};
|
||||
logger.emitNetworkEvent(initial);
|
||||
logger.emitNetworkEvent({
|
||||
id: `req-${i}`,
|
||||
pending: false,
|
||||
response: {
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: 'ok',
|
||||
durationMs: 10,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const logs = logger.getBufferedLogs();
|
||||
// 10 requests * 2 events each = 20 events
|
||||
expect(logs.network.length).toBe(20);
|
||||
// Oldest kept should be req-5 (first 5 evicted)
|
||||
expect(logs.network[0].id).toBe('req-5');
|
||||
// Last should be req-14
|
||||
expect(logs.network[19].id).toBe('req-14');
|
||||
});
|
||||
|
||||
it('keeps all chunk events for a buffered request', () => {
|
||||
// One request with many chunks
|
||||
logger.emitNetworkEvent({
|
||||
id: 'chunked',
|
||||
timestamp: 1,
|
||||
method: 'POST',
|
||||
url: 'http://example.com',
|
||||
headers: {},
|
||||
pending: true,
|
||||
});
|
||||
for (let i = 0; i < 5; i++) {
|
||||
logger.emitNetworkEvent({
|
||||
id: 'chunked',
|
||||
pending: true,
|
||||
chunk: { index: i, data: `chunk-${i}`, timestamp: 2 + i },
|
||||
});
|
||||
}
|
||||
logger.emitNetworkEvent({
|
||||
id: 'chunked',
|
||||
pending: false,
|
||||
response: { status: 200, headers: {}, body: 'done', durationMs: 50 },
|
||||
});
|
||||
|
||||
const logs = logger.getBufferedLogs();
|
||||
// 1 initial + 5 chunks + 1 response = 7 events, all for 'chunked'
|
||||
expect(logs.network.length).toBe(7);
|
||||
expect(logs.network.every((l) => l.id === 'chunked')).toBe(true);
|
||||
});
|
||||
|
||||
it('buffers only the last 10 console logs', () => {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const log: ConsoleLogPayload = { content: `log-${i}`, type: 'log' };
|
||||
logger.logConsole(log);
|
||||
}
|
||||
|
||||
const logs = logger.getBufferedLogs();
|
||||
expect(logs.console.length).toBe(10);
|
||||
expect(logs.console[0].content).toBe('log-5');
|
||||
expect(logs.console[9].content).toBe('log-14');
|
||||
});
|
||||
|
||||
it('getBufferedLogs is non-destructive', () => {
|
||||
logger.logConsole({ content: 'test', type: 'log' });
|
||||
const first = logger.getBufferedLogs();
|
||||
const second = logger.getBufferedLogs();
|
||||
expect(first.console.length).toBe(1);
|
||||
expect(second.console.length).toBe(1);
|
||||
});
|
||||
|
||||
it('clearBufferedLogs empties both buffers', () => {
|
||||
logger.logConsole({ content: 'test', type: 'log' });
|
||||
logger.emitNetworkEvent({
|
||||
id: 'r1',
|
||||
timestamp: 1,
|
||||
method: 'GET',
|
||||
url: 'http://example.com',
|
||||
headers: {},
|
||||
});
|
||||
logger.clearBufferedLogs();
|
||||
const logs = logger.getBufferedLogs();
|
||||
expect(logs.console.length).toBe(0);
|
||||
expect(logs.network.length).toBe(0);
|
||||
});
|
||||
|
||||
it('drainBufferedLogs returns and clears atomically', () => {
|
||||
logger.logConsole({ content: 'drain-test', type: 'log' });
|
||||
logger.emitNetworkEvent({
|
||||
id: 'r1',
|
||||
timestamp: 1,
|
||||
method: 'GET',
|
||||
url: 'http://example.com',
|
||||
headers: {},
|
||||
});
|
||||
|
||||
const drained = logger.drainBufferedLogs();
|
||||
expect(drained.console.length).toBe(1);
|
||||
expect(drained.network.length).toBe(1);
|
||||
|
||||
// Buffer should now be empty
|
||||
const after = logger.getBufferedLogs();
|
||||
expect(after.console.length).toBe(0);
|
||||
expect(after.network.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -4,23 +4,31 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
|
||||
/* eslint-disable @typescript-eslint/no-this-alias */
|
||||
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import zlib from 'node:zlib';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { CoreEvent, coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
CoreEvent,
|
||||
coreEvents,
|
||||
debugLogger,
|
||||
type ConsoleLogPayload,
|
||||
type Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const ACTIVITY_ID_HEADER = 'x-activity-request-id';
|
||||
const MAX_BUFFER_SIZE = 100;
|
||||
|
||||
/** Type guard: Array.isArray doesn't narrow readonly arrays in TS 5.8 */
|
||||
function isHeaderRecord(
|
||||
h: http.OutgoingHttpHeaders | readonly string[],
|
||||
): h is http.OutgoingHttpHeaders {
|
||||
return !Array.isArray(h);
|
||||
}
|
||||
|
||||
export interface NetworkLog {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
@@ -43,6 +51,9 @@ export interface NetworkLog {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Partial update to an existing network log. */
|
||||
export type PartialNetworkLog = { id: string } & Partial<NetworkLog>;
|
||||
|
||||
/**
|
||||
* Capture utility for session activities (network and console).
|
||||
* Provides a stream of events that can be persisted for analysis or inspection.
|
||||
@@ -53,6 +64,14 @@ export class ActivityLogger extends EventEmitter {
|
||||
private requestStartTimes = new Map<string, number>();
|
||||
private networkLoggingEnabled = false;
|
||||
|
||||
private networkBufferMap = new Map<
|
||||
string,
|
||||
Array<NetworkLog | PartialNetworkLog>
|
||||
>();
|
||||
private networkBufferIds: string[] = [];
|
||||
private consoleBuffer: Array<ConsoleLogPayload & { timestamp: number }> = [];
|
||||
private readonly bufferLimit = 10;
|
||||
|
||||
static getInstance(): ActivityLogger {
|
||||
if (!ActivityLogger.instance) {
|
||||
ActivityLogger.instance = new ActivityLogger();
|
||||
@@ -73,6 +92,47 @@ export class ActivityLogger extends EventEmitter {
|
||||
return this.networkLoggingEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically returns and clears all buffered logs.
|
||||
* Prevents data loss from events emitted between get and clear.
|
||||
*/
|
||||
drainBufferedLogs(): {
|
||||
network: Array<NetworkLog | PartialNetworkLog>;
|
||||
console: Array<ConsoleLogPayload & { timestamp: number }>;
|
||||
} {
|
||||
const network: Array<NetworkLog | PartialNetworkLog> = [];
|
||||
for (const id of this.networkBufferIds) {
|
||||
const events = this.networkBufferMap.get(id);
|
||||
if (events) network.push(...events);
|
||||
}
|
||||
const console = [...this.consoleBuffer];
|
||||
this.networkBufferMap.clear();
|
||||
this.networkBufferIds = [];
|
||||
this.consoleBuffer = [];
|
||||
return { network, console };
|
||||
}
|
||||
|
||||
getBufferedLogs(): {
|
||||
network: Array<NetworkLog | PartialNetworkLog>;
|
||||
console: Array<ConsoleLogPayload & { timestamp: number }>;
|
||||
} {
|
||||
const network: Array<NetworkLog | PartialNetworkLog> = [];
|
||||
for (const id of this.networkBufferIds) {
|
||||
const events = this.networkBufferMap.get(id);
|
||||
if (events) network.push(...events);
|
||||
}
|
||||
return {
|
||||
network,
|
||||
console: [...this.consoleBuffer],
|
||||
};
|
||||
}
|
||||
|
||||
clearBufferedLogs(): void {
|
||||
this.networkBufferMap.clear();
|
||||
this.networkBufferIds = [];
|
||||
this.consoleBuffer = [];
|
||||
}
|
||||
|
||||
private stringifyHeaders(headers: unknown): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
if (!headers) return result;
|
||||
@@ -91,13 +151,15 @@ export class ActivityLogger extends EventEmitter {
|
||||
return result;
|
||||
}
|
||||
|
||||
private sanitizeNetworkLog(log: any): any {
|
||||
private sanitizeNetworkLog(
|
||||
log: NetworkLog | PartialNetworkLog,
|
||||
): NetworkLog | PartialNetworkLog {
|
||||
if (!log || typeof log !== 'object') return log;
|
||||
|
||||
const sanitized = { ...log };
|
||||
|
||||
// Sanitize request headers
|
||||
if (sanitized.headers) {
|
||||
if ('headers' in sanitized && sanitized.headers) {
|
||||
const headers = { ...sanitized.headers };
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (
|
||||
@@ -112,7 +174,7 @@ export class ActivityLogger extends EventEmitter {
|
||||
}
|
||||
|
||||
// Sanitize response headers
|
||||
if (sanitized.response?.headers) {
|
||||
if ('response' in sanitized && sanitized.response?.headers) {
|
||||
const resHeaders = { ...sanitized.response.headers };
|
||||
for (const key of Object.keys(resHeaders)) {
|
||||
if (['set-cookie'].includes(key.toLowerCase())) {
|
||||
@@ -125,8 +187,27 @@ export class ActivityLogger extends EventEmitter {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private safeEmitNetwork(payload: any) {
|
||||
this.emit('network', this.sanitizeNetworkLog(payload));
|
||||
/** @internal Emit a network event — public for testing only. */
|
||||
emitNetworkEvent(payload: NetworkLog | PartialNetworkLog) {
|
||||
this.safeEmitNetwork(payload);
|
||||
}
|
||||
|
||||
private safeEmitNetwork(payload: NetworkLog | PartialNetworkLog) {
|
||||
const sanitized = this.sanitizeNetworkLog(payload);
|
||||
const id = sanitized.id;
|
||||
|
||||
if (!this.networkBufferMap.has(id)) {
|
||||
this.networkBufferIds.push(id);
|
||||
this.networkBufferMap.set(id, []);
|
||||
// Evict oldest request group if over limit
|
||||
if (this.networkBufferIds.length > this.bufferLimit) {
|
||||
const evictId = this.networkBufferIds.shift()!;
|
||||
this.networkBufferMap.delete(evictId);
|
||||
}
|
||||
}
|
||||
this.networkBufferMap.get(id)!.push(sanitized);
|
||||
|
||||
this.emit('network', sanitized);
|
||||
}
|
||||
|
||||
enable() {
|
||||
@@ -147,8 +228,7 @@ export class ActivityLogger extends EventEmitter {
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(input as any).url;
|
||||
: input.url;
|
||||
if (url.includes('127.0.0.1') || url.includes('localhost'))
|
||||
return originalFetch(input, init);
|
||||
|
||||
@@ -277,57 +357,108 @@ export class ActivityLogger extends EventEmitter {
|
||||
}
|
||||
|
||||
private patchNodeHttp() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
const self = this;
|
||||
const originalRequest = http.request;
|
||||
const originalHttpsRequest = https.request;
|
||||
|
||||
const wrapRequest = (originalFn: any, args: any[], protocol: string) => {
|
||||
const options = args[0];
|
||||
const url =
|
||||
typeof options === 'string'
|
||||
? options
|
||||
: options.href ||
|
||||
`${protocol}//${options.hostname || options.host || 'localhost'}${options.path || '/'}`;
|
||||
const wrapRequest = (
|
||||
originalFn: typeof http.request,
|
||||
args: unknown[],
|
||||
protocol: string,
|
||||
) => {
|
||||
const firstArg = args[0];
|
||||
let options: http.RequestOptions | string | URL;
|
||||
if (typeof firstArg === 'string') {
|
||||
options = firstArg;
|
||||
} else if (firstArg instanceof URL) {
|
||||
options = firstArg;
|
||||
} else {
|
||||
options = (firstArg ?? {}) as http.RequestOptions;
|
||||
}
|
||||
|
||||
let url = '';
|
||||
if (typeof options === 'string') {
|
||||
url = options;
|
||||
} else if (options instanceof URL) {
|
||||
url = options.href;
|
||||
} else {
|
||||
// Some callers pass URL-like objects that include href
|
||||
const href =
|
||||
'href' in options && typeof options.href === 'string'
|
||||
? options.href
|
||||
: '';
|
||||
url =
|
||||
href ||
|
||||
`${protocol}//${options.hostname || options.host || 'localhost'}${options.path || '/'}`;
|
||||
}
|
||||
|
||||
if (url.includes('127.0.0.1') || url.includes('localhost'))
|
||||
return originalFn.apply(http, args);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
return originalFn.apply(http, args as any);
|
||||
|
||||
const headers =
|
||||
typeof options === 'object' && typeof options !== 'function'
|
||||
? (options as any).headers
|
||||
: {};
|
||||
if (headers && headers[ACTIVITY_ID_HEADER]) {
|
||||
const rawHeaders =
|
||||
typeof options === 'object' &&
|
||||
options !== null &&
|
||||
!(options instanceof URL)
|
||||
? options.headers
|
||||
: undefined;
|
||||
let headers: http.OutgoingHttpHeaders = {};
|
||||
if (rawHeaders && isHeaderRecord(rawHeaders)) {
|
||||
headers = rawHeaders;
|
||||
}
|
||||
|
||||
if (headers[ACTIVITY_ID_HEADER]) {
|
||||
delete headers[ACTIVITY_ID_HEADER];
|
||||
return originalFn.apply(http, args);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
return originalFn.apply(http, args as any);
|
||||
}
|
||||
|
||||
const id = Math.random().toString(36).substring(7);
|
||||
self.requestStartTimes.set(id, Date.now());
|
||||
const req = originalFn.apply(http, args);
|
||||
this.requestStartTimes.set(id, Date.now());
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
const req = originalFn.apply(http, args as any);
|
||||
const requestChunks: Buffer[] = [];
|
||||
|
||||
const oldWrite = req.write;
|
||||
const oldEnd = req.end;
|
||||
|
||||
req.write = function (chunk: any, ...etc: any[]) {
|
||||
req.write = function (chunk: unknown, ...etc: unknown[]) {
|
||||
if (chunk) {
|
||||
const encoding =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
typeof etc[0] === 'string' ? (etc[0] as BufferEncoding) : undefined;
|
||||
requestChunks.push(
|
||||
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding),
|
||||
Buffer.isBuffer(chunk)
|
||||
? chunk
|
||||
: typeof chunk === 'string'
|
||||
? Buffer.from(chunk, encoding)
|
||||
: Buffer.from(
|
||||
chunk instanceof Uint8Array ? chunk : String(chunk),
|
||||
),
|
||||
);
|
||||
}
|
||||
return oldWrite.apply(this, [chunk, ...etc]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
return oldWrite.apply(this, [chunk, ...etc] as any);
|
||||
};
|
||||
|
||||
req.end = function (this: any, chunk: any, ...etc: any[]) {
|
||||
req.end = function (
|
||||
this: http.ClientRequest,
|
||||
chunk: unknown,
|
||||
...etc: unknown[]
|
||||
) {
|
||||
if (chunk && typeof chunk !== 'function') {
|
||||
const encoding =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
typeof etc[0] === 'string' ? (etc[0] as BufferEncoding) : undefined;
|
||||
requestChunks.push(
|
||||
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding),
|
||||
Buffer.isBuffer(chunk)
|
||||
? chunk
|
||||
: typeof chunk === 'string'
|
||||
? Buffer.from(chunk, encoding)
|
||||
: Buffer.from(
|
||||
chunk instanceof Uint8Array ? chunk : String(chunk),
|
||||
),
|
||||
);
|
||||
}
|
||||
const body = Buffer.concat(requestChunks).toString('utf8');
|
||||
@@ -341,10 +472,11 @@ export class ActivityLogger extends EventEmitter {
|
||||
body,
|
||||
pending: true,
|
||||
});
|
||||
return oldEnd.apply(this, [chunk, ...etc]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
return (oldEnd as any).apply(this, [chunk, ...etc]);
|
||||
};
|
||||
|
||||
req.on('response', (res: any) => {
|
||||
req.on('response', (res: http.IncomingMessage) => {
|
||||
const responseChunks: Buffer[] = [];
|
||||
let chunkIndex = 0;
|
||||
|
||||
@@ -378,7 +510,7 @@ export class ActivityLogger extends EventEmitter {
|
||||
id,
|
||||
pending: false,
|
||||
response: {
|
||||
status: res.statusCode,
|
||||
status: res.statusCode || 0,
|
||||
headers: self.stringifyHeaders(res.headers),
|
||||
body: resBody,
|
||||
durationMs,
|
||||
@@ -400,23 +532,34 @@ export class ActivityLogger extends EventEmitter {
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err: any) => {
|
||||
req.on('error', (err: Error) => {
|
||||
self.requestStartTimes.delete(id);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
self.safeEmitNetwork({ id, pending: false, error: message });
|
||||
const message = err.message;
|
||||
self.safeEmitNetwork({
|
||||
id,
|
||||
pending: false,
|
||||
error: message,
|
||||
});
|
||||
});
|
||||
|
||||
return req;
|
||||
};
|
||||
|
||||
http.request = (...args: any[]) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
(http as any).request = (...args: unknown[]) =>
|
||||
wrapRequest(originalRequest, args, 'http:');
|
||||
https.request = (...args: any[]) =>
|
||||
wrapRequest(originalHttpsRequest, args, 'https:');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
(https as any).request = (...args: unknown[]) =>
|
||||
wrapRequest(originalHttpsRequest as typeof http.request, args, 'https:');
|
||||
}
|
||||
|
||||
logConsole(payload: unknown) {
|
||||
this.emit('console', payload);
|
||||
logConsole(payload: ConsoleLogPayload) {
|
||||
const enriched = { ...payload, timestamp: Date.now() };
|
||||
this.consoleBuffer.push(enriched);
|
||||
if (this.consoleBuffer.length > this.bufferLimit) {
|
||||
this.consoleBuffer.shift();
|
||||
}
|
||||
this.emit('console', enriched);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,7 +619,7 @@ function setupNetworkLogging(
|
||||
config: Config,
|
||||
onReconnectFailed?: () => void,
|
||||
) {
|
||||
const buffer: Array<Record<string, unknown>> = [];
|
||||
const transportBuffer: object[] = [];
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: NodeJS.Timeout | null = null;
|
||||
let sessionId: string | null = null;
|
||||
@@ -501,8 +644,21 @@ function setupNetworkLogging(
|
||||
|
||||
ws.on('message', (data: Buffer) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
handleServerMessage(message);
|
||||
const parsed: unknown = JSON.parse(data.toString());
|
||||
if (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
'type' in parsed &&
|
||||
typeof parsed.type === 'string'
|
||||
) {
|
||||
handleServerMessage({
|
||||
type: parsed.type,
|
||||
sessionId:
|
||||
'sessionId' in parsed && typeof parsed.sessionId === 'string'
|
||||
? parsed.sessionId
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
debugLogger.debug('Invalid WebSocket message:', err);
|
||||
}
|
||||
@@ -523,10 +679,13 @@ function setupNetworkLogging(
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerMessage = (message: any) => {
|
||||
const handleServerMessage = (message: {
|
||||
type: string;
|
||||
sessionId?: string;
|
||||
}) => {
|
||||
switch (message.type) {
|
||||
case 'registered':
|
||||
sessionId = message.sessionId;
|
||||
sessionId = message.sessionId || null;
|
||||
debugLogger.debug(`WebSocket session registered: ${sessionId}`);
|
||||
|
||||
// Start ping interval
|
||||
@@ -549,13 +708,13 @@ function setupNetworkLogging(
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = (message: any) => {
|
||||
const sendMessage = (message: object) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
};
|
||||
|
||||
const sendToNetwork = (type: 'console' | 'network', payload: unknown) => {
|
||||
const sendToNetwork = (type: 'console' | 'network', payload: object) => {
|
||||
const message = {
|
||||
type,
|
||||
payload,
|
||||
@@ -569,8 +728,8 @@ function setupNetworkLogging(
|
||||
ws.readyState !== WebSocket.OPEN ||
|
||||
!capture.isNetworkLoggingEnabled()
|
||||
) {
|
||||
buffer.push(message);
|
||||
if (buffer.length > MAX_BUFFER_SIZE) buffer.shift();
|
||||
transportBuffer.push(message);
|
||||
if (transportBuffer.length > MAX_BUFFER_SIZE) transportBuffer.shift();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -586,9 +745,39 @@ function setupNetworkLogging(
|
||||
return;
|
||||
}
|
||||
|
||||
debugLogger.debug(`Flushing ${buffer.length} buffered logs...`);
|
||||
while (buffer.length > 0) {
|
||||
const message = buffer.shift()!;
|
||||
const { network, console: consoleLogs } = capture.drainBufferedLogs();
|
||||
const allInitialLogs: Array<{
|
||||
type: 'network' | 'console';
|
||||
payload: object;
|
||||
timestamp: number;
|
||||
}> = [
|
||||
...network.map((l) => ({
|
||||
type: 'network' as const,
|
||||
payload: l,
|
||||
timestamp: 'timestamp' in l && l.timestamp ? l.timestamp : Date.now(),
|
||||
})),
|
||||
...consoleLogs.map((l) => ({
|
||||
type: 'console' as const,
|
||||
payload: l,
|
||||
timestamp: l.timestamp,
|
||||
})),
|
||||
].sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
debugLogger.debug(
|
||||
`Flushing ${allInitialLogs.length} initial buffered logs and ${transportBuffer.length} transport buffered logs...`,
|
||||
);
|
||||
|
||||
for (const log of allInitialLogs) {
|
||||
sendMessage({
|
||||
type: log.type,
|
||||
payload: log.payload,
|
||||
sessionId: sessionId || config.getSessionId(),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
while (transportBuffer.length > 0) {
|
||||
const message = transportBuffer.shift()!;
|
||||
sendMessage(message);
|
||||
}
|
||||
};
|
||||
@@ -625,6 +814,7 @@ function setupNetworkLogging(
|
||||
|
||||
capture.on('console', (payload) => sendToNetwork('console', payload));
|
||||
capture.on('network', (payload) => sendToNetwork('network', payload));
|
||||
|
||||
capture.on('network-logging-enabled', () => {
|
||||
debugLogger.debug('Network logging enabled, flushing buffer...');
|
||||
flushBuffer();
|
||||
@@ -666,7 +856,8 @@ export function initActivityLogger(
|
||||
port: number;
|
||||
onReconnectFailed?: () => void;
|
||||
}
|
||||
| { mode: 'file'; filePath?: string },
|
||||
| { mode: 'file'; filePath?: string }
|
||||
| { mode: 'buffer' },
|
||||
): void {
|
||||
const capture = ActivityLogger.getInstance();
|
||||
capture.enable();
|
||||
@@ -680,9 +871,10 @@ export function initActivityLogger(
|
||||
options.onReconnectFailed,
|
||||
);
|
||||
capture.enableNetworkLogging();
|
||||
} else {
|
||||
} else if (options.mode === 'file') {
|
||||
setupFileLogging(capture, config, options.filePath);
|
||||
}
|
||||
// buffer mode: no transport, just intercept + bridge
|
||||
|
||||
bridgeCoreEvents(capture);
|
||||
}
|
||||
|
||||
@@ -56,17 +56,34 @@ const mockDevToolsInstance = vi.hoisted(() => ({
|
||||
getPort: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockActivityLoggerInstance = vi.hoisted(() => ({
|
||||
disableNetworkLogging: vi.fn(),
|
||||
enableNetworkLogging: vi.fn(),
|
||||
drainBufferedLogs: vi.fn().mockReturnValue({ network: [], console: [] }),
|
||||
}));
|
||||
|
||||
vi.mock('./activityLogger.js', () => ({
|
||||
initActivityLogger: mockInitActivityLogger,
|
||||
addNetworkTransport: mockAddNetworkTransport,
|
||||
ActivityLogger: {
|
||||
getInstance: () => mockActivityLoggerInstance,
|
||||
},
|
||||
}));
|
||||
|
||||
const mockShouldLaunchBrowser = vi.hoisted(() => vi.fn(() => true));
|
||||
const mockOpenBrowserSecurely = vi.hoisted(() =>
|
||||
vi.fn(() => Promise.resolve()),
|
||||
);
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
debugLogger: {
|
||||
log: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
shouldLaunchBrowser: mockShouldLaunchBrowser,
|
||||
openBrowserSecurely: mockOpenBrowserSecurely,
|
||||
}));
|
||||
|
||||
vi.mock('ws', () => ({
|
||||
@@ -80,7 +97,12 @@ vi.mock('gemini-cli-devtools', () => ({
|
||||
}));
|
||||
|
||||
// --- Import under test (after mocks) ---
|
||||
import { registerActivityLogger, resetForTesting } from './devtoolsService.js';
|
||||
import {
|
||||
setupInitialActivityLogger,
|
||||
startDevToolsServer,
|
||||
toggleDevToolsPanel,
|
||||
resetForTesting,
|
||||
} from './devtoolsService.js';
|
||||
|
||||
function createMockConfig(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -100,104 +122,218 @@ describe('devtoolsService', () => {
|
||||
delete process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'];
|
||||
});
|
||||
|
||||
describe('registerActivityLogger', () => {
|
||||
it('connects to existing DevTools server when probe succeeds', async () => {
|
||||
describe('setupInitialActivityLogger', () => {
|
||||
it('stays in buffer mode when no existing server found', async () => {
|
||||
const config = createMockConfig();
|
||||
const promise = setupInitialActivityLogger(config);
|
||||
|
||||
// The probe WebSocket will succeed
|
||||
const promise = registerActivityLogger(config);
|
||||
// Probe fires immediately — no server running
|
||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
|
||||
// Wait for WebSocket to be created
|
||||
await vi.waitFor(() => {
|
||||
expect(MockWebSocket.instances.length).toBe(1);
|
||||
await promise;
|
||||
|
||||
expect(mockInitActivityLogger).toHaveBeenCalledWith(config, {
|
||||
mode: 'buffer',
|
||||
});
|
||||
expect(mockAddNetworkTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Simulate probe success
|
||||
it('attaches transport when existing server found at startup', async () => {
|
||||
const config = createMockConfig();
|
||||
const promise = setupInitialActivityLogger(config);
|
||||
|
||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockInitActivityLogger).toHaveBeenCalledWith(config, {
|
||||
mode: 'network',
|
||||
host: '127.0.0.1',
|
||||
port: 25417,
|
||||
onReconnectFailed: expect.any(Function),
|
||||
mode: 'buffer',
|
||||
});
|
||||
expect(mockAddNetworkTransport).toHaveBeenCalledWith(
|
||||
config,
|
||||
'127.0.0.1',
|
||||
25417,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(
|
||||
mockActivityLoggerInstance.enableNetworkLogging,
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts new DevTools server when probe fails', async () => {
|
||||
it('F12 short-circuits when startup already connected', async () => {
|
||||
const config = createMockConfig();
|
||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
|
||||
mockDevToolsInstance.getPort.mockReturnValue(25417);
|
||||
|
||||
const promise = registerActivityLogger(config);
|
||||
// Startup: probe succeeds
|
||||
const setupPromise = setupInitialActivityLogger(config);
|
||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
await setupPromise;
|
||||
|
||||
// Wait for probe WebSocket
|
||||
await vi.waitFor(() => {
|
||||
expect(MockWebSocket.instances.length).toBe(1);
|
||||
});
|
||||
mockAddNetworkTransport.mockClear();
|
||||
mockActivityLoggerInstance.enableNetworkLogging.mockClear();
|
||||
|
||||
// Simulate probe failure
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
// F12: should return URL immediately
|
||||
const url = await startDevToolsServer(config);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockDevToolsInstance.start).toHaveBeenCalled();
|
||||
expect(mockInitActivityLogger).toHaveBeenCalledWith(config, {
|
||||
mode: 'network',
|
||||
host: '127.0.0.1',
|
||||
port: 25417,
|
||||
onReconnectFailed: expect.any(Function),
|
||||
});
|
||||
expect(url).toBe('http://localhost:25417');
|
||||
expect(mockAddNetworkTransport).not.toHaveBeenCalled();
|
||||
expect(mockDevToolsInstance.start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to file mode when target env var is set', async () => {
|
||||
it('initializes in file mode when target env var is set', async () => {
|
||||
process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'] = '/tmp/test.jsonl';
|
||||
const config = createMockConfig();
|
||||
|
||||
await registerActivityLogger(config);
|
||||
await setupInitialActivityLogger(config);
|
||||
|
||||
expect(mockInitActivityLogger).toHaveBeenCalledWith(config, {
|
||||
mode: 'file',
|
||||
filePath: '/tmp/test.jsonl',
|
||||
});
|
||||
// No probe attempted
|
||||
expect(MockWebSocket.instances.length).toBe(0);
|
||||
});
|
||||
|
||||
it('does nothing in file mode when config.storage is missing', async () => {
|
||||
process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'] = '/tmp/test.jsonl';
|
||||
const config = createMockConfig({ storage: undefined });
|
||||
|
||||
await registerActivityLogger(config);
|
||||
await setupInitialActivityLogger(config);
|
||||
|
||||
expect(mockInitActivityLogger).not.toHaveBeenCalled();
|
||||
expect(MockWebSocket.instances.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startDevToolsServer', () => {
|
||||
it('starts new server when none exists and enables logging', async () => {
|
||||
const config = createMockConfig();
|
||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
|
||||
mockDevToolsInstance.getPort.mockReturnValue(25417);
|
||||
|
||||
const promise = startDevToolsServer(config);
|
||||
|
||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
|
||||
const url = await promise;
|
||||
|
||||
expect(url).toBe('http://localhost:25417');
|
||||
expect(mockAddNetworkTransport).toHaveBeenCalledWith(
|
||||
config,
|
||||
'127.0.0.1',
|
||||
25417,
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(
|
||||
mockActivityLoggerInstance.enableNetworkLogging,
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to file logging when DevTools start fails', async () => {
|
||||
it('connects to existing server if one is found', async () => {
|
||||
const config = createMockConfig();
|
||||
|
||||
const promise = startDevToolsServer(config);
|
||||
|
||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
|
||||
const url = await promise;
|
||||
|
||||
expect(url).toBe('http://localhost:25417');
|
||||
expect(mockAddNetworkTransport).toHaveBeenCalled();
|
||||
expect(
|
||||
mockActivityLoggerInstance.enableNetworkLogging,
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deduplicates concurrent calls (returns same promise)', async () => {
|
||||
const config = createMockConfig();
|
||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
|
||||
mockDevToolsInstance.getPort.mockReturnValue(25417);
|
||||
|
||||
const promise1 = startDevToolsServer(config);
|
||||
const promise2 = startDevToolsServer(config);
|
||||
|
||||
expect(promise1).toBe(promise2);
|
||||
|
||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
|
||||
const [url1, url2] = await Promise.all([promise1, promise2]);
|
||||
expect(url1).toBe('http://localhost:25417');
|
||||
expect(url2).toBe('http://localhost:25417');
|
||||
// Only one probe + one server start
|
||||
expect(mockAddNetworkTransport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws when DevTools server fails to start', async () => {
|
||||
const config = createMockConfig();
|
||||
mockDevToolsInstance.start.mockRejectedValue(
|
||||
new Error('MODULE_NOT_FOUND'),
|
||||
);
|
||||
|
||||
const promise = registerActivityLogger(config);
|
||||
const promise = startDevToolsServer(config);
|
||||
|
||||
// Wait for probe WebSocket
|
||||
await vi.waitFor(() => {
|
||||
expect(MockWebSocket.instances.length).toBe(1);
|
||||
});
|
||||
|
||||
// Probe fails → tries to start server → server start fails → file fallback
|
||||
// Probe fails first
|
||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockInitActivityLogger).toHaveBeenCalledWith(config, {
|
||||
mode: 'file',
|
||||
filePath: undefined,
|
||||
});
|
||||
await expect(promise).rejects.toThrow('MODULE_NOT_FOUND');
|
||||
expect(mockAddNetworkTransport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows retry after server start failure', async () => {
|
||||
const config = createMockConfig();
|
||||
mockDevToolsInstance.start.mockRejectedValueOnce(
|
||||
new Error('MODULE_NOT_FOUND'),
|
||||
);
|
||||
|
||||
const promise1 = startDevToolsServer(config);
|
||||
|
||||
// Probe fails
|
||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
|
||||
await expect(promise1).rejects.toThrow('MODULE_NOT_FOUND');
|
||||
|
||||
// Second attempt should work (not return the cached rejected promise)
|
||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
|
||||
mockDevToolsInstance.getPort.mockReturnValue(25417);
|
||||
|
||||
const promise2 = startDevToolsServer(config);
|
||||
|
||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(2));
|
||||
MockWebSocket.instances[1].simulateError();
|
||||
|
||||
const url = await promise2;
|
||||
expect(url).toBe('http://localhost:25417');
|
||||
expect(mockAddNetworkTransport).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('short-circuits on second F12 after successful start', async () => {
|
||||
const config = createMockConfig();
|
||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
|
||||
mockDevToolsInstance.getPort.mockReturnValue(25417);
|
||||
|
||||
const promise1 = startDevToolsServer(config);
|
||||
|
||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
|
||||
const url1 = await promise1;
|
||||
expect(url1).toBe('http://localhost:25417');
|
||||
|
||||
mockAddNetworkTransport.mockClear();
|
||||
mockDevToolsInstance.start.mockClear();
|
||||
|
||||
// Second call should short-circuit via connectedUrl
|
||||
const url2 = await startDevToolsServer(config);
|
||||
expect(url2).toBe('http://localhost:25417');
|
||||
expect(mockAddNetworkTransport).not.toHaveBeenCalled();
|
||||
expect(mockDevToolsInstance.start).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startOrJoinDevTools (via registerActivityLogger)', () => {
|
||||
it('stops own server and connects to existing when losing port race', async () => {
|
||||
const config = createMockConfig();
|
||||
|
||||
@@ -205,7 +341,7 @@ describe('devtoolsService', () => {
|
||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25418');
|
||||
mockDevToolsInstance.getPort.mockReturnValue(25418);
|
||||
|
||||
const promise = registerActivityLogger(config);
|
||||
const promise = startDevToolsServer(config);
|
||||
|
||||
// First: probe for existing server (fails)
|
||||
await vi.waitFor(() => {
|
||||
@@ -220,16 +356,15 @@ describe('devtoolsService', () => {
|
||||
// Winner is alive
|
||||
MockWebSocket.instances[1].simulateOpen();
|
||||
|
||||
await promise;
|
||||
const url = await promise;
|
||||
|
||||
expect(mockDevToolsInstance.stop).toHaveBeenCalled();
|
||||
expect(mockInitActivityLogger).toHaveBeenCalledWith(
|
||||
expect(url).toBe('http://localhost:25417');
|
||||
expect(mockAddNetworkTransport).toHaveBeenCalledWith(
|
||||
config,
|
||||
expect.objectContaining({
|
||||
mode: 'network',
|
||||
host: '127.0.0.1',
|
||||
port: 25417, // connected to winner's port
|
||||
}),
|
||||
'127.0.0.1',
|
||||
25417,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -239,7 +374,7 @@ describe('devtoolsService', () => {
|
||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25418');
|
||||
mockDevToolsInstance.getPort.mockReturnValue(25418);
|
||||
|
||||
const promise = registerActivityLogger(config);
|
||||
const promise = startDevToolsServer(config);
|
||||
|
||||
// Probe for existing (fails)
|
||||
await vi.waitFor(() => {
|
||||
@@ -253,27 +388,27 @@ describe('devtoolsService', () => {
|
||||
});
|
||||
MockWebSocket.instances[1].simulateError();
|
||||
|
||||
await promise;
|
||||
const url = await promise;
|
||||
|
||||
expect(mockDevToolsInstance.stop).not.toHaveBeenCalled();
|
||||
expect(mockInitActivityLogger).toHaveBeenCalledWith(
|
||||
expect(url).toBe('http://localhost:25418');
|
||||
expect(mockAddNetworkTransport).toHaveBeenCalledWith(
|
||||
config,
|
||||
expect.objectContaining({
|
||||
mode: 'network',
|
||||
port: 25418, // kept own port
|
||||
}),
|
||||
'127.0.0.1',
|
||||
25418,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePromotion (via onReconnectFailed)', () => {
|
||||
describe('handlePromotion (via startDevToolsServer)', () => {
|
||||
it('caps promotion attempts at MAX_PROMOTION_ATTEMPTS', async () => {
|
||||
const config = createMockConfig();
|
||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
|
||||
mockDevToolsInstance.getPort.mockReturnValue(25417);
|
||||
|
||||
// First: set up the logger so we can grab onReconnectFailed
|
||||
const promise = registerActivityLogger(config);
|
||||
const promise = startDevToolsServer(config);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(MockWebSocket.instances.length).toBe(1);
|
||||
@@ -283,8 +418,8 @@ describe('devtoolsService', () => {
|
||||
await promise;
|
||||
|
||||
// Extract onReconnectFailed callback
|
||||
const initCall = mockInitActivityLogger.mock.calls[0];
|
||||
const onReconnectFailed = initCall[1].onReconnectFailed;
|
||||
const initCall = mockAddNetworkTransport.mock.calls[0];
|
||||
const onReconnectFailed = initCall[3];
|
||||
expect(onReconnectFailed).toBeDefined();
|
||||
|
||||
// Trigger promotion MAX_PROMOTION_ATTEMPTS + 1 times
|
||||
@@ -300,4 +435,98 @@ describe('devtoolsService', () => {
|
||||
expect(mockAddNetworkTransport).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleDevToolsPanel', () => {
|
||||
it('calls toggle (to close) when already open', async () => {
|
||||
const config = createMockConfig();
|
||||
const toggle = vi.fn();
|
||||
const setOpen = vi.fn();
|
||||
|
||||
const promise = toggleDevToolsPanel(config, true, toggle, setOpen);
|
||||
await promise;
|
||||
|
||||
expect(toggle).toHaveBeenCalledTimes(1);
|
||||
expect(setOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT call toggle or setOpen when browser opens successfully', async () => {
|
||||
const config = createMockConfig();
|
||||
const toggle = vi.fn();
|
||||
const setOpen = vi.fn();
|
||||
|
||||
mockShouldLaunchBrowser.mockReturnValue(true);
|
||||
mockOpenBrowserSecurely.mockResolvedValue(undefined);
|
||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
|
||||
mockDevToolsInstance.getPort.mockReturnValue(25417);
|
||||
|
||||
const promise = toggleDevToolsPanel(config, false, toggle, setOpen);
|
||||
|
||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
|
||||
await promise;
|
||||
|
||||
expect(toggle).not.toHaveBeenCalled();
|
||||
expect(setOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls setOpen when browser fails to open', async () => {
|
||||
const config = createMockConfig();
|
||||
const toggle = vi.fn();
|
||||
const setOpen = vi.fn();
|
||||
|
||||
mockShouldLaunchBrowser.mockReturnValue(true);
|
||||
mockOpenBrowserSecurely.mockRejectedValue(new Error('no browser'));
|
||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
|
||||
mockDevToolsInstance.getPort.mockReturnValue(25417);
|
||||
|
||||
const promise = toggleDevToolsPanel(config, false, toggle, setOpen);
|
||||
|
||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
|
||||
await promise;
|
||||
|
||||
expect(toggle).not.toHaveBeenCalled();
|
||||
expect(setOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls setOpen when shouldLaunchBrowser returns false', async () => {
|
||||
const config = createMockConfig();
|
||||
const toggle = vi.fn();
|
||||
const setOpen = vi.fn();
|
||||
|
||||
mockShouldLaunchBrowser.mockReturnValue(false);
|
||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
|
||||
mockDevToolsInstance.getPort.mockReturnValue(25417);
|
||||
|
||||
const promise = toggleDevToolsPanel(config, false, toggle, setOpen);
|
||||
|
||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
|
||||
await promise;
|
||||
|
||||
expect(toggle).not.toHaveBeenCalled();
|
||||
expect(setOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls setOpen when DevTools server fails to start', async () => {
|
||||
const config = createMockConfig();
|
||||
const toggle = vi.fn();
|
||||
const setOpen = vi.fn();
|
||||
|
||||
mockDevToolsInstance.start.mockRejectedValue(new Error('fail'));
|
||||
|
||||
const promise = toggleDevToolsPanel(config, false, toggle, setOpen);
|
||||
|
||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
|
||||
await promise;
|
||||
|
||||
expect(toggle).not.toHaveBeenCalled();
|
||||
expect(setOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import WebSocket from 'ws';
|
||||
import { initActivityLogger, addNetworkTransport } from './activityLogger.js';
|
||||
import {
|
||||
initActivityLogger,
|
||||
addNetworkTransport,
|
||||
ActivityLogger,
|
||||
} from './activityLogger.js';
|
||||
|
||||
interface IDevTools {
|
||||
start(): Promise<string>;
|
||||
@@ -20,6 +24,8 @@ const DEFAULT_DEVTOOLS_PORT = 25417;
|
||||
const DEFAULT_DEVTOOLS_HOST = '127.0.0.1';
|
||||
const MAX_PROMOTION_ATTEMPTS = 3;
|
||||
let promotionAttempts = 0;
|
||||
let serverStartPromise: Promise<string> | null = null;
|
||||
let connectedUrl: string | null = null;
|
||||
|
||||
/**
|
||||
* Probe whether a DevTools server is already listening on the given host:port.
|
||||
@@ -110,70 +116,145 @@ async function handlePromotion(config: Config) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the activity logger.
|
||||
* Captures network and console logs via DevTools WebSocket or to a file.
|
||||
*
|
||||
* Environment variable GEMINI_CLI_ACTIVITY_LOG_TARGET controls the output:
|
||||
* - file path (e.g., "/tmp/logs.jsonl") → file mode
|
||||
* - not set → auto-start DevTools (reuses existing instance if already running)
|
||||
*
|
||||
* @param config The CLI configuration
|
||||
* Initializes the activity logger.
|
||||
* Interception starts immediately in buffering mode.
|
||||
* If an existing DevTools server is found, attaches transport eagerly.
|
||||
*/
|
||||
export async function registerActivityLogger(config: Config) {
|
||||
export async function setupInitialActivityLogger(config: Config) {
|
||||
const target = process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'];
|
||||
|
||||
if (!target) {
|
||||
// No explicit target: try connecting to existing DevTools, then start new one
|
||||
const onReconnectFailed = () => handlePromotion(config);
|
||||
if (target) {
|
||||
if (!config.storage) return;
|
||||
initActivityLogger(config, { mode: 'file', filePath: target });
|
||||
} else {
|
||||
// Start in buffering mode (no transport attached yet)
|
||||
initActivityLogger(config, { mode: 'buffer' });
|
||||
|
||||
// Probe for an existing DevTools server
|
||||
const existing = await probeDevTools(
|
||||
DEFAULT_DEVTOOLS_HOST,
|
||||
DEFAULT_DEVTOOLS_PORT,
|
||||
);
|
||||
if (existing) {
|
||||
debugLogger.log(
|
||||
`DevTools (existing) at: http://${DEFAULT_DEVTOOLS_HOST}:${DEFAULT_DEVTOOLS_PORT}`,
|
||||
// Eagerly probe for an existing DevTools server
|
||||
try {
|
||||
const existing = await probeDevTools(
|
||||
DEFAULT_DEVTOOLS_HOST,
|
||||
DEFAULT_DEVTOOLS_PORT,
|
||||
);
|
||||
initActivityLogger(config, {
|
||||
mode: 'network',
|
||||
host: DEFAULT_DEVTOOLS_HOST,
|
||||
port: DEFAULT_DEVTOOLS_PORT,
|
||||
onReconnectFailed,
|
||||
});
|
||||
return;
|
||||
if (existing) {
|
||||
const onReconnectFailed = () => handlePromotion(config);
|
||||
addNetworkTransport(
|
||||
config,
|
||||
DEFAULT_DEVTOOLS_HOST,
|
||||
DEFAULT_DEVTOOLS_PORT,
|
||||
onReconnectFailed,
|
||||
);
|
||||
ActivityLogger.getInstance().enableNetworkLogging();
|
||||
connectedUrl = `http://localhost:${DEFAULT_DEVTOOLS_PORT}`;
|
||||
debugLogger.log(`DevTools (existing) at startup: ${connectedUrl}`);
|
||||
}
|
||||
} catch {
|
||||
// Probe failed silently — stay in buffer mode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the DevTools server and opens the UI in the browser.
|
||||
* Returns the URL to the DevTools UI.
|
||||
* Deduplicates concurrent calls — returns the same promise if already in flight.
|
||||
*/
|
||||
export function startDevToolsServer(config: Config): Promise<string> {
|
||||
if (connectedUrl) return Promise.resolve(connectedUrl);
|
||||
if (serverStartPromise) return serverStartPromise;
|
||||
serverStartPromise = startDevToolsServerImpl(config).catch((err) => {
|
||||
serverStartPromise = null;
|
||||
throw err;
|
||||
});
|
||||
return serverStartPromise;
|
||||
}
|
||||
|
||||
async function startDevToolsServerImpl(config: Config): Promise<string> {
|
||||
const onReconnectFailed = () => handlePromotion(config);
|
||||
|
||||
// Probe for an existing DevTools server
|
||||
const existing = await probeDevTools(
|
||||
DEFAULT_DEVTOOLS_HOST,
|
||||
DEFAULT_DEVTOOLS_PORT,
|
||||
);
|
||||
|
||||
let host = DEFAULT_DEVTOOLS_HOST;
|
||||
let port = DEFAULT_DEVTOOLS_PORT;
|
||||
|
||||
if (existing) {
|
||||
debugLogger.log(
|
||||
`DevTools (existing) at: http://${DEFAULT_DEVTOOLS_HOST}:${DEFAULT_DEVTOOLS_PORT}`,
|
||||
);
|
||||
} else {
|
||||
// No existing server — start (or join if we lose the race)
|
||||
try {
|
||||
const result = await startOrJoinDevTools(
|
||||
DEFAULT_DEVTOOLS_HOST,
|
||||
DEFAULT_DEVTOOLS_PORT,
|
||||
);
|
||||
initActivityLogger(config, {
|
||||
mode: 'network',
|
||||
host: result.host,
|
||||
port: result.port,
|
||||
onReconnectFailed,
|
||||
});
|
||||
return;
|
||||
host = result.host;
|
||||
port = result.port;
|
||||
} catch (err) {
|
||||
debugLogger.debug(
|
||||
'Failed to start DevTools, falling back to file logging:',
|
||||
err,
|
||||
);
|
||||
debugLogger.debug('Failed to start DevTools:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// File mode fallback
|
||||
if (!config.storage) {
|
||||
// Promote the activity logger to use the network transport
|
||||
addNetworkTransport(config, host, port, onReconnectFailed);
|
||||
const capture = ActivityLogger.getInstance();
|
||||
capture.enableNetworkLogging();
|
||||
|
||||
const url = `http://localhost:${port}`;
|
||||
connectedUrl = url;
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the F12 key toggle for the DevTools panel.
|
||||
* Starts the DevTools server, attempts to open the browser.
|
||||
* If the panel is already open, it closes it.
|
||||
* If the panel is closed:
|
||||
* - Attempts to open the browser.
|
||||
* - If browser opening is successful, the panel remains closed.
|
||||
* - If browser opening fails or is not possible, the panel is opened.
|
||||
*/
|
||||
export async function toggleDevToolsPanel(
|
||||
config: Config,
|
||||
isOpen: boolean,
|
||||
toggle: () => void,
|
||||
setOpen: () => void,
|
||||
): Promise<void> {
|
||||
if (isOpen) {
|
||||
toggle();
|
||||
return;
|
||||
}
|
||||
|
||||
initActivityLogger(config, { mode: 'file', filePath: target });
|
||||
try {
|
||||
const { openBrowserSecurely, shouldLaunchBrowser } = await import(
|
||||
'@google/gemini-cli-core'
|
||||
);
|
||||
const url = await startDevToolsServer(config);
|
||||
if (shouldLaunchBrowser()) {
|
||||
try {
|
||||
await openBrowserSecurely(url);
|
||||
// Browser opened successfully, don't open drawer.
|
||||
return;
|
||||
} catch (e) {
|
||||
debugLogger.warn('Failed to open browser securely:', e);
|
||||
}
|
||||
}
|
||||
// If we can't launch browser or it failed, open drawer.
|
||||
setOpen();
|
||||
} catch (e) {
|
||||
setOpen();
|
||||
debugLogger.error('Failed to start DevTools server:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset module-level state — test only. */
|
||||
export function resetForTesting() {
|
||||
promotionAttempts = 0;
|
||||
serverStartPromise = null;
|
||||
connectedUrl = null;
|
||||
}
|
||||
|
||||
@@ -162,8 +162,11 @@ export async function start_sandbox(
|
||||
process.kill(-proxyProcess.pid, 'SIGTERM');
|
||||
}
|
||||
};
|
||||
process.off('exit', stopProxy);
|
||||
process.on('exit', stopProxy);
|
||||
process.off('SIGINT', stopProxy);
|
||||
process.on('SIGINT', stopProxy);
|
||||
process.off('SIGTERM', stopProxy);
|
||||
process.on('SIGTERM', stopProxy);
|
||||
|
||||
// commented out as it disrupts ink rendering
|
||||
@@ -659,8 +662,11 @@ export async function start_sandbox(
|
||||
debugLogger.log('stopping proxy container ...');
|
||||
execSync(`${config.command} rm -f ${SANDBOX_PROXY_NAME}`);
|
||||
};
|
||||
process.off('exit', stopProxy);
|
||||
process.on('exit', stopProxy);
|
||||
process.off('SIGINT', stopProxy);
|
||||
process.on('SIGINT', stopProxy);
|
||||
process.off('SIGTERM', stopProxy);
|
||||
process.on('SIGTERM', stopProxy);
|
||||
|
||||
// commented out as it disrupts ink rendering
|
||||
|
||||
@@ -85,6 +85,17 @@ describe('SettingsUtils', () => {
|
||||
default: {},
|
||||
description: 'Advanced settings for power users.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
autoConfigureMemory: {
|
||||
type: 'boolean',
|
||||
label: 'Auto Configure Max Old Space Size',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Automatically configure Node.js memory limits',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
type: 'object',
|
||||
@@ -395,11 +406,15 @@ describe('SettingsUtils', () => {
|
||||
expect(uiKeys).not.toContain('ui.theme'); // This is now marked false
|
||||
});
|
||||
|
||||
it('should not include Advanced category settings', () => {
|
||||
it('should include Advanced category settings', () => {
|
||||
const categories = getDialogSettingsByCategory();
|
||||
|
||||
// Advanced settings should be filtered out
|
||||
expect(categories['Advanced']).toBeUndefined();
|
||||
// Advanced settings should now be included because of autoConfigureMemory
|
||||
expect(categories['Advanced']).toBeDefined();
|
||||
const advancedSettings = categories['Advanced'];
|
||||
expect(advancedSettings.map((s) => s.key)).toContain(
|
||||
'advanced.autoConfigureMemory',
|
||||
);
|
||||
});
|
||||
|
||||
it('should include settings with showInDialog=true', () => {
|
||||
|
||||
@@ -6,9 +6,13 @@
|
||||
|
||||
import { vi, beforeEach, afterEach } from 'vitest';
|
||||
import { format } from 'node:util';
|
||||
import { coreEvents } from '@google/gemini-cli-core';
|
||||
|
||||
global.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
// Increase max listeners to avoid warnings in large test suites
|
||||
coreEvents.setMaxListeners(100);
|
||||
|
||||
// Unset NO_COLOR environment variable to ensure consistent theme behavior between local and CI test runs
|
||||
if (process.env.NO_COLOR !== undefined) {
|
||||
delete process.env.NO_COLOR;
|
||||
@@ -55,6 +59,8 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
|
||||
vi.unstubAllEnvs();
|
||||
|
||||
if (actWarnings.length > 0) {
|
||||
const messages = actWarnings
|
||||
.map(({ message, stack }) => `${message}\n${stack}`)
|
||||
|
||||
@@ -29,6 +29,9 @@ export default defineConfig({
|
||||
react: path.resolve(__dirname, '../../node_modules/react'),
|
||||
},
|
||||
setupFiles: ['./test-setup.ts'],
|
||||
testTimeout: 60000,
|
||||
hookTimeout: 60000,
|
||||
pool: 'forks',
|
||||
coverage: {
|
||||
enabled: true,
|
||||
provider: 'v8',
|
||||
@@ -45,8 +48,8 @@ export default defineConfig({
|
||||
},
|
||||
poolOptions: {
|
||||
threads: {
|
||||
minThreads: 8,
|
||||
maxThreads: 16,
|
||||
minThreads: 1,
|
||||
maxThreads: 4,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user