Merge branch 'main' into cb/genericlist

This commit is contained in:
Christine Betts
2026-02-11 14:28:41 -05:00
22 changed files with 663 additions and 228 deletions
+80 -3
View File
@@ -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
+13 -3
View File
@@ -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
+1
View File
@@ -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:
+55
View File
@@ -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.
+10 -19
View File
@@ -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
View File
@@ -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);
+102 -126
View File
@@ -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();
});
} }
}); });
}); });
+20 -9
View File
@@ -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(() => {
+11 -4
View File
@@ -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();
+24 -12
View File
@@ -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();
+16 -3
View File
@@ -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);
+4
View File
@@ -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();
}); });
+103
View File
@@ -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');
});
}); });
+2 -2
View File
@@ -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();
} }
+1
View File
@@ -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;
+19 -1
View File
@@ -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
});
}); });
+16 -3
View File
@@ -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;
} }