mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 21:03:05 -07:00
Merge branch 'main' into cb/genericlist
This commit is contained in:
+80
-3
@@ -30,6 +30,8 @@ implementation strategy.
|
|||||||
- [The Planning Workflow](#the-planning-workflow)
|
- [The Planning Workflow](#the-planning-workflow)
|
||||||
- [Exiting Plan Mode](#exiting-plan-mode)
|
- [Exiting Plan Mode](#exiting-plan-mode)
|
||||||
- [Tool Restrictions](#tool-restrictions)
|
- [Tool Restrictions](#tool-restrictions)
|
||||||
|
- [Customizing Planning with Skills](#customizing-planning-with-skills)
|
||||||
|
- [Customizing Policies](#customizing-policies)
|
||||||
|
|
||||||
## Starting in Plan Mode
|
## Starting in Plan Mode
|
||||||
|
|
||||||
@@ -61,7 +63,8 @@ You can enter Plan Mode in three ways:
|
|||||||
1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes
|
1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes
|
||||||
(`Default` -> `Plan` -> `Auto-Edit`).
|
(`Default` -> `Plan` -> `Auto-Edit`).
|
||||||
2. **Command:** Type `/plan` in the input box.
|
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
|
### The Planning Workflow
|
||||||
|
|
||||||
@@ -81,8 +84,8 @@ You can enter Plan Mode in three ways:
|
|||||||
To exit Plan Mode:
|
To exit Plan Mode:
|
||||||
|
|
||||||
1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle to the desired 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
|
2. **Tool:** The agent calls the [`exit_plan_mode`] tool to present the
|
||||||
plan for your approval.
|
finalized plan for your approval.
|
||||||
|
|
||||||
## Tool Restrictions
|
## Tool Restrictions
|
||||||
|
|
||||||
@@ -97,6 +100,75 @@ These are the only allowed tools:
|
|||||||
`postgres_read_schema`) are allowed.
|
`postgres_read_schema`) are allowed.
|
||||||
- **Planning (Write):** [`write_file`] and [`replace`] ONLY allowed for `.md`
|
- **Planning (Write):** [`write_file`] and [`replace`] ONLY allowed for `.md`
|
||||||
files in the `~/.gemini/tmp/<project>/plans/` directory.
|
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
|
[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder
|
||||||
[`read_file`]: /docs/tools/file-system.md#2-read_file-readfile
|
[`read_file`]: /docs/tools/file-system.md#2-read_file-readfile
|
||||||
@@ -106,3 +178,8 @@ These are the only allowed tools:
|
|||||||
[`google_web_search`]: /docs/tools/web-search.md
|
[`google_web_search`]: /docs/tools/web-search.md
|
||||||
[`replace`]: /docs/tools/file-system.md#6-replace-edit
|
[`replace`]: /docs/tools/file-system.md#6-replace-edit
|
||||||
[MCP tools]: /docs/tools/mcp-server.md
|
[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
|
||||||
|
|||||||
@@ -119,9 +119,17 @@ For example:
|
|||||||
|
|
||||||
Approval modes allow the policy engine to apply different sets of rules based on
|
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
|
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
|
(e.g., `yolo`, `autoEdit`, `plan`). The rule will only be active if the CLI is
|
||||||
in one of its specified modes. If a rule has no modes specified, it is always
|
running in one of its specified modes. If a rule has no modes specified, it is
|
||||||
active.
|
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
|
## Rule matching
|
||||||
|
|
||||||
@@ -303,3 +311,5 @@ out-of-the-box experience.
|
|||||||
- In **`yolo`** mode, a high-priority rule allows all tools.
|
- In **`yolo`** mode, a high-priority rule allows all tools.
|
||||||
- In **`autoEdit`** mode, rules allow certain write operations to happen without
|
- In **`autoEdit`** mode, rules allow certain write operations to happen without
|
||||||
prompting.
|
prompting.
|
||||||
|
|
||||||
|
[Customizing Plan Mode Policies]: /docs/cli/plan-mode.md#customizing-policies
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ Gemini CLI's built-in tools can be broadly categorized as follows:
|
|||||||
information across sessions.
|
information across sessions.
|
||||||
- **[Todo Tool](./todos.md) (`write_todos`):** For managing subtasks of complex
|
- **[Todo Tool](./todos.md) (`write_todos`):** For managing subtasks of complex
|
||||||
requests.
|
requests.
|
||||||
|
- **[Planning Tools](./planning.md):** For entering and exiting Plan Mode.
|
||||||
|
|
||||||
Additionally, these tools incorporate:
|
Additionally, these tools incorporate:
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -238,18 +238,15 @@ vi.mock('./validateNonInterActiveAuth.js', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe('gemini.tsx main function', () => {
|
describe('gemini.tsx main function', () => {
|
||||||
let originalEnvGeminiSandbox: string | undefined;
|
|
||||||
let originalEnvSandbox: string | undefined;
|
|
||||||
let originalIsTTY: boolean | undefined;
|
let originalIsTTY: boolean | undefined;
|
||||||
let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
|
let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
|
||||||
[];
|
[];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Store and clear sandbox-related env variables to ensure a consistent test environment
|
// Store and clear sandbox-related env variables to ensure a consistent test environment
|
||||||
originalEnvGeminiSandbox = process.env['GEMINI_SANDBOX'];
|
vi.stubEnv('GEMINI_SANDBOX', '');
|
||||||
originalEnvSandbox = process.env['SANDBOX'];
|
vi.stubEnv('SANDBOX', '');
|
||||||
delete process.env['GEMINI_SANDBOX'];
|
vi.stubEnv('SHPOOL_SESSION_NAME', '');
|
||||||
delete process.env['SANDBOX'];
|
|
||||||
|
|
||||||
initialUnhandledRejectionListeners =
|
initialUnhandledRejectionListeners =
|
||||||
process.listeners('unhandledRejection');
|
process.listeners('unhandledRejection');
|
||||||
@@ -260,18 +257,6 @@ describe('gemini.tsx main function', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
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');
|
const currentListeners = process.listeners('unhandledRejection');
|
||||||
currentListeners.forEach((listener) => {
|
currentListeners.forEach((listener) => {
|
||||||
if (!initialUnhandledRejectionListeners.includes(listener)) {
|
if (!initialUnhandledRejectionListeners.includes(listener)) {
|
||||||
@@ -282,6 +267,7 @@ describe('gemini.tsx main function', () => {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(process.stdin as any).isTTY = originalIsTTY;
|
(process.stdin as any).isTTY = originalIsTTY;
|
||||||
|
|
||||||
|
vi.unstubAllEnvs();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1209,7 +1195,12 @@ describe('startInteractiveUI', () => {
|
|||||||
registerTelemetryConfig: vi.fn(),
|
registerTelemetryConfig: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubEnv('SHPOOL_SESSION_NAME', '');
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1308,7 +1299,7 @@ describe('startInteractiveUI', () => {
|
|||||||
|
|
||||||
// Verify all startup tasks were called
|
// Verify all startup tasks were called
|
||||||
expect(getVersion).toHaveBeenCalledTimes(1);
|
expect(getVersion).toHaveBeenCalledTimes(1);
|
||||||
expect(registerCleanup).toHaveBeenCalledTimes(3);
|
expect(registerCleanup).toHaveBeenCalledTimes(4);
|
||||||
|
|
||||||
// Verify cleanup handler is registered with unmount function
|
// Verify cleanup handler is registered with unmount function
|
||||||
const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0];
|
const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0];
|
||||||
|
|||||||
+33
-20
@@ -57,8 +57,8 @@ import {
|
|||||||
writeToStderr,
|
writeToStderr,
|
||||||
disableMouseEvents,
|
disableMouseEvents,
|
||||||
enableMouseEvents,
|
enableMouseEvents,
|
||||||
enterAlternateScreen,
|
|
||||||
disableLineWrapping,
|
disableLineWrapping,
|
||||||
|
enableLineWrapping,
|
||||||
shouldEnterAlternateScreen,
|
shouldEnterAlternateScreen,
|
||||||
startupProfiler,
|
startupProfiler,
|
||||||
ExitCodes,
|
ExitCodes,
|
||||||
@@ -89,6 +89,7 @@ import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
|||||||
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
||||||
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
|
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
|
||||||
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
||||||
|
import { useTerminalSize } from './ui/hooks/useTerminalSize.js';
|
||||||
import {
|
import {
|
||||||
relaunchAppInChildProcess,
|
relaunchAppInChildProcess,
|
||||||
relaunchOnExitCode,
|
relaunchOnExitCode,
|
||||||
@@ -214,9 +215,13 @@ export async function startInteractiveUI(
|
|||||||
|
|
||||||
const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio();
|
const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio();
|
||||||
|
|
||||||
|
const isShpool = !!process.env['SHPOOL_SESSION_NAME'];
|
||||||
|
|
||||||
// Create wrapper component to use hooks inside render
|
// Create wrapper component to use hooks inside render
|
||||||
const AppWrapper = () => {
|
const AppWrapper = () => {
|
||||||
useKittyKeyboardProtocol();
|
useKittyKeyboardProtocol();
|
||||||
|
const { columns, rows } = useTerminalSize();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsContext.Provider value={settings}>
|
<SettingsContext.Provider value={settings}>
|
||||||
<KeypressProvider
|
<KeypressProvider
|
||||||
@@ -234,6 +239,7 @@ export async function startInteractiveUI(
|
|||||||
<SessionStatsProvider>
|
<SessionStatsProvider>
|
||||||
<VimModeProvider settings={settings}>
|
<VimModeProvider settings={settings}>
|
||||||
<AppContainer
|
<AppContainer
|
||||||
|
key={`${columns}-${rows}`}
|
||||||
config={config}
|
config={config}
|
||||||
startupWarnings={startupWarnings}
|
startupWarnings={startupWarnings}
|
||||||
version={version}
|
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(
|
const instance = render(
|
||||||
process.env['DEBUG'] ? (
|
process.env['DEBUG'] ? (
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
@@ -273,10 +290,19 @@ export async function startInteractiveUI(
|
|||||||
patchConsole: false,
|
patchConsole: false,
|
||||||
alternateBuffer: useAlternateBuffer,
|
alternateBuffer: useAlternateBuffer,
|
||||||
incrementalRendering:
|
incrementalRendering:
|
||||||
settings.merged.ui.incrementalRendering !== false && useAlternateBuffer,
|
settings.merged.ui.incrementalRendering !== false &&
|
||||||
|
useAlternateBuffer &&
|
||||||
|
!isShpool,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (useAlternateBuffer) {
|
||||||
|
disableLineWrapping();
|
||||||
|
registerCleanup(() => {
|
||||||
|
enableLineWrapping();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
checkForUpdates(settings)
|
checkForUpdates(settings)
|
||||||
.then((info) => {
|
.then((info) => {
|
||||||
handleAutoUpdate(info, settings, config.getProjectRoot());
|
handleAutoUpdate(info, settings, config.getProjectRoot());
|
||||||
@@ -590,26 +616,13 @@ export async function main() {
|
|||||||
// input showing up in the output.
|
// input showing up in the output.
|
||||||
process.stdin.setRawMode(true);
|
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.
|
// This cleanup isn't strictly needed but may help in certain situations.
|
||||||
const restoreRawMode = () => {
|
process.on('SIGTERM', () => {
|
||||||
process.stdin.setRawMode(wasRaw);
|
process.stdin.setRawMode(wasRaw);
|
||||||
};
|
});
|
||||||
process.off('SIGTERM', restoreRawMode);
|
process.on('SIGINT', () => {
|
||||||
process.on('SIGTERM', restoreRawMode);
|
process.stdin.setRawMode(wasRaw);
|
||||||
process.off('SIGINT', restoreRawMode);
|
});
|
||||||
process.on('SIGINT', restoreRawMode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await setupTerminalAndTheme(config, settings);
|
await setupTerminalAndTheme(config, settings);
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
import ansiEscapes from 'ansi-escapes';
|
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 type { InitializationResult } from '../core/initializer.js';
|
||||||
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
||||||
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
|
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
|
||||||
@@ -92,6 +92,7 @@ import {
|
|||||||
UIActionsContext,
|
UIActionsContext,
|
||||||
type UIActions,
|
type UIActions,
|
||||||
} from './contexts/UIActionsContext.js';
|
} from './contexts/UIActionsContext.js';
|
||||||
|
import { KeypressProvider } from './contexts/KeypressContext.js';
|
||||||
|
|
||||||
// Mock useStdout to capture terminal title writes
|
// Mock useStdout to capture terminal title writes
|
||||||
vi.mock('ink', async (importOriginal) => {
|
vi.mock('ink', async (importOriginal) => {
|
||||||
@@ -133,7 +134,6 @@ vi.mock('./hooks/useGeminiStream.js');
|
|||||||
vi.mock('./hooks/vim.js');
|
vi.mock('./hooks/vim.js');
|
||||||
vi.mock('./hooks/useFocus.js');
|
vi.mock('./hooks/useFocus.js');
|
||||||
vi.mock('./hooks/useBracketedPaste.js');
|
vi.mock('./hooks/useBracketedPaste.js');
|
||||||
vi.mock('./hooks/useKeypress.js');
|
|
||||||
vi.mock('./hooks/useLoadingIndicator.js');
|
vi.mock('./hooks/useLoadingIndicator.js');
|
||||||
vi.mock('./hooks/useFolderTrust.js');
|
vi.mock('./hooks/useFolderTrust.js');
|
||||||
vi.mock('./hooks/useIdeTrustListener.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 { useLogger } from './hooks/useLogger.js';
|
||||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||||
import { useInputHistoryStore } from './hooks/useInputHistoryStore.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 { measureElement } from 'ink';
|
||||||
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
||||||
import {
|
import {
|
||||||
@@ -232,13 +232,15 @@ describe('AppContainer State Management', () => {
|
|||||||
resumedSessionData?: ResumedSessionData;
|
resumedSessionData?: ResumedSessionData;
|
||||||
} = {}) => (
|
} = {}) => (
|
||||||
<SettingsContext.Provider value={settings}>
|
<SettingsContext.Provider value={settings}>
|
||||||
<AppContainer
|
<KeypressProvider config={config}>
|
||||||
config={config}
|
<AppContainer
|
||||||
version={version}
|
config={config}
|
||||||
initializationResult={initResult}
|
version={version}
|
||||||
startupWarnings={startupWarnings}
|
initializationResult={initResult}
|
||||||
resumedSessionData={resumedSessionData}
|
startupWarnings={startupWarnings}
|
||||||
/>
|
resumedSessionData={resumedSessionData}
|
||||||
|
/>
|
||||||
|
</KeypressProvider>
|
||||||
</SettingsContext.Provider>
|
</SettingsContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -268,7 +270,6 @@ describe('AppContainer State Management', () => {
|
|||||||
const mockedUseTextBuffer = useTextBuffer as Mock;
|
const mockedUseTextBuffer = useTextBuffer as Mock;
|
||||||
const mockedUseLogger = useLogger as Mock;
|
const mockedUseLogger = useLogger as Mock;
|
||||||
const mockedUseLoadingIndicator = useLoadingIndicator as Mock;
|
const mockedUseLoadingIndicator = useLoadingIndicator as Mock;
|
||||||
const mockedUseKeypress = useKeypress as Mock;
|
|
||||||
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
|
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
|
||||||
const mockedUseHookDisplayState = useHookDisplayState as Mock;
|
const mockedUseHookDisplayState = useHookDisplayState as Mock;
|
||||||
const mockedUseTerminalTheme = useTerminalTheme as Mock;
|
const mockedUseTerminalTheme = useTerminalTheme as Mock;
|
||||||
@@ -1770,47 +1771,36 @@ describe('AppContainer State Management', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => {
|
describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => {
|
||||||
let handleGlobalKeypress: (key: Key) => boolean;
|
|
||||||
let mockHandleSlashCommand: Mock;
|
let mockHandleSlashCommand: Mock;
|
||||||
let mockCancelOngoingRequest: Mock;
|
let mockCancelOngoingRequest: Mock;
|
||||||
let rerender: () => void;
|
let rerender: () => void;
|
||||||
let unmount: () => void;
|
let unmount: () => void;
|
||||||
|
let stdin: ReturnType<typeof render>['stdin'];
|
||||||
|
|
||||||
// Helper function to reduce boilerplate in tests
|
// Helper function to reduce boilerplate in tests
|
||||||
const setupKeypressTest = async () => {
|
const setupKeypressTest = async () => {
|
||||||
const renderResult = renderAppContainer();
|
const renderResult = renderAppContainer();
|
||||||
|
stdin = renderResult.stdin;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(0);
|
vi.advanceTimersByTime(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
rerender = () => renderResult.rerender(getAppContainer());
|
rerender = () => {
|
||||||
|
renderResult.rerender(getAppContainer());
|
||||||
|
};
|
||||||
unmount = renderResult.unmount;
|
unmount = renderResult.unmount;
|
||||||
};
|
};
|
||||||
|
|
||||||
const pressKey = (key: Partial<Key>, times = 1) => {
|
const pressKey = (sequence: string, times = 1) => {
|
||||||
for (let i = 0; i < times; i++) {
|
for (let i = 0; i < times; i++) {
|
||||||
act(() => {
|
act(() => {
|
||||||
handleGlobalKeypress({
|
stdin.write(sequence);
|
||||||
name: 'c',
|
|
||||||
shift: false,
|
|
||||||
alt: false,
|
|
||||||
ctrl: false,
|
|
||||||
cmd: false,
|
|
||||||
...key,
|
|
||||||
} as Key);
|
|
||||||
});
|
});
|
||||||
rerender();
|
rerender();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Capture the keypress handler from the AppContainer
|
|
||||||
mockedUseKeypress.mockImplementation(
|
|
||||||
(callback: (key: Key) => boolean) => {
|
|
||||||
handleGlobalKeypress = callback;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mock slash command handler
|
// Mock slash command handler
|
||||||
mockHandleSlashCommand = vi.fn();
|
mockHandleSlashCommand = vi.fn();
|
||||||
mockedUseSlashCommandProcessor.mockReturnValue({
|
mockedUseSlashCommandProcessor.mockReturnValue({
|
||||||
@@ -1855,7 +1845,7 @@ describe('AppContainer State Management', () => {
|
|||||||
});
|
});
|
||||||
await setupKeypressTest();
|
await setupKeypressTest();
|
||||||
|
|
||||||
pressKey({ name: 'c', ctrl: true });
|
pressKey('\x03'); // Ctrl+C
|
||||||
|
|
||||||
expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(1);
|
expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(1);
|
||||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||||
@@ -1865,7 +1855,7 @@ describe('AppContainer State Management', () => {
|
|||||||
it('should quit on second press', async () => {
|
it('should quit on second press', async () => {
|
||||||
await setupKeypressTest();
|
await setupKeypressTest();
|
||||||
|
|
||||||
pressKey({ name: 'c', ctrl: true }, 2);
|
pressKey('\x03', 2); // Ctrl+C
|
||||||
|
|
||||||
expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(2);
|
expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(2);
|
||||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||||
@@ -1880,7 +1870,7 @@ describe('AppContainer State Management', () => {
|
|||||||
it('should reset press count after a timeout', async () => {
|
it('should reset press count after a timeout', async () => {
|
||||||
await setupKeypressTest();
|
await setupKeypressTest();
|
||||||
|
|
||||||
pressKey({ name: 'c', ctrl: true });
|
pressKey('\x03'); // Ctrl+C
|
||||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||||
|
|
||||||
// Advance timer past the reset threshold
|
// Advance timer past the reset threshold
|
||||||
@@ -1888,7 +1878,7 @@ describe('AppContainer State Management', () => {
|
|||||||
vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1);
|
vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
pressKey({ name: 'c', ctrl: true });
|
pressKey('\x03'); // Ctrl+C
|
||||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -1898,7 +1888,7 @@ describe('AppContainer State Management', () => {
|
|||||||
it('should quit on second press if buffer is empty', async () => {
|
it('should quit on second press if buffer is empty', async () => {
|
||||||
await setupKeypressTest();
|
await setupKeypressTest();
|
||||||
|
|
||||||
pressKey({ name: 'd', ctrl: true }, 2);
|
pressKey('\x04', 2); // Ctrl+D
|
||||||
|
|
||||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||||
'/quit',
|
'/quit',
|
||||||
@@ -1909,7 +1899,7 @@ describe('AppContainer State Management', () => {
|
|||||||
unmount();
|
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({
|
mockedUseTextBuffer.mockReturnValue({
|
||||||
text: 'some text',
|
text: 'some text',
|
||||||
setText: vi.fn(),
|
setText: vi.fn(),
|
||||||
@@ -1919,30 +1909,12 @@ describe('AppContainer State Management', () => {
|
|||||||
});
|
});
|
||||||
await setupKeypressTest();
|
await setupKeypressTest();
|
||||||
|
|
||||||
// Capture return value
|
pressKey('\x04'); // Ctrl+D
|
||||||
let result = true;
|
|
||||||
const originalPressKey = (key: Partial<Key>) => {
|
|
||||||
act(() => {
|
|
||||||
result = handleGlobalKeypress({
|
|
||||||
name: 'd',
|
|
||||||
shift: false,
|
|
||||||
alt: false,
|
|
||||||
ctrl: true,
|
|
||||||
cmd: false,
|
|
||||||
...key,
|
|
||||||
} as Key);
|
|
||||||
});
|
|
||||||
rerender();
|
|
||||||
};
|
|
||||||
|
|
||||||
originalPressKey({ name: 'd', ctrl: true });
|
// Should only be called once, so count is 1, not quitting yet.
|
||||||
|
|
||||||
// AppContainer's handler should return true if it reaches it
|
|
||||||
expect(result).toBe(true);
|
|
||||||
// But it should only be called once, so count is 1, not quitting yet.
|
|
||||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||||
|
|
||||||
originalPressKey({ name: 'd', ctrl: true });
|
pressKey('\x04'); // Ctrl+D
|
||||||
// Now count is 2, it should quit.
|
// Now count is 2, it should quit.
|
||||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||||
'/quit',
|
'/quit',
|
||||||
@@ -1956,7 +1928,7 @@ describe('AppContainer State Management', () => {
|
|||||||
it('should reset press count after a timeout', async () => {
|
it('should reset press count after a timeout', async () => {
|
||||||
await setupKeypressTest();
|
await setupKeypressTest();
|
||||||
|
|
||||||
pressKey({ name: 'd', ctrl: true });
|
pressKey('\x04'); // Ctrl+D
|
||||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||||
|
|
||||||
// Advance timer past the reset threshold
|
// Advance timer past the reset threshold
|
||||||
@@ -1964,7 +1936,7 @@ describe('AppContainer State Management', () => {
|
|||||||
vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1);
|
vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
pressKey({ name: 'd', ctrl: true });
|
pressKey('\x04'); // Ctrl+D
|
||||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -1982,7 +1954,7 @@ describe('AppContainer State Management', () => {
|
|||||||
it('should focus shell input on Tab', async () => {
|
it('should focus shell input on Tab', async () => {
|
||||||
await setupKeypressTest();
|
await setupKeypressTest();
|
||||||
|
|
||||||
pressKey({ name: 'tab', shift: false });
|
pressKey('\t');
|
||||||
|
|
||||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||||
unmount();
|
unmount();
|
||||||
@@ -1992,11 +1964,11 @@ describe('AppContainer State Management', () => {
|
|||||||
await setupKeypressTest();
|
await setupKeypressTest();
|
||||||
|
|
||||||
// Focus first
|
// Focus first
|
||||||
pressKey({ name: 'tab', shift: false });
|
pressKey('\t');
|
||||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||||
|
|
||||||
// Unfocus via Shift+Tab
|
// Unfocus via Shift+Tab
|
||||||
pressKey({ name: 'tab', shift: true });
|
pressKey('\x1b[Z');
|
||||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -2015,13 +1987,7 @@ describe('AppContainer State Management', () => {
|
|||||||
|
|
||||||
// Focus it
|
// Focus it
|
||||||
act(() => {
|
act(() => {
|
||||||
handleGlobalKeypress({
|
renderResult.stdin.write('\t');
|
||||||
name: 'tab',
|
|
||||||
shift: false,
|
|
||||||
alt: false,
|
|
||||||
ctrl: false,
|
|
||||||
cmd: false,
|
|
||||||
} as Key);
|
|
||||||
});
|
});
|
||||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||||
|
|
||||||
@@ -2056,7 +2022,7 @@ describe('AppContainer State Management', () => {
|
|||||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||||
|
|
||||||
// Press Tab
|
// Press Tab
|
||||||
pressKey({ name: 'tab', shift: false });
|
pressKey('\t');
|
||||||
|
|
||||||
// Should be focused
|
// Should be focused
|
||||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||||
@@ -2084,7 +2050,7 @@ describe('AppContainer State Management', () => {
|
|||||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||||
|
|
||||||
// Press Ctrl+B
|
// Press Ctrl+B
|
||||||
pressKey({ name: 'b', ctrl: true });
|
pressKey('\x02');
|
||||||
|
|
||||||
// Should have toggled (closed) the shell
|
// Should have toggled (closed) the shell
|
||||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||||
@@ -2113,7 +2079,7 @@ describe('AppContainer State Management', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Press Ctrl+B
|
// Press Ctrl+B
|
||||||
pressKey({ name: 'b', ctrl: true });
|
pressKey('\x02');
|
||||||
|
|
||||||
// Should have toggled (shown) the shell
|
// Should have toggled (shown) the shell
|
||||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||||
@@ -2126,11 +2092,14 @@ describe('AppContainer State Management', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Copy Mode (CTRL+S)', () => {
|
describe('Copy Mode (CTRL+S)', () => {
|
||||||
let handleGlobalKeypress: (key: Key) => boolean;
|
|
||||||
let rerender: () => void;
|
let rerender: () => void;
|
||||||
let unmount: () => 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
|
// Update settings for this test run
|
||||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||||
const testSettings = {
|
const testSettings = {
|
||||||
@@ -2144,23 +2113,39 @@ describe('AppContainer State Management', () => {
|
|||||||
},
|
},
|
||||||
} as unknown as LoadedSettings;
|
} 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 () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(0);
|
vi.advanceTimersByTime(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
rerender = () =>
|
rerender = () => renderResult.rerender(getTree(testSettings));
|
||||||
renderResult.rerender(getAppContainer({ settings: testSettings }));
|
|
||||||
unmount = renderResult.unmount;
|
unmount = renderResult.unmount;
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.mockStdout.write.mockClear();
|
mocks.mockStdout.write.mockClear();
|
||||||
mockedUseKeypress.mockImplementation(
|
|
||||||
(callback: (key: Key) => boolean) => {
|
|
||||||
handleGlobalKeypress = callback;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2186,15 +2171,7 @@ describe('AppContainer State Management', () => {
|
|||||||
mocks.mockStdout.write.mockClear(); // Clear initial enable call
|
mocks.mockStdout.write.mockClear(); // Clear initial enable call
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
handleGlobalKeypress({
|
stdin.write('\x13'); // Ctrl+S
|
||||||
name: 's',
|
|
||||||
shift: false,
|
|
||||||
alt: false,
|
|
||||||
ctrl: true,
|
|
||||||
cmd: false,
|
|
||||||
insertable: false,
|
|
||||||
sequence: '\x13',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
rerender();
|
rerender();
|
||||||
|
|
||||||
@@ -2213,30 +2190,14 @@ describe('AppContainer State Management', () => {
|
|||||||
|
|
||||||
// Turn it on (disable mouse)
|
// Turn it on (disable mouse)
|
||||||
act(() => {
|
act(() => {
|
||||||
handleGlobalKeypress({
|
stdin.write('\x13'); // Ctrl+S
|
||||||
name: 's',
|
|
||||||
shift: false,
|
|
||||||
alt: false,
|
|
||||||
ctrl: true,
|
|
||||||
cmd: false,
|
|
||||||
insertable: false,
|
|
||||||
sequence: '\x13',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
rerender();
|
rerender();
|
||||||
expect(disableMouseEvents).toHaveBeenCalled();
|
expect(disableMouseEvents).toHaveBeenCalled();
|
||||||
|
|
||||||
// Turn it off (enable mouse)
|
// Turn it off (enable mouse)
|
||||||
act(() => {
|
act(() => {
|
||||||
handleGlobalKeypress({
|
stdin.write('a'); // Any key should exit copy mode
|
||||||
name: 'any', // Any key should exit copy mode
|
|
||||||
shift: false,
|
|
||||||
alt: false,
|
|
||||||
ctrl: false,
|
|
||||||
cmd: false,
|
|
||||||
insertable: true,
|
|
||||||
sequence: 'a',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
rerender();
|
rerender();
|
||||||
|
|
||||||
@@ -2249,15 +2210,7 @@ describe('AppContainer State Management', () => {
|
|||||||
|
|
||||||
// Enter copy mode
|
// Enter copy mode
|
||||||
act(() => {
|
act(() => {
|
||||||
handleGlobalKeypress({
|
stdin.write('\x13'); // Ctrl+S
|
||||||
name: 's',
|
|
||||||
shift: false,
|
|
||||||
alt: false,
|
|
||||||
ctrl: true,
|
|
||||||
cmd: false,
|
|
||||||
insertable: false,
|
|
||||||
sequence: '\x13',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
rerender();
|
rerender();
|
||||||
|
|
||||||
@@ -2265,15 +2218,7 @@ describe('AppContainer State Management', () => {
|
|||||||
|
|
||||||
// Press any other key
|
// Press any other key
|
||||||
act(() => {
|
act(() => {
|
||||||
handleGlobalKeypress({
|
stdin.write('a');
|
||||||
name: 'a',
|
|
||||||
shift: false,
|
|
||||||
alt: false,
|
|
||||||
ctrl: false,
|
|
||||||
cmd: false,
|
|
||||||
insertable: true,
|
|
||||||
sequence: 'a',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
rerender();
|
rerender();
|
||||||
|
|
||||||
@@ -2281,6 +2226,37 @@ describe('AppContainer State Management', () => {
|
|||||||
expect(enableMouseEvents).toHaveBeenCalled();
|
expect(enableMouseEvents).toHaveBeenCalled();
|
||||||
unmount();
|
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 { type InitializationResult } from '../core/initializer.js';
|
||||||
import { useFocus } from './hooks/useFocus.js';
|
import { useFocus } from './hooks/useFocus.js';
|
||||||
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
||||||
|
import { KeypressPriority } from './contexts/KeypressContext.js';
|
||||||
import { keyMatchers, Command } from './keyMatchers.js';
|
import { keyMatchers, Command } from './keyMatchers.js';
|
||||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||||
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
|
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
|
||||||
@@ -363,7 +364,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
(async () => {
|
(async () => {
|
||||||
// Note: the program will not work if this fails so let errors be
|
// Note: the program will not work if this fails so let errors be
|
||||||
// handled by the global catch.
|
// handled by the global catch.
|
||||||
await config.initialize();
|
if (!config.isInitialized()) {
|
||||||
|
await config.initialize();
|
||||||
|
}
|
||||||
setConfigInitialized(true);
|
setConfigInitialized(true);
|
||||||
startupProfiler.flush(config);
|
startupProfiler.flush(config);
|
||||||
|
|
||||||
@@ -1481,13 +1484,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
|
|
||||||
const handleGlobalKeypress = useCallback(
|
const handleGlobalKeypress = useCallback(
|
||||||
(key: Key): boolean => {
|
(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
|
// Debug log keystrokes if enabled
|
||||||
if (settings.merged.general.debugKeystrokeLogging) {
|
if (settings.merged.general.debugKeystrokeLogging) {
|
||||||
debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key));
|
debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key));
|
||||||
@@ -1525,6 +1521,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
);
|
);
|
||||||
await toggleDevToolsPanel(
|
await toggleDevToolsPanel(
|
||||||
config,
|
config,
|
||||||
|
showErrorDetails,
|
||||||
() => setShowErrorDetails((prev) => !prev),
|
() => setShowErrorDetails((prev) => !prev),
|
||||||
() => setShowErrorDetails(true),
|
() => setShowErrorDetails(true),
|
||||||
);
|
);
|
||||||
@@ -1654,7 +1651,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
settings.merged.general.debugKeystrokeLogging,
|
settings.merged.general.debugKeystrokeLogging,
|
||||||
refreshStatic,
|
refreshStatic,
|
||||||
setCopyModeEnabled,
|
setCopyModeEnabled,
|
||||||
copyModeEnabled,
|
|
||||||
isAlternateBuffer,
|
isAlternateBuffer,
|
||||||
backgroundCurrentShell,
|
backgroundCurrentShell,
|
||||||
toggleBackgroundShell,
|
toggleBackgroundShell,
|
||||||
@@ -1665,11 +1661,26 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
tabFocusTimeoutRef,
|
tabFocusTimeoutRef,
|
||||||
showTransientMessage,
|
showTransientMessage,
|
||||||
settings.merged.general.devtools,
|
settings.merged.general.devtools,
|
||||||
|
showErrorDetails,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
|
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(() => {
|
useEffect(() => {
|
||||||
// Respect hideWindowTitle settings
|
// Respect hideWindowTitle settings
|
||||||
if (settings.merged.ui.hideWindowTitle) return;
|
if (settings.merged.ui.hideWindowTitle) return;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { debugLogger, type Config } from '@google/gemini-cli-core';
|
import { debugLogger, type Config } from '@google/gemini-cli-core';
|
||||||
import { useStdin } from 'ink';
|
import { useStdin } from 'ink';
|
||||||
|
import { MultiMap } from 'mnemonist';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
@@ -26,6 +27,13 @@ export const ESC_TIMEOUT = 50;
|
|||||||
export const PASTE_TIMEOUT = 30_000;
|
export const PASTE_TIMEOUT = 30_000;
|
||||||
export const FAST_RETURN_TIMEOUT = 30;
|
export const FAST_RETURN_TIMEOUT = 30;
|
||||||
|
|
||||||
|
export enum KeypressPriority {
|
||||||
|
Low = -100,
|
||||||
|
Normal = 0,
|
||||||
|
High = 100,
|
||||||
|
Critical = 200,
|
||||||
|
}
|
||||||
|
|
||||||
// Parse the key itself
|
// Parse the key itself
|
||||||
const KEY_INFO_MAP: Record<
|
const KEY_INFO_MAP: Record<
|
||||||
string,
|
string,
|
||||||
@@ -645,7 +653,10 @@ export interface Key {
|
|||||||
export type KeypressHandler = (key: Key) => boolean | void;
|
export type KeypressHandler = (key: Key) => boolean | void;
|
||||||
|
|
||||||
interface KeypressContextValue {
|
interface KeypressContextValue {
|
||||||
subscribe: (handler: KeypressHandler, priority?: boolean) => void;
|
subscribe: (
|
||||||
|
handler: KeypressHandler,
|
||||||
|
priority?: KeypressPriority | boolean,
|
||||||
|
) => void;
|
||||||
unsubscribe: (handler: KeypressHandler) => void;
|
unsubscribe: (handler: KeypressHandler) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,44 +685,75 @@ export function KeypressProvider({
|
|||||||
}) {
|
}) {
|
||||||
const { stdin, setRawMode } = useStdin();
|
const { stdin, setRawMode } = useStdin();
|
||||||
|
|
||||||
const prioritySubscribers = useRef<Set<KeypressHandler>>(new Set()).current;
|
const subscribersToPriority = useRef<Map<KeypressHandler, number>>(
|
||||||
const normalSubscribers = useRef<Set<KeypressHandler>>(new Set()).current;
|
new Map(),
|
||||||
|
).current;
|
||||||
|
const subscribers = useRef(
|
||||||
|
new MultiMap<number, KeypressHandler>(Set),
|
||||||
|
).current;
|
||||||
|
const sortedPriorities = useRef<number[]>([]);
|
||||||
|
|
||||||
const subscribe = useCallback(
|
const subscribe = useCallback(
|
||||||
(handler: KeypressHandler, priority = false) => {
|
(
|
||||||
const set = priority ? prioritySubscribers : normalSubscribers;
|
handler: KeypressHandler,
|
||||||
set.add(handler);
|
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(
|
const unsubscribe = useCallback(
|
||||||
(handler: KeypressHandler) => {
|
(handler: KeypressHandler) => {
|
||||||
prioritySubscribers.delete(handler);
|
const p = subscribersToPriority.get(handler);
|
||||||
normalSubscribers.delete(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(
|
const broadcast = useCallback(
|
||||||
(key: Key) => {
|
(key: Key) => {
|
||||||
// Process priority subscribers first, in reverse order (stack behavior: last subscribed is first to handle)
|
// Use cached sorted priorities to avoid sorting on every keypress
|
||||||
const priorityHandlers = Array.from(prioritySubscribers).reverse();
|
for (const p of sortedPriorities.current) {
|
||||||
for (const handler of priorityHandlers) {
|
const set = subscribers.get(p);
|
||||||
if (handler(key) === true) {
|
if (!set) continue;
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then process normal subscribers, also in reverse order
|
// Within a priority level, use stack behavior (last subscribed is first to handle)
|
||||||
const normalHandlers = Array.from(normalSubscribers).reverse();
|
const handlers = Array.from(set).reverse();
|
||||||
for (const handler of normalHandlers) {
|
for (const handler of handlers) {
|
||||||
if (handler(key) === true) {
|
if (handler(key) === true) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[prioritySubscribers, normalSubscribers],
|
[subscribers],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -5,8 +5,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import type { KeypressHandler, Key } from '../contexts/KeypressContext.js';
|
import {
|
||||||
import { useKeypressContext } from '../contexts/KeypressContext.js';
|
useKeypressContext,
|
||||||
|
type KeypressHandler,
|
||||||
|
type Key,
|
||||||
|
type KeypressPriority,
|
||||||
|
} from '../contexts/KeypressContext.js';
|
||||||
|
|
||||||
export type { Key };
|
export type { Key };
|
||||||
|
|
||||||
@@ -16,11 +20,14 @@ export type { Key };
|
|||||||
* @param onKeypress - The callback function to execute on each keypress.
|
* @param onKeypress - The callback function to execute on each keypress.
|
||||||
* @param options - Options to control the hook's behavior.
|
* @param options - Options to control the hook's behavior.
|
||||||
* @param options.isActive - Whether the hook should be actively listening for input.
|
* @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(
|
export function useKeypress(
|
||||||
onKeypress: KeypressHandler,
|
onKeypress: KeypressHandler,
|
||||||
{ isActive, priority }: { isActive: boolean; priority?: boolean },
|
{
|
||||||
|
isActive,
|
||||||
|
priority,
|
||||||
|
}: { isActive: boolean; priority?: KeypressPriority | boolean },
|
||||||
) {
|
) {
|
||||||
const { subscribe, unsubscribe } = useKeypressContext();
|
const { subscribe, unsubscribe } = useKeypressContext();
|
||||||
|
|
||||||
|
|||||||
@@ -437,7 +437,19 @@ describe('devtoolsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('toggleDevToolsPanel', () => {
|
describe('toggleDevToolsPanel', () => {
|
||||||
it('calls toggle when browser opens successfully', async () => {
|
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 config = createMockConfig();
|
||||||
const toggle = vi.fn();
|
const toggle = vi.fn();
|
||||||
const setOpen = vi.fn();
|
const setOpen = vi.fn();
|
||||||
@@ -447,18 +459,18 @@ describe('devtoolsService', () => {
|
|||||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
|
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
|
||||||
mockDevToolsInstance.getPort.mockReturnValue(25417);
|
mockDevToolsInstance.getPort.mockReturnValue(25417);
|
||||||
|
|
||||||
const promise = toggleDevToolsPanel(config, toggle, setOpen);
|
const promise = toggleDevToolsPanel(config, false, toggle, setOpen);
|
||||||
|
|
||||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||||
MockWebSocket.instances[0].simulateError();
|
MockWebSocket.instances[0].simulateError();
|
||||||
|
|
||||||
await promise;
|
await promise;
|
||||||
|
|
||||||
expect(toggle).toHaveBeenCalledTimes(1);
|
expect(toggle).not.toHaveBeenCalled();
|
||||||
expect(setOpen).not.toHaveBeenCalled();
|
expect(setOpen).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls toggle when browser fails to open', async () => {
|
it('calls setOpen when browser fails to open', async () => {
|
||||||
const config = createMockConfig();
|
const config = createMockConfig();
|
||||||
const toggle = vi.fn();
|
const toggle = vi.fn();
|
||||||
const setOpen = vi.fn();
|
const setOpen = vi.fn();
|
||||||
@@ -468,18 +480,18 @@ describe('devtoolsService', () => {
|
|||||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
|
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
|
||||||
mockDevToolsInstance.getPort.mockReturnValue(25417);
|
mockDevToolsInstance.getPort.mockReturnValue(25417);
|
||||||
|
|
||||||
const promise = toggleDevToolsPanel(config, toggle, setOpen);
|
const promise = toggleDevToolsPanel(config, false, toggle, setOpen);
|
||||||
|
|
||||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||||
MockWebSocket.instances[0].simulateError();
|
MockWebSocket.instances[0].simulateError();
|
||||||
|
|
||||||
await promise;
|
await promise;
|
||||||
|
|
||||||
expect(toggle).toHaveBeenCalledTimes(1);
|
expect(toggle).not.toHaveBeenCalled();
|
||||||
expect(setOpen).not.toHaveBeenCalled();
|
expect(setOpen).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls toggle when shouldLaunchBrowser returns false', async () => {
|
it('calls setOpen when shouldLaunchBrowser returns false', async () => {
|
||||||
const config = createMockConfig();
|
const config = createMockConfig();
|
||||||
const toggle = vi.fn();
|
const toggle = vi.fn();
|
||||||
const setOpen = vi.fn();
|
const setOpen = vi.fn();
|
||||||
@@ -488,15 +500,15 @@ describe('devtoolsService', () => {
|
|||||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
|
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
|
||||||
mockDevToolsInstance.getPort.mockReturnValue(25417);
|
mockDevToolsInstance.getPort.mockReturnValue(25417);
|
||||||
|
|
||||||
const promise = toggleDevToolsPanel(config, toggle, setOpen);
|
const promise = toggleDevToolsPanel(config, false, toggle, setOpen);
|
||||||
|
|
||||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||||
MockWebSocket.instances[0].simulateError();
|
MockWebSocket.instances[0].simulateError();
|
||||||
|
|
||||||
await promise;
|
await promise;
|
||||||
|
|
||||||
expect(toggle).toHaveBeenCalledTimes(1);
|
expect(toggle).not.toHaveBeenCalled();
|
||||||
expect(setOpen).not.toHaveBeenCalled();
|
expect(setOpen).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls setOpen when DevTools server fails to start', async () => {
|
it('calls setOpen when DevTools server fails to start', async () => {
|
||||||
@@ -506,7 +518,7 @@ describe('devtoolsService', () => {
|
|||||||
|
|
||||||
mockDevToolsInstance.start.mockRejectedValue(new Error('fail'));
|
mockDevToolsInstance.start.mockRejectedValue(new Error('fail'));
|
||||||
|
|
||||||
const promise = toggleDevToolsPanel(config, toggle, setOpen);
|
const promise = toggleDevToolsPanel(config, false, toggle, setOpen);
|
||||||
|
|
||||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||||
MockWebSocket.instances[0].simulateError();
|
MockWebSocket.instances[0].simulateError();
|
||||||
|
|||||||
@@ -212,14 +212,24 @@ async function startDevToolsServerImpl(config: Config): Promise<string> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the F12 key toggle for the DevTools panel.
|
* Handles the F12 key toggle for the DevTools panel.
|
||||||
* Starts the DevTools server, attempts to open the browser,
|
* Starts the DevTools server, attempts to open the browser.
|
||||||
* and always calls the toggle callback regardless of the outcome.
|
* 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(
|
export async function toggleDevToolsPanel(
|
||||||
config: Config,
|
config: Config,
|
||||||
|
isOpen: boolean,
|
||||||
toggle: () => void,
|
toggle: () => void,
|
||||||
setOpen: () => void,
|
setOpen: () => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (isOpen) {
|
||||||
|
toggle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { openBrowserSecurely, shouldLaunchBrowser } = await import(
|
const { openBrowserSecurely, shouldLaunchBrowser } = await import(
|
||||||
'@google/gemini-cli-core'
|
'@google/gemini-cli-core'
|
||||||
@@ -228,11 +238,14 @@ export async function toggleDevToolsPanel(
|
|||||||
if (shouldLaunchBrowser()) {
|
if (shouldLaunchBrowser()) {
|
||||||
try {
|
try {
|
||||||
await openBrowserSecurely(url);
|
await openBrowserSecurely(url);
|
||||||
|
// Browser opened successfully, don't open drawer.
|
||||||
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugLogger.warn('Failed to open browser securely:', e);
|
debugLogger.warn('Failed to open browser securely:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
toggle();
|
// If we can't launch browser or it failed, open drawer.
|
||||||
|
setOpen();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setOpen();
|
setOpen();
|
||||||
debugLogger.error('Failed to start DevTools server:', e);
|
debugLogger.error('Failed to start DevTools server:', e);
|
||||||
|
|||||||
@@ -905,6 +905,10 @@ export class Config {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isInitialized(): boolean {
|
||||||
|
return this.initialized;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Must only be called once, throws if called again.
|
* Must only be called once, throws if called again.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { LoggingContentGenerator } from './loggingContentGenerator.js';
|
|||||||
import { loadApiKey } from './apiKeyCredentialStorage.js';
|
import { loadApiKey } from './apiKeyCredentialStorage.js';
|
||||||
import { FakeContentGenerator } from './fakeContentGenerator.js';
|
import { FakeContentGenerator } from './fakeContentGenerator.js';
|
||||||
import { RecordingContentGenerator } from './recordingContentGenerator.js';
|
import { RecordingContentGenerator } from './recordingContentGenerator.js';
|
||||||
|
import { resetVersionCache } from '../utils/version.js';
|
||||||
|
|
||||||
vi.mock('../code_assist/codeAssist.js');
|
vi.mock('../code_assist/codeAssist.js');
|
||||||
vi.mock('@google/genai');
|
vi.mock('@google/genai');
|
||||||
@@ -35,6 +36,7 @@ const mockConfig = {
|
|||||||
|
|
||||||
describe('createContentGenerator', () => {
|
describe('createContentGenerator', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
resetVersionCache();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -951,4 +951,107 @@ name = "invalid-name"
|
|||||||
|
|
||||||
vi.doUnmock('node:fs/promises');
|
vi.doUnmock('node:fs/promises');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should allow overriding Plan Mode deny with user policy', async () => {
|
||||||
|
const actualFs =
|
||||||
|
await vi.importActual<typeof import('node:fs/promises')>(
|
||||||
|
'node:fs/promises',
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockReaddir = vi.fn(
|
||||||
|
async (
|
||||||
|
path: string | Buffer | URL,
|
||||||
|
options?: Parameters<typeof actualFs.readdir>[1],
|
||||||
|
) => {
|
||||||
|
const normalizedPath = nodePath.normalize(path.toString());
|
||||||
|
if (normalizedPath.includes(nodePath.normalize('.gemini/policies'))) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'user-plan.toml',
|
||||||
|
isFile: () => true,
|
||||||
|
isDirectory: () => false,
|
||||||
|
},
|
||||||
|
] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>;
|
||||||
|
}
|
||||||
|
return actualFs.readdir(
|
||||||
|
path,
|
||||||
|
options as Parameters<typeof actualFs.readdir>[1],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockReadFile = vi.fn(
|
||||||
|
async (
|
||||||
|
path: Parameters<typeof actualFs.readFile>[0],
|
||||||
|
options: Parameters<typeof actualFs.readFile>[1],
|
||||||
|
) => {
|
||||||
|
const normalizedPath = nodePath.normalize(path.toString());
|
||||||
|
if (normalizedPath.includes('user-plan.toml')) {
|
||||||
|
return `
|
||||||
|
[[rule]]
|
||||||
|
toolName = "run_shell_command"
|
||||||
|
commandPrefix = ["git status", "git diff"]
|
||||||
|
decision = "allow"
|
||||||
|
priority = 100
|
||||||
|
modes = ["plan"]
|
||||||
|
|
||||||
|
[[rule]]
|
||||||
|
toolName = "codebase_investigator"
|
||||||
|
decision = "allow"
|
||||||
|
priority = 100
|
||||||
|
modes = ["plan"]
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return actualFs.readFile(path, options);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.doMock('node:fs/promises', () => ({
|
||||||
|
...actualFs,
|
||||||
|
default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
|
||||||
|
readFile: mockReadFile,
|
||||||
|
readdir: mockReaddir,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
const { createPolicyEngineConfig } = await import('./config.js');
|
||||||
|
|
||||||
|
const settings: PolicySettings = {};
|
||||||
|
const config = await createPolicyEngineConfig(
|
||||||
|
settings,
|
||||||
|
ApprovalMode.PLAN,
|
||||||
|
nodePath.join(__dirname, 'policies'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const shellRules = config.rules?.filter(
|
||||||
|
(r) =>
|
||||||
|
r.toolName === 'run_shell_command' &&
|
||||||
|
r.decision === PolicyDecision.ALLOW &&
|
||||||
|
r.modes?.includes(ApprovalMode.PLAN) &&
|
||||||
|
r.argsPattern,
|
||||||
|
);
|
||||||
|
expect(shellRules).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
shellRules?.some((r) => r.argsPattern?.test('{"command":"git status"}')),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shellRules?.some((r) => r.argsPattern?.test('{"command":"git diff"}')),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shellRules?.every(
|
||||||
|
(r) => !r.argsPattern?.test('{"command":"git commit"}'),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const subagentRule = config.rules?.find(
|
||||||
|
(r) =>
|
||||||
|
r.toolName === 'codebase_investigator' &&
|
||||||
|
r.decision === PolicyDecision.ALLOW &&
|
||||||
|
r.modes?.includes(ApprovalMode.PLAN),
|
||||||
|
);
|
||||||
|
expect(subagentRule).toBeDefined();
|
||||||
|
expect(subagentRule?.priority).toBeCloseTo(2.1, 5);
|
||||||
|
|
||||||
|
vi.doUnmock('node:fs/promises');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,12 +31,12 @@
|
|||||||
decision = "deny"
|
decision = "deny"
|
||||||
priority = 60
|
priority = 60
|
||||||
modes = ["plan"]
|
modes = ["plan"]
|
||||||
deny_message = "You are in Plan Mode - adjust your prompt to only use read and search tools."
|
deny_message = "You are in Plan Mode with access to read-only tools. Execution of scripts (including those from skills) is blocked."
|
||||||
|
|
||||||
# Explicitly Allow Read-Only Tools in Plan mode.
|
# Explicitly Allow Read-Only Tools in Plan mode.
|
||||||
|
|
||||||
[[rule]]
|
[[rule]]
|
||||||
toolName = ["glob", "grep_search", "list_directory", "read_file", "google_web_search"]
|
toolName = ["glob", "grep_search", "list_directory", "read_file", "google_web_search", "activate_skill"]
|
||||||
decision = "allow"
|
decision = "allow"
|
||||||
priority = 70
|
priority = 70
|
||||||
modes = ["plan"]
|
modes = ["plan"]
|
||||||
|
|||||||
@@ -2086,4 +2086,44 @@ describe('PolicyEngine', () => {
|
|||||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Plan Mode', () => {
|
||||||
|
it('should allow activate_skill but deny shell commands in Plan Mode', async () => {
|
||||||
|
const rules: PolicyRule[] = [
|
||||||
|
{
|
||||||
|
decision: PolicyDecision.DENY,
|
||||||
|
priority: 60,
|
||||||
|
modes: [ApprovalMode.PLAN],
|
||||||
|
denyMessage:
|
||||||
|
'You are in Plan Mode with access to read-only tools. Execution of scripts (including those from skills) is blocked.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
toolName: 'activate_skill',
|
||||||
|
decision: PolicyDecision.ALLOW,
|
||||||
|
priority: 70,
|
||||||
|
modes: [ApprovalMode.PLAN],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
engine = new PolicyEngine({
|
||||||
|
rules,
|
||||||
|
approvalMode: ApprovalMode.PLAN,
|
||||||
|
});
|
||||||
|
|
||||||
|
const skillResult = await engine.check(
|
||||||
|
{ name: 'activate_skill', args: { name: 'test' } },
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(skillResult.decision).toBe(PolicyDecision.ALLOW);
|
||||||
|
|
||||||
|
const shellResult = await engine.check(
|
||||||
|
{ name: 'run_shell_command', args: { command: 'ls' } },
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(shellResult.decision).toBe(PolicyDecision.DENY);
|
||||||
|
expect(shellResult.rule?.denyMessage).toContain(
|
||||||
|
'Execution of scripts (including those from skills) is blocked',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -103,6 +103,43 @@ describe('McpClientManager', () => {
|
|||||||
expect(manager.getDiscoveryState()).toBe(MCPDiscoveryState.COMPLETED);
|
expect(manager.getDiscoveryState()).toBe(MCPDiscoveryState.COMPLETED);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should mark discovery completed when all configured servers are user-disabled', async () => {
|
||||||
|
mockConfig.getMcpServers.mockReturnValue({
|
||||||
|
'test-server': {},
|
||||||
|
});
|
||||||
|
mockConfig.getMcpEnablementCallbacks.mockReturnValue({
|
||||||
|
isSessionDisabled: vi.fn().mockReturnValue(false),
|
||||||
|
isFileEnabled: vi.fn().mockResolvedValue(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
|
||||||
|
const promise = manager.startConfiguredMcpServers();
|
||||||
|
expect(manager.getDiscoveryState()).toBe(MCPDiscoveryState.IN_PROGRESS);
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(manager.getDiscoveryState()).toBe(MCPDiscoveryState.COMPLETED);
|
||||||
|
expect(manager.getMcpServerCount()).toBe(0);
|
||||||
|
expect(mockedMcpClient.connect).not.toHaveBeenCalled();
|
||||||
|
expect(mockedMcpClient.discover).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark discovery completed when all configured servers are blocked', async () => {
|
||||||
|
mockConfig.getMcpServers.mockReturnValue({
|
||||||
|
'test-server': {},
|
||||||
|
});
|
||||||
|
mockConfig.getBlockedMcpServers.mockReturnValue(['test-server']);
|
||||||
|
|
||||||
|
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
|
||||||
|
const promise = manager.startConfiguredMcpServers();
|
||||||
|
expect(manager.getDiscoveryState()).toBe(MCPDiscoveryState.IN_PROGRESS);
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(manager.getDiscoveryState()).toBe(MCPDiscoveryState.COMPLETED);
|
||||||
|
expect(manager.getMcpServerCount()).toBe(0);
|
||||||
|
expect(mockedMcpClient.connect).not.toHaveBeenCalled();
|
||||||
|
expect(mockedMcpClient.discover).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('should not discover tools if folder is not trusted', async () => {
|
it('should not discover tools if folder is not trusted', async () => {
|
||||||
mockConfig.getMcpServers.mockReturnValue({
|
mockConfig.getMcpServers.mockReturnValue({
|
||||||
'test-server': {},
|
'test-server': {},
|
||||||
|
|||||||
@@ -337,6 +337,15 @@ export class McpClientManager {
|
|||||||
this.maybeDiscoverMcpServer(name, config),
|
this.maybeDiscoverMcpServer(name, config),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If every configured server was skipped (for example because all are
|
||||||
|
// disabled by user settings), no discovery promise is created. In that
|
||||||
|
// case we must still mark discovery complete or the UI will wait forever.
|
||||||
|
if (this.discoveryState === MCPDiscoveryState.IN_PROGRESS) {
|
||||||
|
this.discoveryState = MCPDiscoveryState.COMPLETED;
|
||||||
|
this.eventEmitter?.emit('mcp-client-update', this.clients);
|
||||||
|
}
|
||||||
|
|
||||||
await this.cliConfig.refreshMcpContext();
|
await this.cliConfig.refreshMcpContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export const PLAN_MODE_TOOLS = [
|
|||||||
LS_TOOL_NAME,
|
LS_TOOL_NAME,
|
||||||
WEB_SEARCH_TOOL_NAME,
|
WEB_SEARCH_TOOL_NAME,
|
||||||
ASK_USER_TOOL_NAME,
|
ASK_USER_TOOL_NAME,
|
||||||
|
ACTIVATE_SKILL_TOOL_NAME,
|
||||||
EXIT_PLAN_MODE_TOOL_NAME,
|
EXIT_PLAN_MODE_TOOL_NAME,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { getVersion } from './version.js';
|
import { getVersion, resetVersionCache } from './version.js';
|
||||||
import { getPackageJson } from './package.js';
|
import { getPackageJson } from './package.js';
|
||||||
|
|
||||||
vi.mock('./package.js', () => ({
|
vi.mock('./package.js', () => ({
|
||||||
@@ -17,6 +17,8 @@ describe('version', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
resetVersionCache();
|
||||||
process.env = { ...originalEnv };
|
process.env = { ...originalEnv };
|
||||||
vi.mocked(getPackageJson).mockResolvedValue({ version: '1.0.0' });
|
vi.mocked(getPackageJson).mockResolvedValue({ version: '1.0.0' });
|
||||||
});
|
});
|
||||||
@@ -43,4 +45,20 @@ describe('version', () => {
|
|||||||
const version = await getVersion();
|
const version = await getVersion();
|
||||||
expect(version).toBe('unknown');
|
expect(version).toBe('unknown');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should cache the version and only call getPackageJson once', async () => {
|
||||||
|
delete process.env['CLI_VERSION'];
|
||||||
|
vi.mocked(getPackageJson).mockResolvedValue({ version: '1.2.3' });
|
||||||
|
|
||||||
|
const version1 = await getVersion();
|
||||||
|
expect(version1).toBe('1.2.3');
|
||||||
|
expect(getPackageJson).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Change the mock value to simulate an update on disk
|
||||||
|
vi.mocked(getPackageJson).mockResolvedValue({ version: '2.0.0' });
|
||||||
|
|
||||||
|
const version2 = await getVersion();
|
||||||
|
expect(version2).toBe('1.2.3'); // Should still be the cached version
|
||||||
|
expect(getPackageJson).toHaveBeenCalledTimes(1); // Should not have been called again
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,20 @@ import path from 'node:path';
|
|||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
export async function getVersion(): Promise<string> {
|
let versionPromise: Promise<string> | undefined;
|
||||||
const pkgJson = await getPackageJson(__dirname);
|
|
||||||
return process.env['CLI_VERSION'] || pkgJson?.version || 'unknown';
|
export function getVersion(): Promise<string> {
|
||||||
|
if (versionPromise) {
|
||||||
|
return versionPromise;
|
||||||
|
}
|
||||||
|
versionPromise = (async () => {
|
||||||
|
const pkgJson = await getPackageJson(__dirname);
|
||||||
|
return process.env['CLI_VERSION'] || pkgJson?.version || 'unknown';
|
||||||
|
})();
|
||||||
|
return versionPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For testing purposes only */
|
||||||
|
export function resetVersionCache(): void {
|
||||||
|
versionPromise = undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user