mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 13:11:03 -07:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02c25b7b28 | |||
| 487fb219cc | |||
| d743c6fae6 | |||
| a15568e013 |
@@ -504,12 +504,12 @@ the dedicated [Custom Commands documentation](../cli/custom-commands.md).
|
||||
These shortcuts apply directly to the input prompt for text manipulation.
|
||||
|
||||
- **Undo:**
|
||||
- **Keyboard shortcut:** Press **Alt+z** or **Cmd+z** to undo the last action
|
||||
in the input prompt.
|
||||
- **Keyboard shortcut:** Press **Ctrl+z** (Windows), **Cmd+z** (macOS), or
|
||||
**Alt+z** (Linux/WSL) to undo the last action in the input prompt.
|
||||
|
||||
- **Redo:**
|
||||
- **Keyboard shortcut:** Press **Shift+Alt+Z** or **Shift+Cmd+Z** to redo the
|
||||
last undone action in the input prompt.
|
||||
- **Keyboard shortcut:** Press **Shift+Cmd+Z** (macOS), or **Shift+Alt+Z**
|
||||
(Linux/WSL) to redo the last undone action in the input prompt.
|
||||
|
||||
## At commands (`@`)
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ available combinations.
|
||||
| `edit.deleteWordRight` | Delete the next word. | `Ctrl+Delete`<br />`Alt+Delete`<br />`Alt+D` |
|
||||
| `edit.deleteLeft` | Delete the character to the left. | `Backspace`<br />`Ctrl+H` |
|
||||
| `edit.deleteRight` | Delete the character to the right. | `Delete`<br />`Ctrl+D` |
|
||||
| `edit.undo` | Undo the most recent text edit. | `Cmd/Win+Z`<br />`Alt+Z` |
|
||||
| `edit.undo` | Undo the most recent text edit. | `Ctrl+Z`<br />`Alt+Z`<br />`Cmd/Win+Z` |
|
||||
| `edit.redo` | Redo the most recent undone text edit. | `Ctrl+Shift+Z`<br />`Shift+Cmd/Win+Z`<br />`Alt+Shift+Z` |
|
||||
|
||||
#### Scrolling
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
getAdminErrorMessage,
|
||||
isHeadlessMode,
|
||||
Config,
|
||||
SimpleExtensionLoader,
|
||||
resolveToRealPath,
|
||||
applyAdminAllowlist,
|
||||
applyRequiredServers,
|
||||
@@ -558,6 +559,7 @@ export interface LoadCliConfigOptions {
|
||||
disabled?: string[];
|
||||
};
|
||||
worktreeSettings?: WorktreeSettings;
|
||||
skipExtensions?: boolean;
|
||||
}
|
||||
|
||||
export async function loadCliConfig(
|
||||
@@ -566,7 +568,7 @@ export async function loadCliConfig(
|
||||
argv: CliArgs,
|
||||
options: LoadCliConfigOptions = {},
|
||||
): Promise<Config> {
|
||||
const { cwd = process.cwd(), projectHooks } = options;
|
||||
const { cwd = process.cwd(), projectHooks, skipExtensions = false } = options;
|
||||
const debugMode = isDebugMode(argv);
|
||||
|
||||
const worktreeSettings =
|
||||
@@ -641,21 +643,24 @@ export async function loadCliConfig(
|
||||
includeDirectories.push(...ideFolders);
|
||||
}
|
||||
|
||||
const extensionManager = new ExtensionManager({
|
||||
settings,
|
||||
requestConsent: requestConsentNonInteractive,
|
||||
requestSetting: promptForSetting,
|
||||
workspaceDir: cwd,
|
||||
enabledExtensionOverrides: argv.extensions,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
|
||||
clientVersion: await getVersion(),
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
let extensionManager: ExtensionManager | undefined;
|
||||
if (!skipExtensions) {
|
||||
extensionManager = new ExtensionManager({
|
||||
settings,
|
||||
requestConsent: requestConsentNonInteractive,
|
||||
requestSetting: promptForSetting,
|
||||
workspaceDir: cwd,
|
||||
enabledExtensionOverrides: argv.extensions,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
|
||||
clientVersion: await getVersion(),
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
}
|
||||
|
||||
const extensionPlanSettings = extensionManager
|
||||
.getExtensions()
|
||||
.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
|
||||
?.getExtensions()
|
||||
?.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
|
||||
|
||||
const experimentalJitContext = settings.experimental.jitContext ?? true;
|
||||
|
||||
@@ -673,6 +678,9 @@ export async function loadCliConfig(
|
||||
let fileCount = 0;
|
||||
let filePaths: string[] = [];
|
||||
|
||||
const finalExtensionLoader =
|
||||
extensionManager ?? new SimpleExtensionLoader([]);
|
||||
|
||||
if (!experimentalJitContext) {
|
||||
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
||||
const result = await loadServerHierarchicalMemory(
|
||||
@@ -681,7 +689,7 @@ export async function loadCliConfig(
|
||||
? includeDirectories
|
||||
: [],
|
||||
fileService,
|
||||
extensionManager,
|
||||
finalExtensionLoader,
|
||||
trustedFolder,
|
||||
memoryImportFormat,
|
||||
memoryFileFiltering,
|
||||
@@ -1037,7 +1045,7 @@ export async function loadCliConfig(
|
||||
listSessions: argv.listSessions || false,
|
||||
deleteSession: argv.deleteSession,
|
||||
enabledExtensions: argv.extensions,
|
||||
extensionLoader: extensionManager,
|
||||
extensionLoader: finalExtensionLoader,
|
||||
extensionRegistryURI,
|
||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||
enableAgents: settings.experimental?.enableAgents,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { loadCliConfig, type CliArgs } from './config.js';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { createTestMergedSettings } from './settings.js';
|
||||
|
||||
vi.mock('./extension-manager.js', () => ({
|
||||
ExtensionManager: vi.fn().mockImplementation(() => ({
|
||||
loadExtensions: vi.fn().mockResolvedValue([]),
|
||||
getExtensions: vi.fn().mockReturnValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('loadCliConfig skipExtensions', () => {
|
||||
const settings = createTestMergedSettings();
|
||||
const argv = {
|
||||
query: undefined,
|
||||
model: undefined,
|
||||
sandbox: undefined,
|
||||
debug: undefined,
|
||||
prompt: undefined,
|
||||
promptInteractive: undefined,
|
||||
yolo: undefined,
|
||||
approvalMode: undefined,
|
||||
policy: undefined,
|
||||
adminPolicy: undefined,
|
||||
allowedMcpServerNames: undefined,
|
||||
allowedTools: undefined,
|
||||
extensions: undefined,
|
||||
listExtensions: undefined,
|
||||
resume: undefined,
|
||||
sessionId: undefined,
|
||||
listSessions: undefined,
|
||||
} as unknown as CliArgs;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should load extensions by default', async () => {
|
||||
await loadCliConfig(settings, 'session-id', argv);
|
||||
expect(ExtensionManager).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip extensions when skipExtensions is true', async () => {
|
||||
await loadCliConfig(settings, 'session-id', argv, { skipExtensions: true });
|
||||
expect(ExtensionManager).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -409,6 +409,7 @@ export async function main() {
|
||||
|
||||
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
|
||||
projectHooks: settings.workspace.settings.hooks,
|
||||
skipExtensions: true,
|
||||
});
|
||||
|
||||
adminControlsListner.setConfig(partialConfig);
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
getTransformedImagePath,
|
||||
} from './text-buffer.js';
|
||||
import { cpLen } from '../../utils/textUtils.js';
|
||||
import { type Key } from '../../hooks/useKeypress.js';
|
||||
import { escapePath } from '@google/gemini-cli-core';
|
||||
|
||||
const defaultVisualLayout: VisualLayout = {
|
||||
@@ -1799,6 +1800,229 @@ describe('useTextBuffer', () => {
|
||||
expect(getBufferState(result).text).toBe('');
|
||||
});
|
||||
|
||||
it('should only handle Undo if there is something to undo', async () => {
|
||||
const { result } = await renderHook(() => useTextBuffer({ viewport }));
|
||||
|
||||
// Platform-specific undo key
|
||||
const undoKey: Key =
|
||||
process.platform === 'win32'
|
||||
? {
|
||||
name: 'z',
|
||||
ctrl: true,
|
||||
shift: false,
|
||||
alt: false,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x1a',
|
||||
}
|
||||
: process.platform === 'darwin'
|
||||
? {
|
||||
name: 'z',
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
cmd: true,
|
||||
insertable: false,
|
||||
sequence: '\u001b[122;D',
|
||||
}
|
||||
: {
|
||||
name: 'z',
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
alt: true,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\u001bz',
|
||||
};
|
||||
|
||||
// 1. Initial state: nothing to undo
|
||||
let handled = true;
|
||||
act(() => {
|
||||
handled = result.current.handleInput(undoKey);
|
||||
});
|
||||
expect(handled).toBe(false);
|
||||
|
||||
// 2. Insert something
|
||||
act(() => {
|
||||
result.current.handleInput({
|
||||
name: 'a',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
insertable: true,
|
||||
sequence: 'a',
|
||||
});
|
||||
});
|
||||
expect(getBufferState(result).text).toBe('a');
|
||||
|
||||
// 3. Now undo should work
|
||||
act(() => {
|
||||
handled = result.current.handleInput(undoKey);
|
||||
});
|
||||
expect(handled).toBe(true);
|
||||
expect(getBufferState(result).text).toBe('');
|
||||
|
||||
// 4. Undo again: nothing left to undo
|
||||
act(() => {
|
||||
handled = result.current.handleInput(undoKey);
|
||||
});
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
it('should handle "Ctrl+Z" for smart bubbling on Linux/WSL', async () => {
|
||||
const { result } = await renderHook(() => useTextBuffer({ viewport }));
|
||||
|
||||
const ctrlZ: Key = {
|
||||
name: 'z',
|
||||
ctrl: true,
|
||||
shift: false,
|
||||
alt: false,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x1a',
|
||||
};
|
||||
|
||||
// 1. Empty buffer: should NOT handle (bubble up to Suspend)
|
||||
let handled = true;
|
||||
act(() => {
|
||||
handled = result.current.handleInput(ctrlZ);
|
||||
});
|
||||
expect(handled).toBe(false);
|
||||
|
||||
// 2. Add text
|
||||
act(() => {
|
||||
result.current.handleInput({
|
||||
name: 'x',
|
||||
insertable: true,
|
||||
sequence: 'x',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Has history: should handle (perform Undo)
|
||||
act(() => {
|
||||
handled = result.current.handleInput(ctrlZ);
|
||||
});
|
||||
expect(handled).toBe(true);
|
||||
expect(getBufferState(result).text).toBe('');
|
||||
|
||||
// 4. Empty again: should NOT handle
|
||||
act(() => {
|
||||
handled = result.current.handleInput(ctrlZ);
|
||||
});
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
it('should only handle Redo if there is something to redo', async () => {
|
||||
const { result } = await renderHook(() => useTextBuffer({ viewport }));
|
||||
|
||||
// Platform-specific redo key (first in list)
|
||||
const redoKey: Key =
|
||||
process.platform === 'win32'
|
||||
? {
|
||||
name: 'z',
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
alt: false,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x1a',
|
||||
}
|
||||
: process.platform === 'darwin'
|
||||
? {
|
||||
name: 'z',
|
||||
ctrl: false,
|
||||
shift: true,
|
||||
alt: false,
|
||||
cmd: true,
|
||||
insertable: false,
|
||||
sequence: '\u001b[122;2D',
|
||||
}
|
||||
: {
|
||||
name: 'z',
|
||||
ctrl: false,
|
||||
shift: true,
|
||||
alt: true,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\u001bZ',
|
||||
};
|
||||
|
||||
const undoKey: Key =
|
||||
process.platform === 'win32'
|
||||
? {
|
||||
name: 'z',
|
||||
ctrl: true,
|
||||
shift: false,
|
||||
alt: false,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x1a',
|
||||
}
|
||||
: process.platform === 'darwin'
|
||||
? {
|
||||
name: 'z',
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
cmd: true,
|
||||
insertable: false,
|
||||
sequence: '\u001b[122;D',
|
||||
}
|
||||
: {
|
||||
name: 'z',
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
alt: true,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\u001bz',
|
||||
};
|
||||
|
||||
// 1. Initial state: nothing to redo
|
||||
let handled = true;
|
||||
act(() => {
|
||||
handled = result.current.handleInput(redoKey);
|
||||
});
|
||||
expect(handled).toBe(false);
|
||||
|
||||
// 2. Insert and Undo
|
||||
act(() => {
|
||||
result.current.handleInput({
|
||||
name: 'a',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
insertable: true,
|
||||
sequence: 'a',
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
result.current.handleInput(undoKey);
|
||||
});
|
||||
expect(getBufferState(result).text).toBe('');
|
||||
|
||||
// 3. Now redo should work
|
||||
act(() => {
|
||||
handled = result.current.handleInput(redoKey);
|
||||
});
|
||||
expect(handled).toBe(true);
|
||||
expect(getBufferState(result).text).toBe('a');
|
||||
|
||||
// 4. Redo again: nothing left to redo
|
||||
act(() => {
|
||||
handled = result.current.handleInput(redoKey);
|
||||
});
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle multiple delete characters in one input', async () => {
|
||||
const { result } = await renderHook(() =>
|
||||
useTextBuffer({
|
||||
|
||||
@@ -2889,6 +2889,8 @@ export function useTextBuffer({
|
||||
transformationsByLine,
|
||||
pastedContent,
|
||||
expandedPaste,
|
||||
undoStack,
|
||||
redoStack,
|
||||
} = state;
|
||||
|
||||
const text = useMemo(() => lines.join('\n'), [lines]);
|
||||
@@ -3454,10 +3456,16 @@ export function useTextBuffer({
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.UNDO](key)) {
|
||||
if (undoStack.length === 0) {
|
||||
return false;
|
||||
}
|
||||
undo();
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.REDO](key)) {
|
||||
if (redoStack.length === 0) {
|
||||
return false;
|
||||
}
|
||||
redo();
|
||||
return true;
|
||||
}
|
||||
@@ -3486,6 +3494,8 @@ export function useTextBuffer({
|
||||
visualCursor,
|
||||
visualLines,
|
||||
keyMatchers,
|
||||
undoStack.length,
|
||||
redoStack.length,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -108,6 +108,30 @@ describe('keyBindings config', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should have platform-specific UNDO bindings', () => {
|
||||
const undoBindings = defaultKeyBindingConfig.get(Command.UNDO);
|
||||
if (process.platform === 'win32') {
|
||||
expect(undoBindings?.[0].name).toBe('z');
|
||||
expect(undoBindings?.[0].ctrl).toBe(true);
|
||||
} else if (process.platform === 'darwin') {
|
||||
expect(undoBindings?.[0].name).toBe('z');
|
||||
expect(undoBindings?.[0].cmd).toBe(true);
|
||||
} else {
|
||||
expect(undoBindings?.[0].name).toBe('z');
|
||||
expect(undoBindings?.[0].alt).toBe(true);
|
||||
// Ensure ctrl+z is also present for smart bubbling
|
||||
expect(undoBindings?.some((b) => b.name === 'z' && b.ctrl)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have platform-specific REDO bindings', () => {
|
||||
const redoBindings = defaultKeyBindingConfig.get(Command.REDO);
|
||||
// Ctrl+Shift+Z is now the universal primary to avoid conflict with YOLO (Ctrl+Y)
|
||||
expect(redoBindings?.[0].name).toBe('z');
|
||||
expect(redoBindings?.[0].shift).toBe(true);
|
||||
expect(redoBindings?.[0].ctrl).toBe(true);
|
||||
});
|
||||
|
||||
describe('command metadata', () => {
|
||||
const commandValues = Object.values(Command);
|
||||
|
||||
|
||||
@@ -312,15 +312,8 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
|
||||
Command.DELETE_CHAR_RIGHT,
|
||||
[new KeyBinding('delete'), new KeyBinding('ctrl+d')],
|
||||
],
|
||||
[Command.UNDO, [new KeyBinding('cmd+z'), new KeyBinding('alt+z')]],
|
||||
[
|
||||
Command.REDO,
|
||||
[
|
||||
new KeyBinding('ctrl+shift+z'),
|
||||
new KeyBinding('cmd+shift+z'),
|
||||
new KeyBinding('alt+shift+z'),
|
||||
],
|
||||
],
|
||||
[Command.UNDO, getPlatformUndoBindings(process.platform)],
|
||||
[Command.REDO, getPlatformRedoBindings(process.platform)],
|
||||
|
||||
// Scrolling
|
||||
[Command.SCROLL_UP, [new KeyBinding('shift+up')]],
|
||||
@@ -782,3 +775,33 @@ export async function loadCustomKeybindings(): Promise<{
|
||||
|
||||
return { config, errors };
|
||||
}
|
||||
|
||||
export function getPlatformUndoBindings(
|
||||
platform: string,
|
||||
): readonly KeyBinding[] {
|
||||
if (platform === 'win32') {
|
||||
return [new KeyBinding('ctrl+z'), new KeyBinding('alt+z')];
|
||||
}
|
||||
if (platform === 'darwin') {
|
||||
return [new KeyBinding('cmd+z'), new KeyBinding('alt+z')];
|
||||
}
|
||||
// Linux / WSL: Promote Alt+Z to avoid Windows interception,
|
||||
// but keep Ctrl+Z for smart bubbling.
|
||||
return [
|
||||
new KeyBinding('alt+z'),
|
||||
new KeyBinding('cmd+z'),
|
||||
new KeyBinding('ctrl+z'),
|
||||
];
|
||||
}
|
||||
|
||||
export function getPlatformRedoBindings(
|
||||
_platform: string,
|
||||
): readonly KeyBinding[] {
|
||||
// Use a stable order for all platforms to minimize churn.
|
||||
// Ctrl+Shift+Z is the universal primary.
|
||||
return [
|
||||
new KeyBinding('ctrl+shift+z'),
|
||||
new KeyBinding('cmd+shift+z'),
|
||||
new KeyBinding('alt+shift+z'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -149,23 +149,44 @@ describe('keyMatchers', () => {
|
||||
{
|
||||
command: Command.UNDO,
|
||||
positive: [
|
||||
createKey('z', { shift: false, cmd: true }),
|
||||
createKey('z', { shift: false, alt: true }),
|
||||
...(process.platform === 'win32'
|
||||
? [createKey('z', { shift: false, ctrl: true })]
|
||||
: process.platform === 'darwin'
|
||||
? [createKey('z', { shift: false, cmd: true })]
|
||||
: [
|
||||
createKey('z', { shift: false, alt: true }),
|
||||
createKey('z', { shift: false, cmd: true }),
|
||||
createKey('z', { shift: false, ctrl: true }),
|
||||
]),
|
||||
...(process.platform !== 'linux'
|
||||
? [createKey('z', { shift: false, alt: true })]
|
||||
: []),
|
||||
],
|
||||
negative: [
|
||||
createKey('z'),
|
||||
createKey('z', { shift: true, cmd: true }),
|
||||
createKey('z', { shift: false, ctrl: true }),
|
||||
...(process.platform === 'darwin'
|
||||
? [createKey('z', { shift: false, ctrl: true })]
|
||||
: []),
|
||||
...(process.platform === 'win32'
|
||||
? [createKey('z', { shift: false, cmd: true })]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
command: Command.REDO,
|
||||
positive: [
|
||||
createKey('z', { shift: true, cmd: true }),
|
||||
...(process.platform === 'win32'
|
||||
? []
|
||||
: [createKey('z', { shift: true, cmd: true })]),
|
||||
createKey('z', { shift: true, alt: true }),
|
||||
createKey('z', { shift: true, ctrl: true }),
|
||||
],
|
||||
negative: [createKey('z'), createKey('z', { shift: false, cmd: true })],
|
||||
negative: [
|
||||
createKey('z'),
|
||||
createKey('z', { shift: false, cmd: true }),
|
||||
createKey('y', { shift: false, ctrl: true }),
|
||||
],
|
||||
},
|
||||
|
||||
// Screen control
|
||||
|
||||
@@ -366,5 +366,71 @@ describe('commentJson', () => {
|
||||
|
||||
expect(updatedContent).toContain('// This should be preserved');
|
||||
});
|
||||
|
||||
it('should skip write if logical content has not changed', async () => {
|
||||
const originalContent = `{
|
||||
"context": {
|
||||
"fileName": ["AGENTS.md", "GEMINI.md"]
|
||||
}
|
||||
}\n`;
|
||||
|
||||
fs.writeFileSync(testFilePath, originalContent, 'utf-8');
|
||||
const originalMtime = fs.statSync(testFilePath).mtimeMs;
|
||||
|
||||
// Wait a bit to ensure mtime would change if a write happened
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
updateSettingsFilePreservingFormat(testFilePath, {
|
||||
context: {
|
||||
fileName: ['AGENTS.md', 'GEMINI.md'],
|
||||
},
|
||||
});
|
||||
|
||||
const newMtime = fs.statSync(testFilePath).mtimeMs;
|
||||
expect(newMtime).toBe(originalMtime);
|
||||
|
||||
const updatedContent = fs.readFileSync(testFilePath, 'utf-8');
|
||||
expect(updatedContent).toBe(originalContent);
|
||||
});
|
||||
|
||||
it('should preserve trailing newline on legitimate update', () => {
|
||||
const originalContent = `{
|
||||
"model": "gemini-2.5-pro"
|
||||
}\n`;
|
||||
|
||||
fs.writeFileSync(testFilePath, originalContent, 'utf-8');
|
||||
|
||||
updateSettingsFilePreservingFormat(testFilePath, {
|
||||
model: 'gemini-2.5-flash',
|
||||
});
|
||||
|
||||
const updatedContent = fs.readFileSync(testFilePath, 'utf-8');
|
||||
expect(updatedContent).toMatch(/\n$/);
|
||||
expect(updatedContent).toContain('"model": "gemini-2.5-flash"');
|
||||
});
|
||||
|
||||
it('should add trailing newline to new files', () => {
|
||||
updateSettingsFilePreservingFormat(testFilePath, {
|
||||
model: 'gemini-2.5-pro',
|
||||
});
|
||||
|
||||
const content = fs.readFileSync(testFilePath, 'utf-8');
|
||||
expect(content).toMatch(/\n$/);
|
||||
});
|
||||
|
||||
it('should NOT add trailing newline if original file did not have one', () => {
|
||||
const originalContent = `{
|
||||
"model": "gemini-2.5-pro"
|
||||
}`;
|
||||
|
||||
fs.writeFileSync(testFilePath, originalContent, 'utf-8');
|
||||
|
||||
updateSettingsFilePreservingFormat(testFilePath, {
|
||||
model: 'gemini-2.5-flash',
|
||||
});
|
||||
|
||||
const updatedContent = fs.readFileSync(testFilePath, 'utf-8');
|
||||
expect(updatedContent).not.toMatch(/\n$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import * as fs from 'node:fs';
|
||||
import { parse, stringify } from 'comment-json';
|
||||
import { coreEvents } from '@google/gemini-cli-core';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
|
||||
/**
|
||||
* Type representing an object that may contain Symbol keys for comments.
|
||||
@@ -21,16 +22,23 @@ export function updateSettingsFilePreservingFormat(
|
||||
updates: Record<string, unknown>,
|
||||
): void {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(filePath, JSON.stringify(updates, null, 2), 'utf-8');
|
||||
fs.writeFileSync(filePath, JSON.stringify(updates, null, 2) + '\n', 'utf-8');
|
||||
return;
|
||||
}
|
||||
|
||||
const originalContent = fs.readFileSync(filePath, 'utf-8');
|
||||
const hasTrailingNewline = originalContent.endsWith('\n');
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
let cleanParsed: Record<string, unknown>;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
parsed = parse(originalContent) as Record<string, unknown>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
cleanParsed = JSON.parse(stripJsonComments(originalContent)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
@@ -40,10 +48,57 @@ export function updateSettingsFilePreservingFormat(
|
||||
return;
|
||||
}
|
||||
|
||||
// First, check if logical content would change using the clean parse
|
||||
const updatedClean = applyUpdates(structuredClone(cleanParsed), updates);
|
||||
if (deepEqual(cleanParsed, updatedClean)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If content changed, apply to the version with comments and write
|
||||
const updatedStructure = applyUpdates(parsed, updates);
|
||||
const updatedContent = stringify(updatedStructure, null, 2);
|
||||
const finalContent = hasTrailingNewline
|
||||
? updatedContent + '\n'
|
||||
: updatedContent;
|
||||
|
||||
fs.writeFileSync(filePath, updatedContent, 'utf-8');
|
||||
fs.writeFileSync(filePath, finalContent, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a deep equality check on two objects, ignoring Symbol keys.
|
||||
* This is used to compare comment-json parsed objects without their comment metadata.
|
||||
*/
|
||||
function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
|
||||
if (a && b && typeof a === 'object' && typeof b === 'object') {
|
||||
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
||||
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (!deepEqual(a[i], b[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const keysA = Object.getOwnPropertyNames(a);
|
||||
const keysB = Object.getOwnPropertyNames(b);
|
||||
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
|
||||
for (const key of keysA) {
|
||||
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
if (!deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -140,6 +140,49 @@ describe('readStdin', () => {
|
||||
expect(mockStdin.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should truncate multi-byte characters at byte boundary', async () => {
|
||||
const MAX_STDIN_SIZE = 8 * 1024 * 1024;
|
||||
// '한' is 3 bytes. 2,796,202 * 3 = 8,388,606 bytes.
|
||||
// 2,796,203 * 3 = 8,388,609 bytes.
|
||||
const charCount = Math.floor(MAX_STDIN_SIZE / 3) + 1;
|
||||
const multiByteChunk = '한'.repeat(charCount);
|
||||
|
||||
mockStdin.read
|
||||
.mockReturnValueOnce(multiByteChunk)
|
||||
.mockReturnValueOnce(null);
|
||||
|
||||
const promise = readStdin();
|
||||
onReadableHandler();
|
||||
|
||||
const result = await promise;
|
||||
const resultBytes = Buffer.byteLength(result, 'utf8');
|
||||
|
||||
expect(resultBytes).toBeLessThanOrEqual(MAX_STDIN_SIZE);
|
||||
expect(resultBytes).toBe(Math.floor(MAX_STDIN_SIZE / 3) * 3);
|
||||
expect(result).not.toContain('\uFFFD'); // No replacement characters
|
||||
});
|
||||
|
||||
it('should use byte length instead of string length for limit', async () => {
|
||||
const MAX_STDIN_SIZE = 8 * 1024 * 1024;
|
||||
// '한' is 3 bytes. If we use string length, we'd allow 8M characters = 24MB.
|
||||
// We want to ensure it stops at 8MB.
|
||||
const charCount = MAX_STDIN_SIZE; // 8M characters = 24MB
|
||||
const multiByteChunk = '한'.repeat(charCount);
|
||||
|
||||
mockStdin.read
|
||||
.mockReturnValueOnce(multiByteChunk)
|
||||
.mockReturnValueOnce(null);
|
||||
|
||||
const promise = readStdin();
|
||||
onReadableHandler();
|
||||
|
||||
const result = await promise;
|
||||
expect(Buffer.byteLength(result, 'utf8')).toBeLessThanOrEqual(
|
||||
MAX_STDIN_SIZE,
|
||||
);
|
||||
expect(result.length).toBeLessThan(charCount);
|
||||
});
|
||||
|
||||
it('should handle stdin error', async () => {
|
||||
const promise = readStdin();
|
||||
const error = new Error('stdin error');
|
||||
|
||||
@@ -6,6 +6,23 @@
|
||||
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
* Truncates a string to fit within a UTF-8 byte limit without splitting
|
||||
* multi-byte characters. Walks back from the cut point to find the last
|
||||
* complete character boundary.
|
||||
*/
|
||||
function truncateUtf8Bytes(str: string, maxBytes: number): string {
|
||||
const buf = Buffer.from(str, 'utf8');
|
||||
if (buf.length <= maxBytes) return str;
|
||||
let end = maxBytes;
|
||||
// Walk backward past any UTF-8 continuation bytes (10xxxxxx)
|
||||
while (end > 0 && (buf[end] & 0xc0) === 0x80) {
|
||||
end--;
|
||||
}
|
||||
// end now points to the lead byte of an incomplete sequence — exclude it
|
||||
return buf.subarray(0, end).toString('utf8');
|
||||
}
|
||||
|
||||
export async function readStdin(): Promise<string> {
|
||||
const MAX_STDIN_SIZE = 8 * 1024 * 1024; // 8MB
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -30,9 +47,10 @@ export async function readStdin(): Promise<string> {
|
||||
pipedInputTimerId = null;
|
||||
}
|
||||
|
||||
if (totalSize + chunk.length > MAX_STDIN_SIZE) {
|
||||
const remainingSize = MAX_STDIN_SIZE - totalSize;
|
||||
data += chunk.slice(0, remainingSize);
|
||||
const chunkByteLength = Buffer.byteLength(chunk, 'utf8');
|
||||
if (totalSize + chunkByteLength > MAX_STDIN_SIZE) {
|
||||
const remainingBytes = MAX_STDIN_SIZE - totalSize;
|
||||
data += truncateUtf8Bytes(chunk, remainingBytes);
|
||||
debugLogger.warn(
|
||||
`Warning: stdin input truncated to ${MAX_STDIN_SIZE} bytes.`,
|
||||
);
|
||||
@@ -41,7 +59,7 @@ export async function readStdin(): Promise<string> {
|
||||
break;
|
||||
}
|
||||
data += chunk;
|
||||
totalSize += chunk.length;
|
||||
totalSize += chunkByteLength;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
commandCategories,
|
||||
commandDescriptions,
|
||||
defaultKeyBindingConfig,
|
||||
Command,
|
||||
getPlatformUndoBindings,
|
||||
getPlatformRedoBindings,
|
||||
} from '../packages/cli/src/ui/key/keyBindings.js';
|
||||
import {
|
||||
formatWithPrettier,
|
||||
@@ -81,14 +84,54 @@ export async function main(argv = process.argv.slice(2)) {
|
||||
export function buildDefaultDocSections(): readonly KeybindingDocSection[] {
|
||||
return commandCategories.map((category) => ({
|
||||
title: category.title,
|
||||
commands: category.commands.map((command) => ({
|
||||
command: command,
|
||||
description: commandDescriptions[command],
|
||||
bindings: defaultKeyBindingConfig.get(command) ?? [],
|
||||
})),
|
||||
commands: category.commands.map((command) => {
|
||||
// For UNDO and REDO, we want to show all platform variants in the docs
|
||||
if (command === Command.UNDO) {
|
||||
return {
|
||||
command: command,
|
||||
description: commandDescriptions[command],
|
||||
bindings: getMergedPlatformBindings(getPlatformUndoBindings),
|
||||
};
|
||||
}
|
||||
if (command === Command.REDO) {
|
||||
return {
|
||||
command: command,
|
||||
description: commandDescriptions[command],
|
||||
bindings: getMergedPlatformBindings(getPlatformRedoBindings),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: command,
|
||||
description: commandDescriptions[command],
|
||||
bindings: defaultKeyBindingConfig.get(command) ?? [],
|
||||
};
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
function getMergedPlatformBindings(
|
||||
getBindings: (platform: string) => readonly KeyBinding[],
|
||||
): readonly KeyBinding[] {
|
||||
const win32 = getBindings('win32');
|
||||
const darwin = getBindings('darwin');
|
||||
const linux = getBindings('linux');
|
||||
|
||||
const all = [...win32, ...darwin, ...linux];
|
||||
const seen = new Set<string>();
|
||||
const unique: KeyBinding[] = [];
|
||||
|
||||
for (const b of all) {
|
||||
const key = `${b.name}-${b.ctrl}-${b.shift}-${b.alt}-${b.cmd}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
unique.push(b);
|
||||
}
|
||||
}
|
||||
|
||||
return unique;
|
||||
}
|
||||
|
||||
export function renderDocumentation(
|
||||
sections: readonly KeybindingDocSection[],
|
||||
): string {
|
||||
|
||||
Reference in New Issue
Block a user