This commit is contained in:
Christine Betts
2026-02-13 11:06:19 -05:00
223 changed files with 11042 additions and 5850 deletions
-7
View File
@@ -128,13 +128,6 @@ async function addMcpServer(
settings.setValue(settingsScope, 'mcpServers', mcpServers);
if (transport === 'stdio') {
debugLogger.warn(
'Security Warning: Running MCP servers with stdio transport can expose inherited environment variables. ' +
'While the Gemini CLI redacts common API keys and secrets by default, you should only run servers from trusted sources.',
);
}
if (isExistingServer) {
debugLogger.log(`MCP server "${name}" updated in ${scope} settings.`);
} else {
+24
View File
@@ -141,6 +141,10 @@ vi.mock('@google/gemini-cli-core', async () => {
defaultDecision: ServerConfig.PolicyDecision.ASK_USER,
approvalMode: ServerConfig.ApprovalMode.DEFAULT,
})),
getAdminErrorMessage: vi.fn(
(_feature) =>
`YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli`,
),
isHeadlessMode: vi.fn((opts) => {
if (process.env['VITEST'] === 'true') {
return (
@@ -3192,6 +3196,26 @@ describe('Policy Engine Integration in loadCliConfig', () => {
expect.anything(),
);
});
it('should pass user-provided policy paths from --policy flag to createPolicyEngineConfig', async () => {
process.argv = [
'node',
'script.js',
'--policy',
'/path/to/policy1.toml,/path/to/policy2.toml',
];
const settings = createTestMergedSettings();
const argv = await parseArguments(settings);
await loadCliConfig(settings, 'test-session', argv);
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
policyPaths: ['/path/to/policy1.toml', '/path/to/policy2.toml'],
}),
expect.anything(),
);
});
});
describe('loadCliConfig disableYoloMode', () => {
+25 -4
View File
@@ -75,6 +75,7 @@ export interface CliArgs {
yolo: boolean | undefined;
approvalMode: string | undefined;
policy: string[] | undefined;
allowedMcpServerNames: string[] | undefined;
allowedTools: string[] | undefined;
experimentalAcp: boolean | undefined;
@@ -158,6 +159,21 @@ export async function parseArguments(
description:
'Set the approval mode: default (prompt for approval), auto_edit (auto-approve edit tools), yolo (auto-approve all tools), plan (read-only mode)',
})
.option('policy', {
type: 'array',
string: true,
nargs: 1,
description:
'Additional policy files or directories to load (comma-separated or multiple --policy)',
coerce: (policies: string[]) =>
// Handle comma-separated values
policies.flatMap((p) =>
p
.split(',')
.map((s) => s.trim())
.filter(Boolean),
),
})
.option('experimental-acp', {
type: 'boolean',
description: 'Starts the agent in ACP mode',
@@ -177,7 +193,8 @@ export async function parseArguments(
type: 'array',
string: true,
nargs: 1,
description: 'Tools that are allowed to run without confirmation',
description:
'[DEPRECATED: Use Policy Engine instead See https://geminicli.com/docs/core/policy-engine] Tools that are allowed to run without confirmation',
coerce: (tools: string[]) =>
// Handle comma-separated values
tools.flatMap((tool) => tool.split(',').map((t) => t.trim())),
@@ -445,7 +462,11 @@ export async function loadCliConfig(
process.env['VITEST'] === 'true'
? false
: (settings.security?.folderTrust?.enabled ?? false);
const trustedFolder = isWorkspaceTrusted(settings, cwd)?.isTrusted ?? false;
const trustedFolder =
isWorkspaceTrusted(settings, cwd, undefined, {
prompt: argv.prompt,
query: argv.query,
})?.isTrusted ?? false;
// Set the context filename in the server's memoryTool module BEFORE loading memory
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
@@ -602,8 +623,7 @@ export async function loadCliConfig(
const interactive =
!!argv.promptInteractive ||
!!argv.experimentalAcp ||
(!isHeadlessMode({ prompt: argv.prompt }) &&
!argv.query &&
(!isHeadlessMode({ prompt: argv.prompt, query: argv.query }) &&
!argv.isCommand);
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
@@ -666,6 +686,7 @@ export async function loadCliConfig(
...settings.mcp,
allowed: argv.allowedMcpServerNames ?? settings.mcp?.allowed,
},
policyPaths: argv.policy,
};
const policyEngineConfig = await createPolicyEngineConfig(
+6 -9
View File
@@ -129,7 +129,7 @@ export type KeyBindingConfig = {
export const defaultKeyBindings: KeyBindingConfig = {
// Basic Controls
[Command.RETURN]: [{ key: 'return' }],
[Command.ESCAPE]: [{ key: 'escape' }],
[Command.ESCAPE]: [{ key: 'escape' }, { key: '[', ctrl: true }],
[Command.QUIT]: [{ key: 'c', ctrl: true }],
[Command.EXIT]: [{ key: 'd', ctrl: true }],
@@ -286,10 +286,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: [{ key: 'tab', shift: false }],
[Command.BACKGROUND_SHELL_SELECT]: [{ key: 'return' }],
[Command.BACKGROUND_SHELL_ESCAPE]: [{ key: 'escape' }],
[Command.SHOW_MORE_LINES]: [
{ key: 'o', ctrl: true },
{ key: 's', ctrl: true },
],
[Command.SHOW_MORE_LINES]: [{ key: 'o', ctrl: true }],
[Command.EXPAND_PASTE]: [{ key: 'o', ctrl: true }],
[Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }],
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }],
@@ -501,7 +498,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.CYCLE_APPROVAL_MODE]:
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
[Command.SHOW_MORE_LINES]:
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
'Expand and collapse blocks of content when not in alternate buffer mode.',
[Command.EXPAND_PASTE]:
'Expand or collapse a paste placeholder when cursor is over placeholder.',
[Command.BACKGROUND_SHELL_SELECT]:
@@ -516,12 +513,12 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]:
'Move focus from background shell list to Gemini.',
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]:
'Show warning when trying to unfocus background shell via Tab.',
'Show warning when trying to move focus away from background shell.',
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]:
'Show warning when trying to unfocus shell input via Tab.',
'Show warning when trying to move focus away from shell input.',
[Command.FOCUS_SHELL_INPUT]: 'Move focus from Gemini to the active shell.',
[Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.',
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
[Command.RESTART_APP]: 'Restart the application.',
[Command.SUSPEND_APP]: 'Suspend the application (not yet implemented).',
[Command.SUSPEND_APP]: 'Suspend the CLI and move it to the background.',
};
@@ -336,9 +336,9 @@ describe('Policy Engine Integration Tests', () => {
// Valid plan file paths
const validPaths = [
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/my-plan.md',
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/feature_auth.md',
'/home/user/.gemini/tmp/new-temp_dir_123/plans/plan.md', // new style of temp directory
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/session-1/plans/my-plan.md',
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/session-1/plans/feature_auth.md',
'/home/user/.gemini/tmp/new-temp_dir_123/session-1/plans/plan.md', // new style of temp directory
];
for (const file_path of validPaths) {
@@ -365,7 +365,6 @@ describe('Policy Engine Integration Tests', () => {
'/project/src/file.ts', // Workspace
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js', // Wrong extension
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/subdir/plan.md', // Subdirectory
'/home/user/.gemini/non-tmp/new-temp_dir_123/plans/plan.md', // outside of temp dir
];
+1
View File
@@ -25,6 +25,7 @@ export async function createPolicyEngineConfig(
mcp: settings.mcp,
tools: settings.tools,
mcpServers: settings.mcpServers,
policyPaths: settings.policyPaths,
};
return createCorePolicyEngineConfig(policySettings, approvalMode);
+44
View File
@@ -2546,6 +2546,50 @@ describe('Settings Loading and Merging', () => {
});
});
describe('Reactivity & Snapshots', () => {
let loadedSettings: LoadedSettings;
beforeEach(() => {
const emptySettingsFile: SettingsFile = {
path: '/mock/path',
settings: {},
originalSettings: {},
};
loadedSettings = new LoadedSettings(
{ ...emptySettingsFile, path: getSystemSettingsPath() },
{ ...emptySettingsFile, path: getSystemDefaultsPath() },
{ ...emptySettingsFile, path: USER_SETTINGS_PATH },
{ ...emptySettingsFile, path: MOCK_WORKSPACE_SETTINGS_PATH },
true, // isTrusted
[],
);
});
it('getSnapshot() should return stable reference if no changes occur', () => {
const snap1 = loadedSettings.getSnapshot();
const snap2 = loadedSettings.getSnapshot();
expect(snap1).toBe(snap2);
});
it('setValue() should create a new snapshot reference and emit event', () => {
const oldSnapshot = loadedSettings.getSnapshot();
const oldUserRef = oldSnapshot.user.settings;
loadedSettings.setValue(SettingScope.User, 'ui.theme', 'high-contrast');
const newSnapshot = loadedSettings.getSnapshot();
expect(newSnapshot).not.toBe(oldSnapshot);
expect(newSnapshot.user.settings).not.toBe(oldUserRef);
expect(newSnapshot.user.settings.ui?.theme).toBe('high-contrast');
expect(newSnapshot.system.settings).not.toBe(oldSnapshot.system.settings);
expect(mockCoreEvents.emitSettingsChanged).toHaveBeenCalled();
});
});
describe('Security and Sandbox', () => {
let originalArgv: string[];
let originalEnv: NodeJS.ProcessEnv;
+48
View File
@@ -10,6 +10,7 @@ import { platform } from 'node:os';
import * as dotenv from 'dotenv';
import process from 'node:process';
import {
CoreEvent,
FatalConfigError,
GEMINI_DIR,
getErrorMessage,
@@ -284,6 +285,20 @@ export function createTestMergedSettings(
) as MergedSettings;
}
/**
* An immutable snapshot of settings state.
* Used with useSyncExternalStore for reactive updates.
*/
export interface LoadedSettingsSnapshot {
system: SettingsFile;
systemDefaults: SettingsFile;
user: SettingsFile;
workspace: SettingsFile;
isTrusted: boolean;
errors: SettingsError[];
merged: MergedSettings;
}
export class LoadedSettings {
constructor(
system: SettingsFile,
@@ -303,6 +318,7 @@ export class LoadedSettings {
: this.createEmptyWorkspace(workspace);
this.errors = errors;
this._merged = this.computeMergedSettings();
this._snapshot = this.computeSnapshot();
}
readonly system: SettingsFile;
@@ -314,6 +330,7 @@ export class LoadedSettings {
private _workspaceFile: SettingsFile;
private _merged: MergedSettings;
private _snapshot: LoadedSettingsSnapshot;
private _remoteAdminSettings: Partial<Settings> | undefined;
get merged(): MergedSettings {
@@ -368,6 +385,36 @@ export class LoadedSettings {
return merged;
}
private computeSnapshot(): LoadedSettingsSnapshot {
const cloneSettingsFile = (file: SettingsFile): SettingsFile => ({
path: file.path,
rawJson: file.rawJson,
settings: structuredClone(file.settings),
originalSettings: structuredClone(file.originalSettings),
});
return {
system: cloneSettingsFile(this.system),
systemDefaults: cloneSettingsFile(this.systemDefaults),
user: cloneSettingsFile(this.user),
workspace: cloneSettingsFile(this.workspace),
isTrusted: this.isTrusted,
errors: [...this.errors],
merged: structuredClone(this._merged),
};
}
// Passing this along with getSnapshot to useSyncExternalStore allows for idiomatic reactivity on settings changes
// React will pass a listener fn into this subscribe fn
// that listener fn will perform an object identity check on the snapshot and trigger a React re render if the snapshot has changed
subscribe(listener: () => void): () => void {
coreEvents.on(CoreEvent.SettingsChanged, listener);
return () => coreEvents.off(CoreEvent.SettingsChanged, listener);
}
getSnapshot(): LoadedSettingsSnapshot {
return this._snapshot;
}
forScope(scope: LoadableSettingScope): SettingsFile {
switch (scope) {
case SettingScope.User:
@@ -409,6 +456,7 @@ export class LoadedSettings {
}
this._merged = this.computeMergedSettings();
this._snapshot = this.computeSnapshot();
coreEvents.emitSettingsChanged();
}
+12
View File
@@ -152,6 +152,18 @@ const SETTINGS_SCHEMA = {
},
},
policyPaths: {
type: 'array',
label: 'Policy Paths',
category: 'Advanced',
requiresRestart: true,
default: [] as string[],
description: 'Additional policy files or directories to load.',
showInDialog: false,
items: { type: 'string' },
mergeStrategy: MergeStrategy.UNION,
},
general: {
type: 'object',
label: 'General',
@@ -449,6 +449,14 @@ describe('Trusted Folders', () => {
false,
);
});
it('should return true for isPathTrusted when isHeadlessMode is true', async () => {
const geminiCore = await import('@google/gemini-cli-core');
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true);
const folders = loadTrustedFolders();
expect(folders.isPathTrusted('/any-untrusted-path')).toBe(true);
});
});
describe('Trusted Folders Caching', () => {
+18 -3
View File
@@ -17,6 +17,7 @@ import {
homedir,
isHeadlessMode,
coreEvents,
type HeadlessModeOptions,
} from '@google/gemini-cli-core';
import type { Settings } from './settings.js';
import stripJsonComments from 'strip-json-comments';
@@ -128,7 +129,11 @@ export class LoadedTrustedFolders {
isPathTrusted(
location: string,
config?: Record<string, TrustLevel>,
headlessOptions?: HeadlessModeOptions,
): boolean | undefined {
if (isHeadlessMode(headlessOptions)) {
return true;
}
const configToUse = config ?? this.user.config;
// Resolve location to its realpath for canonical comparison
@@ -333,6 +338,7 @@ export function isFolderTrustEnabled(settings: Settings): boolean {
function getWorkspaceTrustFromLocalConfig(
workspaceDir: string,
trustConfig?: Record<string, TrustLevel>,
headlessOptions?: HeadlessModeOptions,
): TrustResult {
const folders = loadTrustedFolders();
const configToUse = trustConfig ?? folders.user.config;
@@ -346,7 +352,11 @@ function getWorkspaceTrustFromLocalConfig(
);
}
const isTrusted = folders.isPathTrusted(workspaceDir, configToUse);
const isTrusted = folders.isPathTrusted(
workspaceDir,
configToUse,
headlessOptions,
);
return {
isTrusted,
source: isTrusted !== undefined ? 'file' : undefined,
@@ -357,8 +367,9 @@ export function isWorkspaceTrusted(
settings: Settings,
workspaceDir: string = process.cwd(),
trustConfig?: Record<string, TrustLevel>,
headlessOptions?: HeadlessModeOptions,
): TrustResult {
if (isHeadlessMode()) {
if (isHeadlessMode(headlessOptions)) {
return { isTrusted: true, source: undefined };
}
@@ -372,5 +383,9 @@ export function isWorkspaceTrusted(
}
// Fall back to the local user configuration
return getWorkspaceTrustFromLocalConfig(workspaceDir, trustConfig);
return getWorkspaceTrustFromLocalConfig(
workspaceDir,
trustConfig,
headlessOptions,
);
}
+1
View File
@@ -464,6 +464,7 @@ describe('gemini.tsx main function kitty protocol', () => {
query: undefined,
yolo: undefined,
approvalMode: undefined,
policy: undefined,
allowedMcpServerNames: undefined,
allowedTools: undefined,
experimentalAcp: undefined,
+26 -3
View File
@@ -89,7 +89,6 @@ import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
import { useTerminalSize } from './ui/hooks/useTerminalSize.js';
import {
relaunchAppInChildProcess,
relaunchOnExitCode,
@@ -104,6 +103,7 @@ import { TerminalProvider } from './ui/contexts/TerminalContext.js';
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
import { profiler } from './ui/components/DebugProfiler.js';
import { runDeferredCommand } from './deferred.js';
import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js';
const SLOW_RENDER_MS = 200;
@@ -220,7 +220,6 @@ export async function startInteractiveUI(
// Create wrapper component to use hooks inside render
const AppWrapper = () => {
useKittyKeyboardProtocol();
const { columns, rows } = useTerminalSize();
return (
<SettingsContext.Provider value={settings}>
@@ -239,7 +238,6 @@ export async function startInteractiveUI(
<SessionStatsProvider>
<VimModeProvider settings={settings}>
<AppContainer
key={`${columns}-${rows}`}
config={config}
startupWarnings={startupWarnings}
version={version}
@@ -335,6 +333,11 @@ export async function main() {
});
setupUnhandledRejectionHandler();
const slashCommandConflictHandler = new SlashCommandConflictHandler();
slashCommandConflictHandler.start();
registerCleanup(() => slashCommandConflictHandler.stop());
const loadSettingsHandle = startupProfiler.start('load_settings');
const settings = loadSettings();
loadSettingsHandle?.end();
@@ -361,6 +364,26 @@ export async function main() {
const argv = await parseArguments(settings.merged);
parseArgsHandle?.end();
if (
(argv.allowedTools && argv.allowedTools.length > 0) ||
(settings.merged.tools?.allowed && settings.merged.tools.allowed.length > 0)
) {
coreEvents.emitFeedback(
'warning',
'Warning: --allowed-tools cli argument and tools.allowed in settings.json are deprecated and will be removed in 1.0: Migrate to Policy Engine: https://geminicli.com/docs/core/policy-engine/',
);
}
if (
settings.merged.tools?.exclude &&
settings.merged.tools.exclude.length > 0
) {
coreEvents.emitFeedback(
'warning',
'Warning: tools.exclude in settings.json is deprecated and will be removed in 1.0. Migrate to Policy Engine: https://geminicli.com/docs/core/policy-engine/',
);
}
if (argv.startupMessages) {
argv.startupMessages.forEach((msg) => {
coreEvents.emitFeedback('info', msg);
@@ -350,4 +350,117 @@ describe('CommandService', () => {
expect(deployExtension).toBeDefined();
expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud');
});
it('should report conflicts via getConflicts', async () => {
const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN);
const extensionCommand = {
...createMockCommand('deploy', CommandKind.FILE),
extensionName: 'firebase',
};
const mockLoader = new MockCommandLoader([
builtinCommand,
extensionCommand,
]);
const service = await CommandService.create(
[mockLoader],
new AbortController().signal,
);
const conflicts = service.getConflicts();
expect(conflicts).toHaveLength(1);
expect(conflicts[0]).toMatchObject({
name: 'deploy',
winner: builtinCommand,
losers: [
{
renamedTo: 'firebase.deploy',
command: expect.objectContaining({
name: 'deploy',
extensionName: 'firebase',
}),
},
],
});
});
it('should report extension vs extension conflicts correctly', async () => {
// Both extensions try to register 'deploy'
const extension1Command = {
...createMockCommand('deploy', CommandKind.FILE),
extensionName: 'firebase',
};
const extension2Command = {
...createMockCommand('deploy', CommandKind.FILE),
extensionName: 'aws',
};
const mockLoader = new MockCommandLoader([
extension1Command,
extension2Command,
]);
const service = await CommandService.create(
[mockLoader],
new AbortController().signal,
);
const conflicts = service.getConflicts();
expect(conflicts).toHaveLength(1);
expect(conflicts[0]).toMatchObject({
name: 'deploy',
winner: expect.objectContaining({
name: 'deploy',
extensionName: 'firebase',
}),
losers: [
{
renamedTo: 'aws.deploy', // ext2 is 'aws' and it lost because it was second in the list
command: expect.objectContaining({
name: 'deploy',
extensionName: 'aws',
}),
},
],
});
});
it('should report multiple conflicts for the same command name', async () => {
const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN);
const ext1 = {
...createMockCommand('deploy', CommandKind.FILE),
extensionName: 'ext1',
};
const ext2 = {
...createMockCommand('deploy', CommandKind.FILE),
extensionName: 'ext2',
};
const mockLoader = new MockCommandLoader([builtinCommand, ext1, ext2]);
const service = await CommandService.create(
[mockLoader],
new AbortController().signal,
);
const conflicts = service.getConflicts();
expect(conflicts).toHaveLength(1);
expect(conflicts[0].name).toBe('deploy');
expect(conflicts[0].losers).toHaveLength(2);
expect(conflicts[0].losers).toEqual(
expect.arrayContaining([
expect.objectContaining({
renamedTo: 'ext1.deploy',
command: expect.objectContaining({ extensionName: 'ext1' }),
}),
expect.objectContaining({
renamedTo: 'ext2.deploy',
command: expect.objectContaining({ extensionName: 'ext2' }),
}),
]),
);
});
});
+56 -3
View File
@@ -4,10 +4,19 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { debugLogger } from '@google/gemini-cli-core';
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
import type { SlashCommand } from '../ui/commands/types.js';
import type { ICommandLoader } from './types.js';
export interface CommandConflict {
name: string;
winner: SlashCommand;
losers: Array<{
command: SlashCommand;
renamedTo: string;
}>;
}
/**
* Orchestrates the discovery and loading of all slash commands for the CLI.
*
@@ -23,8 +32,12 @@ export class CommandService {
/**
* Private constructor to enforce the use of the async factory.
* @param commands A readonly array of the fully loaded and de-duplicated commands.
* @param conflicts A readonly array of conflicts that occurred during loading.
*/
private constructor(private readonly commands: readonly SlashCommand[]) {}
private constructor(
private readonly commands: readonly SlashCommand[],
private readonly conflicts: readonly CommandConflict[],
) {}
/**
* Asynchronously creates and initializes a new CommandService instance.
@@ -63,11 +76,14 @@ export class CommandService {
}
const commandMap = new Map<string, SlashCommand>();
const conflictsMap = new Map<string, CommandConflict>();
for (const cmd of allCommands) {
let finalName = cmd.name;
// Extension commands get renamed if they conflict with existing commands
if (cmd.extensionName && commandMap.has(cmd.name)) {
const winner = commandMap.get(cmd.name)!;
let renamedName = `${cmd.extensionName}.${cmd.name}`;
let suffix = 1;
@@ -78,6 +94,19 @@ export class CommandService {
}
finalName = renamedName;
if (!conflictsMap.has(cmd.name)) {
conflictsMap.set(cmd.name, {
name: cmd.name,
winner,
losers: [],
});
}
conflictsMap.get(cmd.name)!.losers.push({
command: cmd,
renamedTo: finalName,
});
}
commandMap.set(finalName, {
@@ -86,8 +115,23 @@ export class CommandService {
});
}
const conflicts = Array.from(conflictsMap.values());
if (conflicts.length > 0) {
coreEvents.emitSlashCommandConflicts(
conflicts.flatMap((c) =>
c.losers.map((l) => ({
name: c.name,
renamedTo: l.renamedTo,
loserExtensionName: l.command.extensionName,
winnerExtensionName: c.winner.extensionName,
})),
),
);
}
const finalCommands = Object.freeze(Array.from(commandMap.values()));
return new CommandService(finalCommands);
const finalConflicts = Object.freeze(conflicts);
return new CommandService(finalCommands, finalConflicts);
}
/**
@@ -101,4 +145,13 @@ export class CommandService {
getCommands(): readonly SlashCommand[] {
return this.commands;
}
/**
* Retrieves the list of conflicts that occurred during command loading.
*
* @returns A readonly array of command conflicts.
*/
getConflicts(): readonly CommandConflict[] {
return this.conflicts;
}
}
@@ -0,0 +1,54 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
coreEvents,
CoreEvent,
type SlashCommandConflictsPayload,
} from '@google/gemini-cli-core';
export class SlashCommandConflictHandler {
private notifiedConflicts = new Set<string>();
constructor() {
this.handleConflicts = this.handleConflicts.bind(this);
}
start() {
coreEvents.on(CoreEvent.SlashCommandConflicts, this.handleConflicts);
}
stop() {
coreEvents.off(CoreEvent.SlashCommandConflicts, this.handleConflicts);
}
private handleConflicts(payload: SlashCommandConflictsPayload) {
const newConflicts = payload.conflicts.filter((c) => {
const key = `${c.name}:${c.loserExtensionName}`;
if (this.notifiedConflicts.has(key)) {
return false;
}
this.notifiedConflicts.add(key);
return true;
});
if (newConflicts.length > 0) {
const conflictMessages = newConflicts
.map((c) => {
const winnerSource = c.winnerExtensionName
? `extension '${c.winnerExtensionName}'`
: 'an existing command';
return `- Command '/${c.name}' from extension '${c.loserExtensionName}' was renamed to '/${c.renamedTo}' because it conflicts with ${winnerSource}.`;
})
.join('\n');
coreEvents.emitFeedback(
'info',
`Command conflicts detected:\n${conflictMessages}`,
);
}
}
}
@@ -18,6 +18,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getSandbox: vi.fn(() => undefined),
getQuestion: vi.fn(() => ''),
isInteractive: vi.fn(() => false),
isInitialized: vi.fn(() => true),
setTerminalBackground: vi.fn(),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
+18 -1
View File
@@ -33,6 +33,9 @@ import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
import { FakePersistentState } from './persistentStateFake.js';
import { AppContext, type AppState } from '../ui/contexts/AppContext.js';
import { createMockSettings } from './settings.js';
import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
import { DefaultLight } from '../ui/themes/default-light.js';
import { pickDefaultThemeName } from '../ui/themes/theme.js';
export const persistentStateMock = new FakePersistentState();
@@ -150,7 +153,8 @@ const baseMockUiState = {
terminalWidth: 120,
terminalHeight: 40,
currentModel: 'gemini-pro',
terminalBackgroundColor: undefined,
terminalBackgroundColor: 'black',
cleanUiDetailsVisible: false,
activePtyId: undefined,
backgroundShells: new Map(),
backgroundShellHeight: 0,
@@ -204,6 +208,10 @@ const mockUIActions: UIActions = {
handleApiKeyCancel: vi.fn(),
setBannerVisible: vi.fn(),
setShortcutsHelpVisible: vi.fn(),
setCleanUiDetailsVisible: vi.fn(),
toggleCleanUiDetailsVisible: vi.fn(),
revealCleanUiDetailsTemporarily: vi.fn(),
handleWarning: vi.fn(),
setEmbeddedShellFocused: vi.fn(),
dismissBackgroundShell: vi.fn(),
setActiveBackgroundShellPid: vi.fn(),
@@ -293,6 +301,15 @@ export const renderWithProviders = (
mainAreaWidth,
};
themeManager.setTerminalBackground(baseState.terminalBackgroundColor);
const themeName = pickDefaultThemeName(
baseState.terminalBackgroundColor,
themeManager.getAllThemes(),
DEFAULT_THEME.name,
DefaultLight.name,
);
themeManager.setActiveTheme(themeName);
const finalUIActions = { ...mockUIActions, ...uiActions };
const allToolCalls = (finalUiState.pendingHistoryItems || [])
+1 -4
View File
@@ -66,6 +66,7 @@ describe('App', () => {
const mockUIState: Partial<UIState> = {
streamingState: StreamingState.Idle,
cleanUiDetailsVisible: true,
quittingMessages: null,
dialogsVisible: false,
mainControlsRef: {
@@ -220,10 +221,6 @@ describe('App', () => {
} as UIState;
const configWithExperiment = makeFakeConfig();
vi.spyOn(
configWithExperiment,
'isEventDrivenSchedulerEnabled',
).mockReturnValue(true);
vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true);
vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false);
+205 -29
View File
@@ -14,13 +14,13 @@ import {
type Mock,
type MockedObject,
} from 'vitest';
import { render } from '../test-utils/render.js';
import { render, persistentStateMock } from '../test-utils/render.js';
import { waitFor } from '../test-utils/async.js';
import { cleanup } from 'ink-testing-library';
import { act, useContext, type ReactElement } from 'react';
import { AppContainer } from './AppContainer.js';
import { SettingsContext } from './contexts/SettingsContext.js';
import { type TrackedToolCall } from './hooks/useReactToolScheduler.js';
import { type TrackedToolCall } from './hooks/useToolScheduler.js';
import {
type Config,
makeFakeConfig,
@@ -135,6 +135,7 @@ vi.mock('./hooks/vim.js');
vi.mock('./hooks/useFocus.js');
vi.mock('./hooks/useBracketedPaste.js');
vi.mock('./hooks/useLoadingIndicator.js');
vi.mock('./hooks/useSuspend.js');
vi.mock('./hooks/useFolderTrust.js');
vi.mock('./hooks/useIdeTrustListener.js');
vi.mock('./hooks/useMessageQueue.js');
@@ -197,7 +198,9 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
import { useKeypress } from './hooks/useKeypress.js';
import { useKeypress, type Key } from './hooks/useKeypress.js';
import * as useKeypressModule from './hooks/useKeypress.js';
import { useSuspend } from './hooks/useSuspend.js';
import { measureElement } from 'ink';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import {
@@ -270,6 +273,7 @@ describe('AppContainer State Management', () => {
const mockedUseTextBuffer = useTextBuffer as Mock;
const mockedUseLogger = useLogger as Mock;
const mockedUseLoadingIndicator = useLoadingIndicator as Mock;
const mockedUseSuspend = useSuspend as Mock;
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
const mockedUseHookDisplayState = useHookDisplayState as Mock;
const mockedUseTerminalTheme = useTerminalTheme as Mock;
@@ -295,6 +299,7 @@ describe('AppContainer State Management', () => {
};
beforeEach(() => {
persistentStateMock.reset();
vi.clearAllMocks();
mockIdeClient.getInstance.mockReturnValue(new Promise(() => {}));
@@ -401,6 +406,9 @@ describe('AppContainer State Management', () => {
elapsedTime: '0.0s',
currentLoadingPhrase: '',
});
mockedUseSuspend.mockReturnValue({
handleSuspend: vi.fn(),
});
mockedUseHookDisplayState.mockReturnValue([]);
mockedUseTerminalTheme.mockReturnValue(undefined);
mockedUseShellInactivityStatus.mockReturnValue({
@@ -440,8 +448,8 @@ describe('AppContainer State Management', () => {
...defaultMergedSettings.ui,
showStatusInTitle: false,
hideWindowTitle: false,
useAlternateBuffer: false,
},
useAlternateBuffer: false,
},
} as unknown as LoadedSettings;
@@ -481,6 +489,37 @@ describe('AppContainer State Management', () => {
await waitFor(() => expect(capturedUIState).toBeTruthy());
unmount!();
});
it('shows full UI details by default', async () => {
let unmount: () => void;
await act(async () => {
const result = renderAppContainer();
unmount = result.unmount;
});
await waitFor(() => {
expect(capturedUIState.cleanUiDetailsVisible).toBe(true);
});
unmount!();
});
it('starts in minimal UI mode when Focus UI preference is persisted', async () => {
persistentStateMock.get.mockReturnValueOnce(true);
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({
settings: mockSettings,
});
unmount = result.unmount;
});
await waitFor(() => {
expect(capturedUIState.cleanUiDetailsVisible).toBe(false);
});
expect(persistentStateMock.get).toHaveBeenCalledWith('focusUiEnabled');
unmount!();
});
});
describe('State Initialization', () => {
@@ -727,10 +766,10 @@ describe('AppContainer State Management', () => {
getChatRecordingService: vi.fn(() => mockChatRecordingService),
};
const configWithRecording = {
...mockConfig,
getGeminiClient: vi.fn(() => mockGeminiClient),
} as unknown as Config;
const configWithRecording = makeFakeConfig();
vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue(
mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
);
expect(() => {
renderAppContainer({
@@ -761,11 +800,13 @@ describe('AppContainer State Management', () => {
setHistory: vi.fn(),
};
const configWithRecording = {
...mockConfig,
getGeminiClient: vi.fn(() => mockGeminiClient),
getSessionId: vi.fn(() => 'test-session-123'),
} as unknown as Config;
const configWithRecording = makeFakeConfig();
vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue(
mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
);
vi.spyOn(configWithRecording, 'getSessionId').mockReturnValue(
'test-session-123',
);
expect(() => {
renderAppContainer({
@@ -801,10 +842,10 @@ describe('AppContainer State Management', () => {
getUserTier: vi.fn(),
};
const configWithRecording = {
...mockConfig,
getGeminiClient: vi.fn(() => mockGeminiClient),
} as unknown as Config;
const configWithRecording = makeFakeConfig();
vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue(
mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
);
renderAppContainer({
config: configWithRecording,
@@ -835,10 +876,10 @@ describe('AppContainer State Management', () => {
})),
};
const configWithClient = {
...mockConfig,
getGeminiClient: vi.fn(() => mockGeminiClient),
} as unknown as Config;
const configWithClient = makeFakeConfig();
vi.spyOn(configWithClient, 'getGeminiClient').mockReturnValue(
mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
);
const resumedData = {
conversation: {
@@ -891,10 +932,10 @@ describe('AppContainer State Management', () => {
getChatRecordingService: vi.fn(),
};
const configWithClient = {
...mockConfig,
getGeminiClient: vi.fn(() => mockGeminiClient),
} as unknown as Config;
const configWithClient = makeFakeConfig();
vi.spyOn(configWithClient, 'getGeminiClient').mockReturnValue(
mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
);
const resumedData = {
conversation: {
@@ -944,10 +985,10 @@ describe('AppContainer State Management', () => {
getUserTier: vi.fn(),
};
const configWithRecording = {
...mockConfig,
getGeminiClient: vi.fn(() => mockGeminiClient),
} as unknown as Config;
const configWithRecording = makeFakeConfig();
vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue(
mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
);
renderAppContainer({
config: configWithRecording,
@@ -1942,6 +1983,19 @@ describe('AppContainer State Management', () => {
});
});
describe('CTRL+Z', () => {
it('should call handleSuspend', async () => {
const handleSuspend = vi.fn();
mockedUseSuspend.mockReturnValue({ handleSuspend });
await setupKeypressTest();
pressKey('\x1A'); // Ctrl+Z
expect(handleSuspend).toHaveBeenCalledTimes(1);
unmount();
});
});
describe('Focus Handling (Tab / Shift+Tab)', () => {
beforeEach(() => {
// Mock activePtyId to enable focus
@@ -2091,6 +2145,128 @@ describe('AppContainer State Management', () => {
});
});
describe('Shortcuts Help Visibility', () => {
let handleGlobalKeypress: (key: Key) => boolean;
let mockedUseKeypress: Mock;
let rerender: () => void;
let unmount: () => void;
const setupShortcutsVisibilityTest = async () => {
const renderResult = renderAppContainer();
await act(async () => {
vi.advanceTimersByTime(0);
});
rerender = () => renderResult.rerender(getAppContainer());
unmount = renderResult.unmount;
};
const pressKey = (key: Partial<Key>) => {
act(() => {
handleGlobalKeypress({
name: 'r',
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '',
...key,
} as Key);
});
rerender();
};
beforeEach(() => {
mockedUseKeypress = vi.spyOn(useKeypressModule, 'useKeypress') as Mock;
mockedUseKeypress.mockImplementation(
(callback: (key: Key) => boolean, options: { isActive: boolean }) => {
// AppContainer registers multiple keypress handlers; capture only
// active handlers so inactive copy-mode handler doesn't override.
if (options?.isActive) {
handleGlobalKeypress = callback;
}
},
);
vi.useFakeTimers();
});
afterEach(() => {
mockedUseKeypress.mockRestore();
vi.useRealTimers();
vi.restoreAllMocks();
});
it('dismisses shortcuts help when a registered hotkey is pressed', async () => {
await setupShortcutsVisibilityTest();
act(() => {
capturedUIActions.setShortcutsHelpVisible(true);
});
rerender();
expect(capturedUIState.shortcutsHelpVisible).toBe(true);
pressKey({ name: 'r', ctrl: true, sequence: '\x12' }); // Ctrl+R
expect(capturedUIState.shortcutsHelpVisible).toBe(false);
unmount();
});
it('dismisses shortcuts help when streaming starts', async () => {
await setupShortcutsVisibilityTest();
act(() => {
capturedUIActions.setShortcutsHelpVisible(true);
});
rerender();
expect(capturedUIState.shortcutsHelpVisible).toBe(true);
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
});
await act(async () => {
rerender();
});
await waitFor(() => {
expect(capturedUIState.shortcutsHelpVisible).toBe(false);
});
unmount();
});
it('dismisses shortcuts help when action-required confirmation appears', async () => {
await setupShortcutsVisibilityTest();
act(() => {
capturedUIActions.setShortcutsHelpVisible(true);
});
rerender();
expect(capturedUIState.shortcutsHelpVisible).toBe(true);
mockedUseSlashCommandProcessor.mockReturnValue({
handleSlashCommand: vi.fn(),
slashCommands: [],
pendingHistoryItems: [],
commandContext: {},
shellConfirmationRequest: null,
confirmationRequest: {
prompt: 'Confirm this action?',
onConfirm: vi.fn(),
},
});
await act(async () => {
rerender();
});
await waitFor(() => {
expect(capturedUIState.shortcutsHelpVisible).toBe(false);
});
unmount();
});
});
describe('Copy Mode (CTRL+S)', () => {
let rerender: () => void;
let unmount: () => void;
+179 -22
View File
@@ -12,7 +12,14 @@ import {
useRef,
useLayoutEffect,
} from 'react';
import { type DOMElement, measureElement } from 'ink';
import {
type DOMElement,
measureElement,
useApp,
useStdout,
useStdin,
type AppProps,
} from 'ink';
import { App } from './App.js';
import { AppContext } from './contexts/AppContext.js';
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
@@ -42,6 +49,7 @@ import {
type UserTierId,
type UserFeedbackPayload,
type AgentDefinition,
type ApprovalMode,
IdeClient,
ideContextStore,
getErrorMessage,
@@ -87,7 +95,6 @@ import { useVimMode } from './contexts/VimModeContext.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import { calculatePromptWidths } from './components/InputPrompt.js';
import { useApp, useStdout, useStdin } from 'ink';
import { calculateMainAreaWidth } from './utils/ui-sizing.js';
import ansiEscapes from 'ansi-escapes';
import { basename } from 'node:path';
@@ -127,6 +134,7 @@ import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { type ExtensionManager } from '../config/extension-manager.js';
import { requestConsentInteractive } from '../config/extensions/consent.js';
import { useSessionBrowser } from './hooks/useSessionBrowser.js';
import { persistentState } from '../utils/persistentState.js';
import { useSessionResume } from './hooks/useSessionResume.js';
import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js';
import { isWorkspaceTrusted } from '../config/trustedFolders.js';
@@ -146,7 +154,8 @@ import { NewAgentsChoice } from './components/NewAgentsNotification.js';
import { isSlashCommand } from './utils/commandUtils.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { useTimedMessage } from './hooks/useTimedMessage.js';
import { isITerm2 } from './utils/terminalUtils.js';
import { shouldDismissShortcutsHelpOnHotkey } from './utils/shortcutsHelp.js';
import { useSuspend } from './hooks/useSuspend.js';
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => {
@@ -177,6 +186,9 @@ interface AppContainerProps {
resumedSessionData?: ResumedSessionData;
}
const APPROVAL_MODE_REVEAL_DURATION_MS = 1200;
const FOCUS_UI_ENABLED_STATE_KEY = 'focusUiEnabled';
/**
* The fraction of the terminal width to allocate to the shell.
* This provides horizontal padding.
@@ -200,6 +212,7 @@ export const AppContainer = (props: AppContainerProps) => {
useMemoryMonitor(historyManager);
const isAlternateBuffer = useAlternateBuffer();
const [corgiMode, setCorgiMode] = useState(false);
const [forceRerenderKey, setForceRerenderKey] = useState(0);
const [debugMessage, setDebugMessage] = useState<string>('');
const [quittingMessages, setQuittingMessages] = useState<
HistoryItem[] | null
@@ -346,7 +359,7 @@ export const AppContainer = (props: AppContainerProps) => {
const { columns: terminalWidth, rows: terminalHeight } = useTerminalSize();
const { stdin, setRawMode } = useStdin();
const { stdout } = useStdout();
const app = useApp();
const app: AppProps = useApp();
// Additional hooks moved from App.tsx
const { stats: sessionStats } = useSessionStats();
@@ -483,7 +496,7 @@ export const AppContainer = (props: AppContainerProps) => {
);
coreEvents.off(CoreEvent.AgentsDiscovered, handleAgentsDiscovered);
};
}, []);
}, [settings]);
const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } =
useConsoleMessages();
@@ -535,10 +548,13 @@ export const AppContainer = (props: AppContainerProps) => {
setHistoryRemountKey((prev) => prev + 1);
}, [setHistoryRemountKey, isAlternateBuffer, stdout]);
const shouldUseAlternateScreen = shouldEnterAlternateScreen(
isAlternateBuffer,
config.getScreenReader(),
);
const handleEditorClose = useCallback(() => {
if (
shouldEnterAlternateScreen(isAlternateBuffer, config.getScreenReader())
) {
if (shouldUseAlternateScreen) {
// The editor may have exited alternate buffer mode so we need to
// enter it again to be safe.
enterAlternateScreen();
@@ -548,7 +564,7 @@ export const AppContainer = (props: AppContainerProps) => {
}
terminalCapabilityManager.enableSupportedModes();
refreshStatic();
}, [refreshStatic, isAlternateBuffer, app, config]);
}, [refreshStatic, shouldUseAlternateScreen, app]);
const [editorError, setEditorError] = useState<string | null>(null);
const {
@@ -596,7 +612,7 @@ export const AppContainer = (props: AppContainerProps) => {
);
// Poll for terminal background color changes to auto-switch theme
useTerminalTheme(handleThemeSelect, config);
useTerminalTheme(handleThemeSelect, config, refreshStatic);
const {
authState,
@@ -785,7 +801,65 @@ Logging in with Google... Restarting Gemini CLI to continue.
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
() => {},
);
const [focusUiEnabledByDefault] = useState(
() => persistentState.get(FOCUS_UI_ENABLED_STATE_KEY) === true,
);
const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false);
const [cleanUiDetailsVisible, setCleanUiDetailsVisibleState] = useState(
!focusUiEnabledByDefault,
);
const modeRevealTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const cleanUiDetailsPinnedRef = useRef(!focusUiEnabledByDefault);
const clearModeRevealTimeout = useCallback(() => {
if (modeRevealTimeoutRef.current) {
clearTimeout(modeRevealTimeoutRef.current);
modeRevealTimeoutRef.current = null;
}
}, []);
const persistFocusUiPreference = useCallback((isFullUiVisible: boolean) => {
persistentState.set(FOCUS_UI_ENABLED_STATE_KEY, !isFullUiVisible);
}, []);
const setCleanUiDetailsVisible = useCallback(
(visible: boolean) => {
clearModeRevealTimeout();
cleanUiDetailsPinnedRef.current = visible;
setCleanUiDetailsVisibleState(visible);
persistFocusUiPreference(visible);
},
[clearModeRevealTimeout, persistFocusUiPreference],
);
const toggleCleanUiDetailsVisible = useCallback(() => {
clearModeRevealTimeout();
setCleanUiDetailsVisibleState((visible) => {
const nextVisible = !visible;
cleanUiDetailsPinnedRef.current = nextVisible;
persistFocusUiPreference(nextVisible);
return nextVisible;
});
}, [clearModeRevealTimeout, persistFocusUiPreference]);
const revealCleanUiDetailsTemporarily = useCallback(
(durationMs: number = APPROVAL_MODE_REVEAL_DURATION_MS) => {
if (cleanUiDetailsPinnedRef.current) {
return;
}
clearModeRevealTimeout();
setCleanUiDetailsVisibleState(true);
modeRevealTimeoutRef.current = setTimeout(() => {
if (!cleanUiDetailsPinnedRef.current) {
setCleanUiDetailsVisibleState(false);
}
modeRevealTimeoutRef.current = null;
}, durationMs);
},
[clearModeRevealTimeout],
);
useEffect(() => () => clearModeRevealTimeout(), [clearModeRevealTimeout]);
const slashCommandActions = useMemo(
() => ({
@@ -1046,11 +1120,25 @@ Logging in with Google... Restarting Gemini CLI to continue.
const shouldShowActionRequiredTitle = inactivityStatus === 'action_required';
const shouldShowSilentWorkingTitle = inactivityStatus === 'silent_working';
const handleApprovalModeChangeWithUiReveal = useCallback(
(mode: ApprovalMode) => {
void handleApprovalModeChange(mode);
if (!cleanUiDetailsVisible) {
revealCleanUiDetailsTemporarily(APPROVAL_MODE_REVEAL_DURATION_MS);
}
},
[
handleApprovalModeChange,
cleanUiDetailsVisible,
revealCleanUiDetailsTemporarily,
],
);
// Auto-accept indicator
const showApprovalModeIndicator = useApprovalModeIndicator({
config,
addItem: historyManager.addItem,
onApprovalModeChange: handleApprovalModeChange,
onApprovalModeChange: handleApprovalModeChangeWithUiReveal,
isActive: !embeddedShellFocused,
});
@@ -1366,9 +1454,30 @@ Logging in with Google... Restarting Gemini CLI to continue.
if (tabFocusTimeoutRef.current) {
clearTimeout(tabFocusTimeoutRef.current);
}
if (modeRevealTimeoutRef.current) {
clearTimeout(modeRevealTimeoutRef.current);
}
};
}, [showTransientMessage]);
const handleWarning = useCallback(
(message: string) => {
showTransientMessage({
text: message,
type: TransientMessageType.Warning,
});
},
[showTransientMessage],
);
const { handleSuspend } = useSuspend({
handleWarning,
setRawMode,
refreshStatic,
setForceRerenderKey,
shouldUseAlternateScreen,
});
useEffect(() => {
if (ideNeedsRestart) {
// IDE trust changed, force a restart.
@@ -1489,6 +1598,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key));
}
if (shortcutsHelpVisible && shouldDismissShortcutsHelpOnHotkey(key)) {
setShortcutsHelpVisible(false);
}
if (isAlternateBuffer && keyMatchers[Command.TOGGLE_COPY_MODE](key)) {
setCopyModeEnabled(true);
disableMouseEvents();
@@ -1505,6 +1618,17 @@ Logging in with Google... Restarting Gemini CLI to continue.
} else if (keyMatchers[Command.EXIT](key)) {
setCtrlDPressCount((prev) => prev + 1);
return true;
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
handleSuspend();
} else if (
keyMatchers[Command.TOGGLE_COPY_MODE](key) &&
!isAlternateBuffer
) {
showTransientMessage({
text: 'Use Ctrl+O to expand and collapse blocks of content.',
type: TransientMessageType.Warning,
});
return true;
}
let enteringConstrainHeightMode = false;
@@ -1530,15 +1654,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
setShowErrorDetails((prev) => !prev);
}
return true;
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
const undoMessage = isITerm2()
? 'Undo has been moved to Option + Z'
: 'Undo has been moved to Alt/Option + Z or Cmd + Z';
showTransientMessage({
text: undoMessage,
type: TransientMessageType.Warning,
});
return true;
} else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) {
setShowFullTodos((prev) => !prev);
return true;
@@ -1647,18 +1762,20 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleSlashCommand,
cancelOngoingRequest,
activePtyId,
handleSuspend,
embeddedShellFocused,
settings.merged.general.debugKeystrokeLogging,
refreshStatic,
setCopyModeEnabled,
tabFocusTimeoutRef,
isAlternateBuffer,
shortcutsHelpVisible,
backgroundCurrentShell,
toggleBackgroundShell,
backgroundShells,
isBackgroundShellVisible,
setIsBackgroundShellListOpen,
lastOutputTimeRef,
tabFocusTimeoutRef,
showTransientMessage,
settings.merged.general.devtools,
showErrorDetails,
@@ -1811,6 +1928,36 @@ Logging in with Google... Restarting Gemini CLI to continue.
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
);
const hasPendingToolConfirmation = useMemo(
() => isToolAwaitingConfirmation(pendingHistoryItems),
[pendingHistoryItems],
);
const hasPendingActionRequired =
hasPendingToolConfirmation ||
!!commandConfirmationRequest ||
!!authConsentRequest ||
confirmUpdateExtensionRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
!!proQuotaRequest ||
!!validationRequest ||
!!customDialog;
const isPassiveShortcutsHelpState =
isInputActive &&
streamingState === StreamingState.Idle &&
!hasPendingActionRequired;
useEffect(() => {
if (shortcutsHelpVisible && !isPassiveShortcutsHelpState) {
setShortcutsHelpVisible(false);
}
}, [
shortcutsHelpVisible,
isPassiveShortcutsHelpState,
setShortcutsHelpVisible,
]);
const allToolCalls = useMemo(
() =>
pendingHistoryItems
@@ -1918,6 +2065,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
ctrlDPressedOnce: ctrlDPressCount >= 1,
showEscapePrompt,
shortcutsHelpVisible,
cleanUiDetailsVisible,
isFocused,
elapsedTime,
currentLoadingPhrase,
@@ -2028,6 +2176,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
ctrlDPressCount,
showEscapePrompt,
shortcutsHelpVisible,
cleanUiDetailsVisible,
isFocused,
elapsedTime,
currentLoadingPhrase,
@@ -2129,6 +2278,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleApiKeyCancel,
setBannerVisible,
setShortcutsHelpVisible,
setCleanUiDetailsVisible,
toggleCleanUiDetailsVisible,
revealCleanUiDetailsTemporarily,
handleWarning,
setEmbeddedShellFocused,
dismissBackgroundShell,
setActiveBackgroundShellPid,
@@ -2205,6 +2358,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleApiKeyCancel,
setBannerVisible,
setShortcutsHelpVisible,
setCleanUiDetailsVisible,
toggleCleanUiDetailsVisible,
revealCleanUiDetailsTemporarily,
handleWarning,
setEmbeddedShellFocused,
dismissBackgroundShell,
setActiveBackgroundShellPid,
@@ -2240,7 +2397,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
>
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
<ShellFocusContext.Provider value={isFocused}>
<App />
<App key={`app-${forceRerenderKey}`} />
</ShellFocusContext.Provider>
</ToolActionsProvider>
</AppContext.Provider>
+2 -2
View File
@@ -15,7 +15,7 @@ export const Colors: ColorsTheme = {
return themeManager.getActiveTheme().colors.Foreground;
},
get Background() {
return themeManager.getActiveTheme().colors.Background;
return themeManager.getColors().Background;
},
get LightBlue() {
return themeManager.getActiveTheme().colors.LightBlue;
@@ -51,7 +51,7 @@ export const Colors: ColorsTheme = {
return themeManager.getActiveTheme().colors.Gray;
},
get DarkGray() {
return themeManager.getActiveTheme().colors.DarkGray;
return themeManager.getColors().DarkGray;
},
get GradientColors() {
return themeManager.getActiveTheme().colors.GradientColors;
@@ -182,7 +182,6 @@ describe('AlternateBufferQuittingDisplay', () => {
type: 'info',
title: 'Confirm Tool',
prompt: 'Confirm this action?',
onConfirm: async () => {},
},
},
],
@@ -12,18 +12,15 @@ import { QuittingDisplay } from './QuittingDisplay.js';
import { useAppContext } from '../contexts/AppContext.js';
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { ToolStatusIndicator, ToolInfo } from './messages/ToolShared.js';
import { theme } from '../semantic-colors.js';
export const AlternateBufferQuittingDisplay = () => {
const { version } = useAppContext();
const uiState = useUIState();
const config = useConfig();
const confirmingTool = useConfirmingTool();
const showPromptedTool =
config.isEventDrivenSchedulerEnabled() && confirmingTool !== null;
const showPromptedTool = confirmingTool !== null;
// We render the entire chat history and header here to ensure that the
// conversation history is visible to the user after the app quits and the
@@ -56,7 +53,6 @@ export const AlternateBufferQuittingDisplay = () => {
terminalWidth={uiState.mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
isFocused={false}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
/>
+10 -1
View File
@@ -17,9 +17,10 @@ import { useTips } from '../hooks/useTips.js';
interface AppHeaderProps {
version: string;
showDetails?: boolean;
}
export const AppHeader = ({ version }: AppHeaderProps) => {
export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
const settings = useSettings();
const config = useConfig();
const { nightly, terminalWidth, bannerData, bannerVisible } = useUIState();
@@ -27,6 +28,14 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
const { bannerText } = useBanner(bannerData);
const { showTips } = useTips();
if (!showDetails) {
return (
<Box flexDirection="column">
<Header version={version} nightly={false} />
</Box>
);
}
return (
<Box flexDirection="column">
{!(settings.merged.ui.hideBanner || config.getScreenReader()) && (
@@ -37,6 +37,7 @@ describe('AskUserDialog', () => {
{
question: 'Which authentication method should we use?',
header: 'Auth',
type: QuestionType.CHOICE,
options: [
{ label: 'OAuth 2.0', description: 'Industry standard, supports SSO' },
{ label: 'JWT tokens', description: 'Stateless, good for APIs' },
@@ -74,6 +75,7 @@ describe('AskUserDialog', () => {
{
question: 'Which features?',
header: 'Features',
type: QuestionType.CHOICE,
options: [
{ label: 'TypeScript', description: '' },
{ label: 'ESLint', description: '' },
@@ -171,6 +173,7 @@ describe('AskUserDialog', () => {
{
question: 'Which authentication method?',
header: 'Auth',
type: QuestionType.CHOICE,
options: [{ label: 'OAuth 2.0', description: '' }],
multiSelect: false,
},
@@ -228,6 +231,7 @@ describe('AskUserDialog', () => {
{
question: 'Choose an option',
header: 'Scroll Test',
type: QuestionType.CHOICE,
options: Array.from({ length: 15 }, (_, i) => ({
label: `Option ${i + 1}`,
description: `Description ${i + 1}`,
@@ -296,6 +300,7 @@ describe('AskUserDialog', () => {
{
question: 'Which database should we use?',
header: 'Database',
type: QuestionType.CHOICE,
options: [
{ label: 'PostgreSQL', description: 'Relational database' },
{ label: 'MongoDB', description: 'Document database' },
@@ -305,6 +310,7 @@ describe('AskUserDialog', () => {
{
question: 'Which ORM do you prefer?',
header: 'ORM',
type: QuestionType.CHOICE,
options: [
{ label: 'Prisma', description: 'Type-safe ORM' },
{ label: 'Drizzle', description: 'Lightweight ORM' },
@@ -359,12 +365,14 @@ describe('AskUserDialog', () => {
{
question: 'Which testing framework?',
header: 'Testing',
type: QuestionType.CHOICE,
options: [{ label: 'Vitest', description: 'Fast unit testing' }],
multiSelect: false,
},
{
question: 'Which CI provider?',
header: 'CI',
type: QuestionType.CHOICE,
options: [
{ label: 'GitHub Actions', description: 'Built into GitHub' },
],
@@ -402,12 +410,14 @@ describe('AskUserDialog', () => {
{
question: 'Which package manager?',
header: 'Package',
type: QuestionType.CHOICE,
options: [{ label: 'pnpm', description: 'Fast, disk efficient' }],
multiSelect: false,
},
{
question: 'Which bundler?',
header: 'Bundler',
type: QuestionType.CHOICE,
options: [{ label: 'Vite', description: 'Next generation bundler' }],
multiSelect: false,
},
@@ -465,6 +475,7 @@ describe('AskUserDialog', () => {
{
question: 'Which framework?',
header: 'Framework',
type: QuestionType.CHOICE,
options: [
{ label: 'React', description: 'Component library' },
{ label: 'Vue', description: 'Progressive framework' },
@@ -474,6 +485,7 @@ describe('AskUserDialog', () => {
{
question: 'Which styling?',
header: 'Styling',
type: QuestionType.CHOICE,
options: [
{ label: 'Tailwind', description: 'Utility-first CSS' },
{ label: 'CSS Modules', description: 'Scoped styles' },
@@ -500,12 +512,14 @@ describe('AskUserDialog', () => {
{
question: 'Create tests?',
header: 'Tests',
type: QuestionType.CHOICE,
options: [{ label: 'Yes', description: 'Generate test files' }],
multiSelect: false,
},
{
question: 'Add documentation?',
header: 'Docs',
type: QuestionType.CHOICE,
options: [{ label: 'Yes', description: 'Generate JSDoc comments' }],
multiSelect: false,
},
@@ -545,12 +559,14 @@ describe('AskUserDialog', () => {
{
question: 'Which license?',
header: 'License',
type: QuestionType.CHOICE,
options: [{ label: 'MIT', description: 'Permissive license' }],
multiSelect: false,
},
{
question: 'Include README?',
header: 'README',
type: QuestionType.CHOICE,
options: [{ label: 'Yes', description: 'Generate README.md' }],
multiSelect: false,
},
@@ -580,12 +596,14 @@ describe('AskUserDialog', () => {
{
question: 'Target Node version?',
header: 'Node',
type: QuestionType.CHOICE,
options: [{ label: 'Node 20', description: 'LTS version' }],
multiSelect: false,
},
{
question: 'Enable strict mode?',
header: 'Strict',
type: QuestionType.CHOICE,
options: [{ label: 'Yes', description: 'Strict TypeScript' }],
multiSelect: false,
},
@@ -727,6 +745,7 @@ describe('AskUserDialog', () => {
{
question: 'Should it be async?',
header: 'Async',
type: QuestionType.CHOICE,
options: [
{ label: 'Yes', description: 'Use async/await' },
{ label: 'No', description: 'Synchronous hook' },
@@ -773,6 +792,7 @@ describe('AskUserDialog', () => {
{
question: 'Which styling approach?',
header: 'Style',
type: QuestionType.CHOICE,
options: [
{ label: 'CSS Modules', description: 'Scoped CSS' },
{ label: 'Tailwind', description: 'Utility classes' },
@@ -895,6 +915,7 @@ describe('AskUserDialog', () => {
{
question: 'Choice Q?',
header: 'Choice',
type: QuestionType.CHOICE,
options: [{ label: 'Option 1', description: '' }],
multiSelect: false,
},
@@ -952,12 +973,14 @@ describe('AskUserDialog', () => {
{
question: 'Question 1?',
header: 'Q1',
type: QuestionType.CHOICE,
options: [{ label: 'A1', description: '' }],
multiSelect: false,
},
{
question: 'Question 2?',
header: 'Q2',
type: QuestionType.CHOICE,
options: [{ label: 'A2', description: '' }],
multiSelect: false,
},
@@ -1008,6 +1031,7 @@ describe('AskUserDialog', () => {
{
question: 'Which option do you prefer?',
header: 'Test',
type: QuestionType.CHOICE,
options: [{ label: 'Yes', description: '' }],
multiSelect: false,
},
@@ -1036,6 +1060,7 @@ describe('AskUserDialog', () => {
{
question: 'Is **this** working?',
header: 'Test',
type: QuestionType.CHOICE,
options: [{ label: 'Yes', description: '' }],
multiSelect: false,
},
@@ -1067,6 +1092,7 @@ describe('AskUserDialog', () => {
{
question: 'Is **this** working?',
header: 'Test',
type: QuestionType.CHOICE,
options: [{ label: 'Yes', description: '' }],
multiSelect: false,
},
@@ -1096,6 +1122,7 @@ describe('AskUserDialog', () => {
{
question: 'Run `npm start`?',
header: 'Test',
type: QuestionType.CHOICE,
options: [{ label: 'Yes', description: '' }],
multiSelect: false,
},
@@ -1126,6 +1153,7 @@ describe('AskUserDialog', () => {
{
question: 'Choose an option',
header: 'Context Test',
type: QuestionType.CHOICE,
options: Array.from({ length: 10 }, (_, i) => ({
label: `Option ${i + 1}`,
description: `Description ${i + 1}`,
@@ -1162,6 +1190,7 @@ describe('AskUserDialog', () => {
{
question: longQuestion,
header: 'Alternate Buffer Test',
type: QuestionType.CHOICE,
options: [{ label: 'Option 1', description: 'Desc 1' }],
multiSelect: false,
},
@@ -1195,6 +1224,7 @@ describe('AskUserDialog', () => {
{
question: 'Select your preferred language:',
header: 'Language',
type: QuestionType.CHOICE,
options: [
{ label: 'TypeScript', description: '' },
{ label: 'JavaScript', description: '' },
@@ -1228,6 +1258,7 @@ describe('AskUserDialog', () => {
{
question: 'Select your preferred language:',
header: 'Language',
type: QuestionType.CHOICE,
options: [
{ label: 'TypeScript', description: '' },
{ label: 'JavaScript', description: '' },
@@ -9,7 +9,7 @@ import { act } from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { AskUserDialog } from './AskUserDialog.js';
import type { Question } from '@google/gemini-cli-core';
import { QuestionType, type Question } from '@google/gemini-cli-core';
describe('Key Bubbling Regression', () => {
afterEach(() => {
@@ -20,6 +20,7 @@ describe('Key Bubbling Regression', () => {
{
question: 'Choice Q?',
header: 'Choice',
type: QuestionType.CHOICE,
options: [
{ label: 'Option 1', description: '' },
{ label: 'Option 2', description: '' },
+261 -10
View File
@@ -4,9 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
import { render } from '../../test-utils/render.js';
import { Box, Text } from 'ink';
import { useEffect } from 'react';
import { Composer } from './Composer.js';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import {
@@ -23,13 +24,18 @@ vi.mock('../contexts/VimModeContext.js', () => ({
vimMode: 'INSERT',
})),
}));
import { ApprovalMode } from '@google/gemini-cli-core';
import { ApprovalMode, tokenLimit } from '@google/gemini-cli-core';
import type { Config } from '@google/gemini-cli-core';
import { StreamingState, ToolCallStatus } from '../types.js';
import { TransientMessageType } from '../../utils/events.js';
import type { LoadedSettings } from '../../config/settings.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
const composerTestControls = vi.hoisted(() => ({
suggestionsVisible: false,
isAlternateBuffer: false,
}));
// Mock child components
vi.mock('./LoadingIndicator.js', () => ({
LoadingIndicator: ({
@@ -90,9 +96,19 @@ vi.mock('./DetailedMessagesDisplay.js', () => ({
}));
vi.mock('./InputPrompt.js', () => ({
InputPrompt: ({ placeholder }: { placeholder?: string }) => (
<Text>InputPrompt: {placeholder}</Text>
),
InputPrompt: ({
placeholder,
onSuggestionsVisibilityChange,
}: {
placeholder?: string;
onSuggestionsVisibilityChange?: (visible: boolean) => void;
}) => {
useEffect(() => {
onSuggestionsVisibilityChange?.(composerTestControls.suggestionsVisible);
}, [onSuggestionsVisibilityChange]);
return <Text>InputPrompt: {placeholder}</Text>;
},
calculatePromptWidths: vi.fn(() => ({
inputWidth: 80,
suggestionsWidth: 40,
@@ -100,6 +116,10 @@ vi.mock('./InputPrompt.js', () => ({
})),
}));
vi.mock('../hooks/useAlternateBuffer.js', () => ({
useAlternateBuffer: () => composerTestControls.isAlternateBuffer,
}));
vi.mock('./Footer.js', () => ({
Footer: () => <Text>Footer</Text>,
}));
@@ -154,15 +174,19 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
ctrlDPressedOnce: false,
showEscapePrompt: false,
shortcutsHelpVisible: false,
cleanUiDetailsVisible: true,
ideContextState: null,
geminiMdFileCount: 0,
renderMarkdown: true,
filteredConsoleMessages: [],
history: [],
sessionStats: {
sessionId: 'test-session',
sessionStartTime: new Date(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
metrics: {} as any,
lastPromptTokenCount: 0,
sessionTokenCount: 0,
totalPrompts: 0,
promptCount: 0,
},
branchName: 'main',
debugMessage: '',
@@ -187,8 +211,12 @@ const createMockUIActions = (): UIActions =>
handleFinalSubmit: vi.fn(),
handleClearScreen: vi.fn(),
setShellModeActive: vi.fn(),
setCleanUiDetailsVisible: vi.fn(),
toggleCleanUiDetailsVisible: vi.fn(),
revealCleanUiDetailsTemporarily: vi.fn(),
onEscapePromptChange: vi.fn(),
vimHandleInput: vi.fn(),
setShortcutsHelpVisible: vi.fn(),
}) as Partial<UIActions> as UIActions;
const createMockConfig = (overrides = {}): Config =>
@@ -232,6 +260,11 @@ const renderComposer = (
);
describe('Composer', () => {
beforeEach(() => {
composerTestControls.suggestionsVisible = false;
composerTestControls.isAlternateBuffer = false;
});
afterEach(() => {
vi.restoreAllMocks();
});
@@ -337,17 +370,18 @@ describe('Composer', () => {
expect(output).toContain('LoadingIndicator: Thinking ...');
});
it('keeps shortcuts hint visible while loading', () => {
it('hides shortcuts hint while loading', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
elapsedTime: 1,
cleanUiDetailsVisible: false,
});
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).toContain('ShortcutsHint');
expect(output).not.toContain('ShortcutsHint');
});
it('renders LoadingIndicator without thought when accessibility disables loading phrases', () => {
@@ -513,6 +547,21 @@ describe('Composer', () => {
});
describe('Input and Indicators', () => {
it('hides non-essential UI details in clean mode', () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
});
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('ShortcutsHint');
expect(output).toContain('InputPrompt');
expect(output).not.toContain('Footer');
expect(output).not.toContain('ApprovalModeIndicator');
expect(output).not.toContain('ContextSummaryDisplay');
});
it('renders InputPrompt when input is active', () => {
const uiState = createMockUIState({
isInputActive: true,
@@ -581,6 +630,92 @@ describe('Composer', () => {
expect(lastFrame()).not.toContain('raw markdown mode');
});
it.each([
[ApprovalMode.YOLO, 'YOLO'],
[ApprovalMode.PLAN, 'plan'],
[ApprovalMode.AUTO_EDIT, 'auto edit'],
])(
'shows minimal mode badge "%s" when clean UI details are hidden',
(mode, label) => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
showApprovalModeIndicator: mode,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain(label);
},
);
it('hides minimal mode badge while loading in clean mode', () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
streamingState: StreamingState.Responding,
elapsedTime: 1,
showApprovalModeIndicator: ApprovalMode.PLAN,
});
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).not.toContain('plan');
expect(output).not.toContain('ShortcutsHint');
});
it('hides minimal mode badge while action-required state is active', () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
showApprovalModeIndicator: ApprovalMode.PLAN,
customDialog: (
<Box>
<Text>Prompt</Text>
</Box>
),
});
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).not.toContain('plan');
expect(output).not.toContain('ShortcutsHint');
});
it('shows Esc rewind prompt in minimal mode without showing full UI', () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
showEscapePrompt: true,
history: [{ id: 1, type: 'user', text: 'msg' }],
});
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('ToastDisplay');
expect(output).not.toContain('ContextSummaryDisplay');
});
it('shows context usage bleed-through when over 60%', () => {
const model = 'gemini-2.5-pro';
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
currentModel: model,
sessionStats: {
sessionId: 'test-session',
sessionStartTime: new Date(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
metrics: {} as any,
lastPromptTokenCount: Math.floor(tokenLimit(model) * 0.7),
promptCount: 0,
},
});
const settings = createMockSettings({
ui: {
footer: { hideContextPercentage: false },
},
});
const { lastFrame } = renderComposer(uiState, settings);
expect(lastFrame()).toContain('%');
});
});
describe('Error Details Display', () => {
@@ -679,11 +814,127 @@ describe('Composer', () => {
});
it('keeps shortcuts hint visible when no action is required', () => {
const uiState = createMockUIState();
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHint');
});
it('shows shortcuts hint when full UI details are visible', () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: true,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHint');
});
it('hides shortcuts hint while loading in minimal mode', () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
streamingState: StreamingState.Responding,
elapsedTime: 1,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
});
it('shows shortcuts help in minimal mode when toggled on', () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
shortcutsHelpVisible: true,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHelp');
});
it('hides shortcuts hint when suggestions are visible above input in alternate buffer', () => {
composerTestControls.isAlternateBuffer = true;
composerTestControls.suggestionsVisible = true;
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
showApprovalModeIndicator: ApprovalMode.PLAN,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
expect(lastFrame()).not.toContain('plan');
});
it('hides approval mode indicator when suggestions are visible above input in alternate buffer', () => {
composerTestControls.isAlternateBuffer = true;
composerTestControls.suggestionsVisible = true;
const uiState = createMockUIState({
cleanUiDetailsVisible: true,
showApprovalModeIndicator: ApprovalMode.YOLO,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).not.toContain('ApprovalModeIndicator');
});
it('keeps shortcuts hint when suggestions are visible below input in regular buffer', () => {
composerTestControls.isAlternateBuffer = false;
composerTestControls.suggestionsVisible = true;
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHint');
});
});
describe('Shortcuts Help', () => {
it('shows shortcuts help in passive state', () => {
const uiState = createMockUIState({
shortcutsHelpVisible: true,
streamingState: StreamingState.Idle,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHelp');
});
it('hides shortcuts help while streaming', () => {
const uiState = createMockUIState({
shortcutsHelpVisible: true,
streamingState: StreamingState.Responding,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHelp');
});
it('hides shortcuts help when action is required', () => {
const uiState = createMockUIState({
shortcutsHelpVisible: true,
customDialog: (
<Box>
<Text>Dialog content</Text>
</Box>
),
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHelp');
});
});
});
+249 -79
View File
@@ -4,8 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState } from 'react';
import { Box, useIsScreenReaderEnabled } from 'ink';
import { useState, useEffect, useMemo } from 'react';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { ApprovalMode, tokenLimit } from '@google/gemini-cli-core';
import { LoadingIndicator } from './LoadingIndicator.js';
import { StatusDisplay } from './StatusDisplay.js';
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
@@ -19,6 +20,7 @@ import { InputPrompt } from './InputPrompt.js';
import { Footer } from './Footer.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { HorizontalLine } from './shared/HorizontalLine.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
@@ -28,10 +30,15 @@ import { useVimMode } from '../contexts/VimModeContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { StreamingState, ToolCallStatus } from '../types.js';
import {
StreamingState,
type HistoryItemToolGroup,
ToolCallStatus,
} from '../types.js';
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
import { TodoTray } from './messages/Todo.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
import { theme } from '../semantic-colors.js';
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const config = useConfig();
@@ -48,14 +55,23 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const isAlternateBuffer = useAlternateBuffer();
const { showApprovalModeIndicator } = uiState;
const showUiDetails = uiState.cleanUiDetailsVisible;
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
const hideContextSummary =
suggestionsVisible && suggestionsPosition === 'above';
const hasPendingToolConfirmation = (uiState.pendingHistoryItems ?? []).some(
(item) =>
item.type === 'tool_group' &&
item.tools.some((tool) => tool.status === ToolCallStatus.Confirming),
const hasPendingToolConfirmation = useMemo(
() =>
(uiState.pendingHistoryItems ?? [])
.filter(
(item): item is HistoryItemToolGroup => item.type === 'tool_group',
)
.some((item) =>
item.tools.some((tool) => tool.status === ToolCallStatus.Confirming),
),
[uiState.pendingHistoryItems],
);
const hasPendingActionRequired =
hasPendingToolConfirmation ||
Boolean(uiState.commandConfirmationRequest) ||
@@ -65,13 +81,81 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
Boolean(uiState.quota.proQuotaRequest) ||
Boolean(uiState.quota.validationRequest) ||
Boolean(uiState.customDialog);
const isPassiveShortcutsHelpState =
uiState.isInputActive &&
uiState.streamingState === StreamingState.Idle &&
!hasPendingActionRequired;
const { setShortcutsHelpVisible } = uiActions;
useEffect(() => {
if (uiState.shortcutsHelpVisible && !isPassiveShortcutsHelpState) {
setShortcutsHelpVisible(false);
}
}, [
uiState.shortcutsHelpVisible,
isPassiveShortcutsHelpState,
setShortcutsHelpVisible,
]);
const showShortcutsHelp =
uiState.shortcutsHelpVisible &&
uiState.streamingState === StreamingState.Idle &&
!hasPendingActionRequired;
const hasToast = shouldShowToast(uiState);
const showLoadingIndicator =
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
uiState.streamingState === StreamingState.Responding &&
!hasPendingActionRequired;
const showApprovalIndicator = !uiState.shellModeActive;
const hideUiDetailsForSuggestions =
suggestionsVisible && suggestionsPosition === 'above';
const showApprovalIndicator =
!uiState.shellModeActive && !hideUiDetailsForSuggestions;
const showRawMarkdownIndicator = !uiState.renderMarkdown;
const modeBleedThrough =
showApprovalModeIndicator === ApprovalMode.YOLO
? { text: 'YOLO', color: theme.status.error }
: showApprovalModeIndicator === ApprovalMode.PLAN
? { text: 'plan', color: theme.status.success }
: showApprovalModeIndicator === ApprovalMode.AUTO_EDIT
? { text: 'auto edit', color: theme.status.warning }
: null;
const hideMinimalModeHintWhileBusy =
!showUiDetails && (showLoadingIndicator || hasPendingActionRequired);
const minimalModeBleedThrough = hideMinimalModeHintWhileBusy
? null
: modeBleedThrough;
const hasMinimalStatusBleedThrough = shouldShowToast(uiState);
const contextTokenLimit =
typeof uiState.currentModel === 'string' && uiState.currentModel.length > 0
? tokenLimit(uiState.currentModel)
: 0;
const showMinimalContextBleedThrough =
!settings.merged.ui.footer.hideContextPercentage &&
typeof uiState.currentModel === 'string' &&
uiState.currentModel.length > 0 &&
contextTokenLimit > 0 &&
uiState.sessionStats.lastPromptTokenCount / contextTokenLimit > 0.6;
const hideShortcutsHintForSuggestions = hideUiDetailsForSuggestions;
const showShortcutsHint =
settings.merged.ui.showShortcutsHint &&
!hideShortcutsHintForSuggestions &&
!hideMinimalModeHintWhileBusy &&
!hasPendingActionRequired &&
(!showUiDetails || !showLoadingIndicator);
const showMinimalModeBleedThrough =
!hideUiDetailsForSuggestions && Boolean(minimalModeBleedThrough);
const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator;
const showMinimalBleedThroughRow =
!showUiDetails &&
(showMinimalModeBleedThrough ||
hasMinimalStatusBleedThrough ||
showMinimalContextBleedThrough);
const showMinimalMetaRow =
!showUiDetails &&
(showMinimalInlineLoading ||
showMinimalBleedThroughRow ||
showShortcutsHint);
return (
<Box
@@ -88,9 +172,11 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
/>
)}
<QueuedMessageDisplay messageQueue={uiState.messageQueue} />
{showUiDetails && (
<QueuedMessageDisplay messageQueue={uiState.messageQueue} />
)}
<TodoTray />
{showUiDetails && <TodoTray />}
<Box marginTop={1} width="100%" flexDirection="column">
<Box
@@ -106,7 +192,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
alignItems="center"
flexGrow={1}
>
{showLoadingIndicator && (
{showUiDetails && showLoadingIndicator && (
<LoadingIndicator
inline
thought={
@@ -133,87 +219,169 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
>
{settings.merged.ui.showShortcutsHint &&
!hasPendingActionRequired && <ShortcutsHint />}
{showUiDetails && showShortcutsHint && <ShortcutsHint />}
</Box>
</Box>
{uiState.shortcutsHelpVisible && <ShortcutsHelp />}
<HorizontalLine />
<Box
justifyContent={
settings.merged.ui.hideContextSummary
? 'flex-start'
: 'space-between'
}
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
{showMinimalMetaRow && (
<Box
marginLeft={1}
marginRight={isNarrow ? 0 : 1}
flexDirection="row"
alignItems="center"
flexGrow={1}
justifyContent="space-between"
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
{hasToast ? (
<ToastDisplay />
) : (
!showLoadingIndicator && (
<Box
marginLeft={1}
marginRight={isNarrow ? 0 : 1}
flexDirection="row"
alignItems={isNarrow ? 'flex-start' : 'center'}
flexGrow={1}
>
{showMinimalInlineLoading && (
<LoadingIndicator
inline
thought={
uiState.streamingState ===
StreamingState.WaitingForConfirmation ||
config.getAccessibility()?.enableLoadingPhrases === false
? undefined
: uiState.thought
}
currentLoadingPhrase={
config.getAccessibility()?.enableLoadingPhrases === false
? undefined
: uiState.currentLoadingPhrase
}
thoughtLabel={
inlineThinkingMode === 'full' ? 'Thinking ...' : undefined
}
elapsedTime={uiState.elapsedTime}
/>
)}
{showMinimalModeBleedThrough && minimalModeBleedThrough && (
<Text color={minimalModeBleedThrough.color}>
{minimalModeBleedThrough.text}
</Text>
)}
{hasMinimalStatusBleedThrough && (
<Box
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
marginLeft={
showMinimalInlineLoading || showMinimalModeBleedThrough
? 1
: 0
}
>
{showApprovalIndicator && (
<ApprovalModeIndicator
approvalMode={showApprovalModeIndicator}
isPlanEnabled={config.isPlanEnabled()}
/>
)}
{uiState.shellModeActive && (
<Box
marginLeft={showApprovalIndicator && !isNarrow ? 1 : 0}
marginTop={showApprovalIndicator && isNarrow ? 1 : 0}
>
<ShellModeIndicator />
</Box>
)}
{showRawMarkdownIndicator && (
<Box
marginLeft={
(showApprovalIndicator || uiState.shellModeActive) &&
!isNarrow
? 1
: 0
}
marginTop={
(showApprovalIndicator || uiState.shellModeActive) &&
isNarrow
? 1
: 0
}
>
<RawMarkdownIndicator />
</Box>
)}
<ToastDisplay />
</Box>
)
)}
</Box>
{(showMinimalContextBleedThrough || showShortcutsHint) && (
<Box
marginTop={isNarrow && showMinimalBleedThroughRow ? 1 : 0}
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
>
{showMinimalContextBleedThrough && (
<ContextUsageDisplay
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
model={uiState.currentModel}
terminalWidth={uiState.terminalWidth}
/>
)}
{showShortcutsHint && (
<Box
marginLeft={
showMinimalContextBleedThrough && !isNarrow ? 1 : 0
}
marginTop={
showMinimalContextBleedThrough && isNarrow ? 1 : 0
}
>
<ShortcutsHint />
</Box>
)}
</Box>
)}
</Box>
)}
{showShortcutsHelp && <ShortcutsHelp />}
{showUiDetails && <HorizontalLine />}
{showUiDetails && (
<Box
marginTop={isNarrow ? 1 : 0}
flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
justifyContent={
settings.merged.ui.hideContextSummary
? 'flex-start'
: 'space-between'
}
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
{!showLoadingIndicator && (
<StatusDisplay hideContextSummary={hideContextSummary} />
)}
<Box
marginLeft={1}
marginRight={isNarrow ? 0 : 1}
flexDirection="row"
alignItems="center"
flexGrow={1}
>
{hasToast ? (
<ToastDisplay />
) : (
!showLoadingIndicator && (
<Box
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
{showApprovalIndicator && (
<ApprovalModeIndicator
approvalMode={showApprovalModeIndicator}
isPlanEnabled={config.isPlanEnabled()}
/>
)}
{uiState.shellModeActive && (
<Box
marginLeft={showApprovalIndicator && !isNarrow ? 1 : 0}
marginTop={showApprovalIndicator && isNarrow ? 1 : 0}
>
<ShellModeIndicator />
</Box>
)}
{showRawMarkdownIndicator && (
<Box
marginLeft={
(showApprovalIndicator || uiState.shellModeActive) &&
!isNarrow
? 1
: 0
}
marginTop={
(showApprovalIndicator || uiState.shellModeActive) &&
isNarrow
? 1
: 0
}
>
<RawMarkdownIndicator />
</Box>
)}
</Box>
)
)}
</Box>
<Box
marginTop={isNarrow ? 1 : 0}
flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
>
{!showLoadingIndicator && (
<StatusDisplay hideContextSummary={hideContextSummary} />
)}
</Box>
</Box>
</Box>
)}
</Box>
{uiState.showErrorDetails && (
{showUiDetails && uiState.showErrorDetails && (
<OverflowProvider>
<Box flexDirection="column">
<DetailedMessagesDisplay
@@ -265,7 +433,9 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
/>
)}
{!settings.merged.ui.hideFooter && !isScreenReaderEnabled && <Footer />}
{showUiDetails &&
!settings.merged.ui.hideFooter &&
!isScreenReaderEnabled && <Footer />}
</Box>
);
};
@@ -210,7 +210,6 @@ describe('<HistoryItemDisplay />', () => {
command: 'echo "\u001b[31mhello\u001b[0m"',
rootCommand: 'echo',
rootCommands: ['echo'],
onConfirm: async () => {},
},
},
],
@@ -43,7 +43,6 @@ interface HistoryItemDisplayProps {
availableTerminalHeight?: number;
terminalWidth: number;
isPending: boolean;
isFocused?: boolean;
commands?: readonly SlashCommand[];
activeShellPtyId?: number | null;
embeddedShellFocused?: boolean;
@@ -56,7 +55,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
terminalWidth,
isPending,
commands,
isFocused = true,
activeShellPtyId,
embeddedShellFocused,
availableTerminalHeightGemini,
@@ -179,7 +177,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
groupId={itemForDisplay.id}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
isFocused={isFocused}
activeShellPtyId={activeShellPtyId}
embeddedShellFocused={embeddedShellFocused}
borderTop={itemForDisplay.borderTop}
@@ -149,8 +149,14 @@ describe('InputPrompt', () => {
);
const mockedUseKittyKeyboardProtocol = vi.mocked(useKittyKeyboardProtocol);
const mockSetEmbeddedShellFocused = vi.fn();
const mockSetCleanUiDetailsVisible = vi.fn();
const mockToggleCleanUiDetailsVisible = vi.fn();
const mockRevealCleanUiDetailsTemporarily = vi.fn();
const uiActions = {
setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
setCleanUiDetailsVisible: mockSetCleanUiDetailsVisible,
toggleCleanUiDetailsVisible: mockToggleCleanUiDetailsVisible,
revealCleanUiDetailsTemporarily: mockRevealCleanUiDetailsTemporarily,
};
beforeEach(() => {
@@ -1543,7 +1549,6 @@ describe('InputPrompt', () => {
{ color: 'black', name: 'black' },
{ color: '#000000', name: '#000000' },
{ color: '#000', name: '#000' },
{ color: undefined, name: 'default (black)' },
{ color: 'white', name: 'white' },
{ color: '#ffffff', name: '#ffffff' },
{ color: '#fff', name: '#fff' },
@@ -1613,6 +1618,11 @@ describe('InputPrompt', () => {
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{
uiState: {
terminalBackgroundColor: 'black',
} as Partial<UIState>,
},
);
await waitFor(() => {
@@ -2945,29 +2955,29 @@ describe('InputPrompt', () => {
});
});
describe('Tab focus toggle', () => {
describe('Tab clean UI toggle', () => {
it.each([
{
name: 'should toggle focus in on Tab when no suggestions or ghost text',
name: 'should toggle clean UI details on double-Tab when no suggestions or ghost text',
showSuggestions: false,
ghostText: '',
suggestions: [],
expectedFocusToggle: true,
expectedUiToggle: true,
},
{
name: 'should accept ghost text and NOT toggle focus on Tab',
name: 'should accept ghost text and NOT toggle clean UI details on Tab',
showSuggestions: false,
ghostText: 'ghost text',
suggestions: [],
expectedFocusToggle: false,
expectedUiToggle: false,
expectedAcceptCall: true,
},
{
name: 'should NOT toggle focus on Tab when suggestions are present',
name: 'should NOT toggle clean UI details on Tab when suggestions are present',
showSuggestions: true,
ghostText: '',
suggestions: [{ label: 'test', value: 'test' }],
expectedFocusToggle: false,
expectedUiToggle: false,
},
])(
'$name',
@@ -2975,7 +2985,7 @@ describe('InputPrompt', () => {
showSuggestions,
ghostText,
suggestions,
expectedFocusToggle,
expectedUiToggle,
expectedAcceptCall,
}) => {
const mockAccept = vi.fn();
@@ -2997,21 +3007,24 @@ describe('InputPrompt', () => {
<InputPrompt {...props} />,
{
uiActions,
uiState: { activePtyId: 1 },
uiState: {},
},
);
await act(async () => {
stdin.write('\t');
if (expectedUiToggle) {
stdin.write('\t');
}
});
await waitFor(() => {
if (expectedFocusToggle) {
expect(uiActions.setEmbeddedShellFocused).toHaveBeenCalledWith(
true,
);
if (expectedUiToggle) {
expect(uiActions.toggleCleanUiDetailsVisible).toHaveBeenCalled();
} else {
expect(uiActions.setEmbeddedShellFocused).not.toHaveBeenCalled();
expect(
uiActions.toggleCleanUiDetailsVisible,
).not.toHaveBeenCalled();
}
if (expectedAcceptCall) {
@@ -3021,6 +3034,75 @@ describe('InputPrompt', () => {
unmount();
},
);
it('should not reveal clean UI details on Shift+Tab when hidden', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: false,
suggestions: [],
promptCompletion: {
text: '',
accept: vi.fn(),
clear: vi.fn(),
isLoading: false,
isActive: false,
markSelected: vi.fn(),
},
});
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{
uiActions,
uiState: { activePtyId: 1, cleanUiDetailsVisible: false },
},
);
await act(async () => {
stdin.write('\x1b[Z');
});
await waitFor(() => {
expect(
uiActions.revealCleanUiDetailsTemporarily,
).not.toHaveBeenCalled();
});
unmount();
});
it('should toggle clean UI details on double-Tab by default', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: false,
suggestions: [],
promptCompletion: {
text: '',
accept: vi.fn(),
clear: vi.fn(),
isLoading: false,
isActive: false,
markSelected: vi.fn(),
},
});
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
{
uiActions,
uiState: {},
},
);
await act(async () => {
stdin.write('\t');
stdin.write('\t');
});
await waitFor(() => {
expect(uiActions.toggleCleanUiDetailsVisible).toHaveBeenCalled();
});
unmount();
});
});
describe('mouse interaction', () => {
@@ -4342,6 +4424,18 @@ describe('InputPrompt', () => {
vi.mocked(clipboardy.read).mockResolvedValue('clipboard text');
},
},
{
name: 'Ctrl+R hotkey is pressed',
input: '\x12',
},
{
name: 'Ctrl+X hotkey is pressed',
input: '\x18',
},
{
name: 'F12 hotkey is pressed',
input: '\x1b[24~',
},
])(
'should close shortcuts help when a $name',
async ({ input, setupMocks, mouseEventsEnabled }) => {
+42 -3
View File
@@ -75,6 +75,7 @@ import { useMouseClick } from '../hooks/useMouseClick.js';
import { useMouse, type MouseEvent } from '../contexts/MouseContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { shouldDismissShortcutsHelpOnHotkey } from '../utils/shortcutsHelp.js';
/**
* Returns if the terminal can be trusted to handle paste events atomically
@@ -143,6 +144,8 @@ export function isLargePaste(text: string): boolean {
);
}
const DOUBLE_TAB_CLEAN_UI_TOGGLE_WINDOW_MS = 350;
/**
* Attempt to toggle expansion of a paste placeholder in the buffer.
* Returns true if a toggle action was performed or hint was shown, false otherwise.
@@ -210,18 +213,22 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const { merged: settings } = useSettings();
const kittyProtocol = useKittyKeyboardProtocol();
const isShellFocused = useShellFocusState();
const { setEmbeddedShellFocused, setShortcutsHelpVisible } = useUIActions();
const {
setEmbeddedShellFocused,
setShortcutsHelpVisible,
toggleCleanUiDetailsVisible,
} = useUIActions();
const {
terminalWidth,
activePtyId,
history,
terminalBackgroundColor,
backgroundShells,
backgroundShellHeight,
shortcutsHelpVisible,
} = useUIState();
const [suppressCompletion, setSuppressCompletion] = useState(false);
const escPressCount = useRef(0);
const lastPlainTabPressTimeRef = useRef<number | null>(null);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
const [recentUnsafePasteTime, setRecentUnsafePasteTime] = useState<
@@ -623,6 +630,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return false;
}
const isPlainTab =
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
const hasTabCompletionInteraction =
completion.showSuggestions ||
Boolean(completion.promptCompletion.text) ||
reverseSearchActive ||
commandSearchActive;
if (isPlainTab) {
if (!hasTabCompletionInteraction) {
const now = Date.now();
const isDoubleTabPress =
lastPlainTabPressTimeRef.current !== null &&
now - lastPlainTabPressTimeRef.current <=
DOUBLE_TAB_CLEAN_UI_TOGGLE_WINDOW_MS;
if (isDoubleTabPress) {
lastPlainTabPressTimeRef.current = null;
toggleCleanUiDetailsVisible();
return true;
}
lastPlainTabPressTimeRef.current = now;
} else {
lastPlainTabPressTimeRef.current = null;
}
} else {
lastPlainTabPressTimeRef.current = null;
}
if (key.name === 'paste') {
if (shortcutsHelpVisible) {
setShortcutsHelpVisible(false);
@@ -661,6 +695,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true;
}
if (shortcutsHelpVisible && shouldDismissShortcutsHelpOnHotkey(key)) {
setShortcutsHelpVisible(false);
}
if (shortcutsHelpVisible) {
if (key.sequence === '?' && key.insertable) {
setShortcutsHelpVisible(false);
@@ -1167,6 +1205,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
kittyProtocol.enabled,
shortcutsHelpVisible,
setShortcutsHelpVisible,
toggleCleanUiDetailsVisible,
tryLoadQueuedMessages,
setBannerVisible,
onSubmit,
@@ -1312,7 +1351,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const useBackgroundColor = config.getUseBackgroundColor();
const isLowColor = isLowColorDepth();
const terminalBg = terminalBackgroundColor || 'black';
const terminalBg = theme.background.primary || 'black';
// We should fallback to lines if the background color is disabled OR if it is
// enabled but we are in a low color depth terminal where we don't have a safe
@@ -9,11 +9,15 @@ import { waitFor } from '../../test-utils/async.js';
import { MainContent } from './MainContent.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Box, Text } from 'ink';
import type React from 'react';
import { act, useState, type JSX } from 'react';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { ToolCallStatus } from '../types.js';
import { SHELL_COMMAND_NAME } from '../constants.js';
import type { UIState } from '../contexts/UIStateContext.js';
import {
UIStateContext,
useUIState,
type UIState,
} from '../contexts/UIStateContext.js';
// Mock dependencies
vi.mock('../contexts/SettingsContext.js', async () => {
@@ -45,7 +49,9 @@ vi.mock('../hooks/useAlternateBuffer.js', () => ({
}));
vi.mock('./AppHeader.js', () => ({
AppHeader: () => <Text>AppHeader</Text>,
AppHeader: ({ showDetails = true }: { showDetails?: boolean }) => (
<Text>{showDetails ? 'AppHeader(full)' : 'AppHeader(minimal)'}</Text>
),
}));
vi.mock('./ShowMoreLines.js', () => ({
@@ -58,7 +64,7 @@ vi.mock('./shared/ScrollableList.js', () => ({
renderItem,
}: {
data: unknown[];
renderItem: (props: { item: unknown }) => React.JSX.Element;
renderItem: (props: { item: unknown }) => JSX.Element;
}) => (
<Box flexDirection="column">
<Text>ScrollableList</Text>
@@ -87,6 +93,7 @@ describe('MainContent', () => {
activePtyId: undefined,
embeddedShellFocused: false,
historyRemountKey: 0,
cleanUiDetailsVisible: true,
bannerData: { defaultText: '', warningText: '' },
bannerVisible: false,
copyModeEnabled: false,
@@ -101,7 +108,7 @@ describe('MainContent', () => {
const { lastFrame } = renderWithProviders(<MainContent />, {
uiState: defaultMockUiState as Partial<UIState>,
});
await waitFor(() => expect(lastFrame()).toContain('AppHeader'));
await waitFor(() => expect(lastFrame()).toContain('AppHeader(full)'));
const output = lastFrame();
expect(output).toContain('Hello');
@@ -116,11 +123,81 @@ describe('MainContent', () => {
await waitFor(() => expect(lastFrame()).toContain('ScrollableList'));
const output = lastFrame();
expect(output).toContain('AppHeader');
expect(output).toContain('AppHeader(full)');
expect(output).toContain('Hello');
expect(output).toContain('Hi there');
});
it('renders minimal header in minimal mode (alternate buffer)', async () => {
vi.mocked(useAlternateBuffer).mockReturnValue(true);
const { lastFrame } = renderWithProviders(<MainContent />, {
uiState: {
...defaultMockUiState,
cleanUiDetailsVisible: false,
} as Partial<UIState>,
});
await waitFor(() => expect(lastFrame()).toContain('Hello'));
const output = lastFrame();
expect(output).toContain('AppHeader(minimal)');
expect(output).not.toContain('AppHeader(full)');
expect(output).toContain('Hello');
});
it('restores full header details after toggle in alternate buffer mode', async () => {
vi.mocked(useAlternateBuffer).mockReturnValue(true);
let setShowDetails: ((visible: boolean) => void) | undefined;
const ToggleHarness = () => {
const outerState = useUIState();
const [showDetails, setShowDetailsState] = useState(
outerState.cleanUiDetailsVisible,
);
setShowDetails = setShowDetailsState;
return (
<UIStateContext.Provider
value={{ ...outerState, cleanUiDetailsVisible: showDetails }}
>
<MainContent />
</UIStateContext.Provider>
);
};
const { lastFrame } = renderWithProviders(<ToggleHarness />, {
uiState: {
...defaultMockUiState,
cleanUiDetailsVisible: false,
} as Partial<UIState>,
});
await waitFor(() => expect(lastFrame()).toContain('AppHeader(minimal)'));
if (!setShowDetails) {
throw new Error('setShowDetails was not initialized');
}
const setShowDetailsSafe = setShowDetails;
act(() => {
setShowDetailsSafe(true);
});
await waitFor(() => expect(lastFrame()).toContain('AppHeader(full)'));
});
it('always renders full header details in normal buffer mode', async () => {
vi.mocked(useAlternateBuffer).mockReturnValue(false);
const { lastFrame } = renderWithProviders(<MainContent />, {
uiState: {
...defaultMockUiState,
cleanUiDetailsVisible: false,
} as Partial<UIState>,
});
await waitFor(() => expect(lastFrame()).toContain('AppHeader(full)'));
expect(lastFrame()).not.toContain('AppHeader(minimal)');
});
it('does not constrain height in alternate buffer mode', async () => {
vi.mocked(useAlternateBuffer).mockReturnValue(true);
const { lastFrame } = renderWithProviders(<MainContent />, {
@@ -129,7 +206,9 @@ describe('MainContent', () => {
await waitFor(() => expect(lastFrame()).toContain('Hello'));
const output = lastFrame();
expect(output).toMatchSnapshot();
expect(output).toContain('AppHeader(full)');
expect(output).toContain('Hello');
expect(output).toContain('Hi there');
});
describe('MainContent Tool Output Height Logic', () => {
@@ -210,6 +289,7 @@ describe('MainContent', () => {
isEditorDialogOpen: false,
slashCommands: [],
historyRemountKey: 0,
cleanUiDetailsVisible: true,
bannerData: {
defaultText: '',
warningText: '',
+17 -8
View File
@@ -19,7 +19,6 @@ import { useMemo, memo, useCallback, useEffect, useRef } from 'react';
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { useConfig } from '../contexts/ConfigContext.js';
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
const MemoizedAppHeader = memo(AppHeader);
@@ -31,12 +30,10 @@ const MemoizedAppHeader = memo(AppHeader);
export const MainContent = () => {
const { version } = useAppContext();
const uiState = useUIState();
const config = useConfig();
const isAlternateBuffer = useAlternateBuffer();
const confirmingTool = useConfirmingTool();
const showConfirmationQueue =
config.isEventDrivenSchedulerEnabled() && confirmingTool !== null;
const showConfirmationQueue = confirmingTool !== null;
const scrollableListRef = useRef<VirtualizedListRef<unknown>>(null);
@@ -51,7 +48,9 @@ export const MainContent = () => {
mainAreaWidth,
staticAreaMaxItemHeight,
availableTerminalHeight,
cleanUiDetailsVisible,
} = uiState;
const showHeaderDetails = cleanUiDetailsVisible;
const historyItems = useMemo(
() =>
@@ -89,7 +88,6 @@ export const MainContent = () => {
terminalWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
isFocused={!uiState.isEditorDialogOpen}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
/>
@@ -105,7 +103,6 @@ export const MainContent = () => {
isAlternateBuffer,
availableTerminalHeight,
mainAreaWidth,
uiState.isEditorDialogOpen,
uiState.activePtyId,
uiState.embeddedShellFocused,
showConfirmationQueue,
@@ -125,7 +122,13 @@ export const MainContent = () => {
const renderItem = useCallback(
({ item }: { item: (typeof virtualizedData)[number] }) => {
if (item.type === 'header') {
return <MemoizedAppHeader key="app-header" version={version} />;
return (
<MemoizedAppHeader
key="app-header"
version={version}
showDetails={showHeaderDetails}
/>
);
} else if (item.type === 'history') {
return (
<MemoizedHistoryItemDisplay
@@ -142,7 +145,13 @@ export const MainContent = () => {
return pendingItems;
}
},
[version, mainAreaWidth, uiState.slashCommands, pendingItems],
[
showHeaderDetails,
version,
mainAreaWidth,
uiState.slashCommands,
pendingItems,
],
);
if (isAlternateBuffer) {
@@ -46,4 +46,10 @@ describe('ShortcutsHelp', () => {
expect(lastFrame()).toMatchSnapshot();
},
);
it('always shows Tab Tab focus UI shortcut', () => {
const rendered = renderWithProviders(<ShortcutsHelp />);
expect(rendered.lastFrame()).toContain('Tab Tab');
rendered.unmount();
});
});
@@ -22,13 +22,14 @@ const buildShortcutItems = (): ShortcutItem[] => {
return [
{ key: '!', description: 'shell mode' },
{ key: '@', description: 'select file or folder' },
{ key: 'Esc Esc', description: 'clear & rewind' },
{ key: 'Tab Tab', description: 'focus UI' },
{ key: 'Ctrl+Y', description: 'YOLO mode' },
{ key: 'Shift+Tab', description: 'cycle mode' },
{ key: 'Ctrl+V', description: 'paste images' },
{ key: '@', description: 'select file or folder' },
{ key: 'Ctrl+Y', description: 'YOLO mode' },
{ key: 'Ctrl+R', description: 'reverse-search history' },
{ key: 'Esc Esc', description: 'clear prompt / rewind' },
{ key: `${altLabel}+M`, description: 'raw markdown mode' },
{ key: 'Ctrl+R', description: 'reverse-search history' },
{ key: 'Ctrl+X', description: 'open external editor' },
];
};
@@ -46,15 +47,29 @@ const Shortcut: React.FC<{ item: ShortcutItem }> = ({ item }) => (
export const ShortcutsHelp: React.FC = () => {
const { terminalWidth } = useUIState();
const items = buildShortcutItems();
const isNarrow = isNarrowWidth(terminalWidth);
const items = buildShortcutItems();
const itemsForDisplay = isNarrow
? items
: [
// Keep first column stable: !, @, Esc Esc, Tab Tab.
items[0],
items[5],
items[6],
items[1],
items[4],
items[7],
items[2],
items[8],
items[9],
items[3],
];
return (
<Box flexDirection="column" width="100%">
<SectionHeader title="Shortcuts (for more, see /help)" />
<Box flexDirection="row" flexWrap="wrap" paddingLeft={1} paddingRight={2}>
{items.map((item, index) => (
{itemsForDisplay.map((item, index) => (
<Box
key={`${item.key}-${index}`}
width={isNarrow ? '100%' : '33%'}
@@ -10,7 +10,12 @@ import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
export const ShortcutsHint: React.FC = () => {
const { shortcutsHelpVisible } = useUIState();
const { cleanUiDetailsVisible, shortcutsHelpVisible } = useUIState();
if (!cleanUiDetailsVisible) {
return <Text color={theme.text.secondary}> press tab twice for more </Text>;
}
const highlightColor = shortcutsHelpVisible
? theme.text.accent
: theme.text.secondary;
+19 -28
View File
@@ -9,7 +9,7 @@ import { useCallback, useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js';
import { pickDefaultThemeName } from '../themes/theme.js';
import { pickDefaultThemeName, type Theme } from '../themes/theme.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { DiffRenderer } from './messages/DiffRenderer.js';
import { colorizeCode } from '../utils/CodeColorizer.js';
@@ -27,7 +27,10 @@ import { useUIState } from '../contexts/UIStateContext.js';
interface ThemeDialogProps {
/** Callback function when a theme is selected */
onSelect: (themeName: string, scope: LoadableSettingScope) => void;
onSelect: (
themeName: string,
scope: LoadableSettingScope,
) => void | Promise<void>;
/** Callback function when the dialog is cancelled */
onCancel: () => void;
@@ -40,24 +43,21 @@ interface ThemeDialogProps {
terminalWidth: number;
}
import {
getThemeTypeFromBackgroundColor,
resolveColor,
} from '../themes/color-utils.js';
import { resolveColor } from '../themes/color-utils.js';
function generateThemeItem(
name: string,
typeDisplay: string,
themeType: string,
themeBackground: string | undefined,
fullTheme: Theme | undefined,
terminalBackgroundColor: string | undefined,
terminalThemeType: 'light' | 'dark' | undefined,
) {
const isCompatible =
themeType === 'custom' ||
terminalThemeType === undefined ||
themeType === 'ansi' ||
themeType === terminalThemeType;
const isCompatible = fullTheme
? themeManager.isThemeCompatible(fullTheme, terminalBackgroundColor)
: true;
const themeBackground = fullTheme
? resolveColor(fullTheme.colors.Background)
: undefined;
const isBackgroundMatch =
terminalBackgroundColor &&
@@ -111,26 +111,17 @@ export function ThemeDialog({
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
const terminalThemeType = getThemeTypeFromBackgroundColor(
terminalBackgroundColor,
);
// Generate theme items
const themeItems = themeManager
.getAvailableThemes()
.map((theme) => {
const fullTheme = themeManager.getTheme(theme.name);
const themeBackground = fullTheme
? resolveColor(fullTheme.colors.Background)
: undefined;
return generateThemeItem(
theme.name,
capitalize(theme.type),
theme.type,
themeBackground,
fullTheme,
terminalBackgroundColor,
terminalThemeType,
);
})
.sort((a, b) => {
@@ -149,8 +140,8 @@ export function ThemeDialog({
const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0;
const handleThemeSelect = useCallback(
(themeName: string) => {
onSelect(themeName, selectedScope);
async (themeName: string) => {
await onSelect(themeName, selectedScope);
refreshStatic();
},
[onSelect, selectedScope, refreshStatic],
@@ -166,8 +157,8 @@ export function ThemeDialog({
}, []);
const handleScopeSelect = useCallback(
(scope: LoadableSettingScope) => {
onSelect(highlightedThemeName, scope);
async (scope: LoadableSettingScope) => {
await onSelect(highlightedThemeName, scope);
refreshStatic();
},
[onSelect, highlightedThemeName, refreshStatic],
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect } from 'vitest';
import { Box } from 'ink';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { ToolCallStatus, StreamingState } from '../types.js';
@@ -12,6 +12,31 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import type { Config } from '@google/gemini-cli-core';
import type { ConfirmingToolState } from '../hooks/useConfirmingTool.js';
import { theme } from '../semantic-colors.js';
vi.mock('./StickyHeader.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./StickyHeader.js')>();
return {
...actual,
StickyHeader: vi.fn((props) => actual.StickyHeader(props)),
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
validatePlanPath: vi.fn().mockResolvedValue(undefined),
validatePlanContent: vi.fn().mockResolvedValue(undefined),
processSingleFileContent: vi.fn().mockResolvedValue({
llmContent: 'Plan content goes here',
error: undefined,
}),
};
});
const { StickyHeader } = await import('./StickyHeader.js');
describe('ToolConfirmationQueue', () => {
const mockConfig = {
@@ -19,8 +44,19 @@ describe('ToolConfirmationQueue', () => {
getIdeMode: () => false,
getModel: () => 'gemini-pro',
getDebugMode: () => false,
getTargetDir: () => '/mock/target/dir',
getFileSystemService: () => ({
readFile: vi.fn().mockResolvedValue('Plan content'),
}),
storage: {
getProjectTempPlansDir: () => '/mock/temp/plans',
},
} as unknown as Config;
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the confirming tool with progress indicator', () => {
const confirmingTool = {
tool: {
@@ -34,7 +70,6 @@ describe('ToolConfirmationQueue', () => {
command: 'ls',
rootCommand: 'ls',
rootCommands: ['ls'],
onConfirm: vi.fn(),
},
},
index: 1,
@@ -60,6 +95,9 @@ describe('ToolConfirmationQueue', () => {
expect(output).toContain('list files'); // Tool description
expect(output).toContain("Allow execution of: 'ls'?");
expect(output).toMatchSnapshot();
const stickyHeaderProps = vi.mocked(StickyHeader).mock.calls[0][0];
expect(stickyHeaderProps.borderColor).toBe(theme.status.warning);
});
it('returns null if tool has no confirmation details', () => {
@@ -105,7 +143,6 @@ describe('ToolConfirmationQueue', () => {
fileDiff: longDiff,
originalContent: 'old',
newContent: 'new',
onConfirm: vi.fn(),
},
},
index: 1,
@@ -153,7 +190,6 @@ describe('ToolConfirmationQueue', () => {
fileDiff: longDiff,
originalContent: 'old',
newContent: 'new',
onConfirm: vi.fn(),
},
},
index: 1,
@@ -203,7 +239,6 @@ describe('ToolConfirmationQueue', () => {
fileDiff: longDiff,
originalContent: 'old',
newContent: 'new',
onConfirm: vi.fn(),
},
},
index: 1,
@@ -229,4 +264,80 @@ describe('ToolConfirmationQueue', () => {
expect(output).not.toContain('Press ctrl-o to show more lines');
expect(output).toMatchSnapshot();
});
it('renders AskUser tool confirmation with Success color', () => {
const confirmingTool = {
tool: {
callId: 'call-1',
name: 'ask_user',
description: 'ask user',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'ask_user' as const,
questions: [],
onConfirm: vi.fn(),
},
},
index: 1,
total: 1,
};
const { lastFrame } = renderWithProviders(
<ToolConfirmationQueue
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
/>,
{
config: mockConfig,
uiState: {
terminalWidth: 80,
},
},
);
const output = lastFrame();
expect(output).toMatchSnapshot();
const stickyHeaderProps = vi.mocked(StickyHeader).mock.calls[0][0];
expect(stickyHeaderProps.borderColor).toBe(theme.status.success);
});
it('renders ExitPlanMode tool confirmation with Success color', async () => {
const confirmingTool = {
tool: {
callId: 'call-1',
name: 'exit_plan_mode',
description: 'exit plan mode',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'exit_plan_mode' as const,
planPath: '/path/to/plan',
onConfirm: vi.fn(),
},
},
index: 1,
total: 1,
};
const { lastFrame } = renderWithProviders(
<ToolConfirmationQueue
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
/>,
{
config: mockConfig,
uiState: {
terminalWidth: 80,
},
},
);
await waitFor(() => {
expect(lastFrame()).toContain('Plan content goes here');
});
const output = lastFrame();
expect(output).toMatchSnapshot();
const stickyHeaderProps = vi.mocked(StickyHeader).mock.calls[0][0];
expect(stickyHeaderProps.borderColor).toBe(theme.status.success);
});
});
@@ -70,10 +70,11 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
? Math.max(maxHeight - 6, 4)
: undefined;
const borderColor = theme.status.warning;
const hideToolIdentity =
const isRoutine =
tool.confirmationDetails?.type === 'ask_user' ||
tool.confirmationDetails?.type === 'exit_plan_mode';
const borderColor = isRoutine ? theme.status.success : theme.status.warning;
const hideToolIdentity = isRoutine;
return (
<OverflowProvider>
@@ -90,7 +91,7 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
marginBottom={hideToolIdentity ? 0 : 1}
justifyContent="space-between"
>
<Text color={theme.status.warning} bold>
<Text color={borderColor} bold>
{getConfirmationHeader(tool.confirmationDetails)}
</Text>
{total > 1 && (
@@ -77,6 +77,39 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Pasted Text: 10 lines]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 5`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
 > [Pasted Text: 10 lines] 
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
 > line1 
 line2 
 line3 
 line4 
 line5 
 line6 
 line7 
 line8 
 line9 
 line10 
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 7`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
 > [Pasted Text: 10 lines] 
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file
@@ -2,7 +2,7 @@
exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Focused shell should expand' 1`] = `
"ScrollableList
AppHeader
AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command Running a long command... │
│ │
@@ -33,7 +33,7 @@ ShowMoreLines"
exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Unfocused shell' 1`] = `
"ScrollableList
AppHeader
AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command Running a long command... │
│ │
@@ -57,7 +57,7 @@ ShowMoreLines"
`;
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Constrained height' 1`] = `
"AppHeader
"AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command Running a long command... │
│ │
@@ -81,7 +81,7 @@ ShowMoreLines"
`;
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unconstrained height' 1`] = `
"AppHeader
"AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command Running a long command... │
│ │
@@ -103,14 +103,3 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unc
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
ShowMoreLines"
`;
exports[`MainContent > does not constrain height in alternate buffer mode 1`] = `
"ScrollableList
AppHeader
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Hello
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
✦ Hi there
ShowMoreLines
"
`;
@@ -3,39 +3,43 @@
exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'linux' 1`] = `
"── Shortcuts (for more, see /help) ─────
! shell mode
@ select file or folder
Esc Esc clear & rewind
Tab Tab focus UI
Ctrl+Y YOLO mode
Shift+Tab cycle mode
Ctrl+V paste images
@ select file or folder
Ctrl+Y YOLO mode
Ctrl+R reverse-search history
Esc Esc clear prompt / rewind
Alt+M raw markdown mode
Ctrl+R reverse-search history
Ctrl+X open external editor"
`;
exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'mac' 1`] = `
"── Shortcuts (for more, see /help) ─────
! shell mode
@ select file or folder
Esc Esc clear & rewind
Tab Tab focus UI
Ctrl+Y YOLO mode
Shift+Tab cycle mode
Ctrl+V paste images
@ select file or folder
Ctrl+Y YOLO mode
Ctrl+R reverse-search history
Esc Esc clear prompt / rewind
Option+M raw markdown mode
Ctrl+R reverse-search history
Ctrl+X open external editor"
`;
exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'linux' 1`] = `
"── Shortcuts (for more, see /help) ─────────────────────────────────────────────────────────────────
! shell mode Shift+Tab cycle mode Ctrl+V paste images
@ select file or folder Ctrl+Y YOLO mode Ctrl+R reverse-search history
Esc Esc clear prompt / rewind Alt+M raw markdown mode Ctrl+X open external editor"
@ select file or folder Ctrl+Y YOLO mode Alt+M raw markdown mode
Esc Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
Tab Tab focus UI"
`;
exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'mac' 1`] = `
"── Shortcuts (for more, see /help) ─────────────────────────────────────────────────────────────────
! shell mode Shift+Tab cycle mode Ctrl+V paste images
@ select file or folder Ctrl+Y YOLO mode Ctrl+R reverse-search history
Esc Esc clear prompt / rewind Option+M raw markdown mode Ctrl+X open external editor"
@ select file or folder Ctrl+Y YOLO mode Option+M raw markdown mode
Esc Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
Tab Tab focus UI"
`;
@@ -90,18 +90,18 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
│ │
│ > Select Theme Preview │
│ ▲ ┌────────────────────────────────────────────────────────────┐ │
1. ANSI Dark │ │ │
│ 2. ANSI Light Light │ 1 # function │ │
│ 3. Atom One Dark │ 2 def fibonacci(n): │ │
│ 4. Ayu Dark │ 3 a, b = 0, 1 │ │
│ 5. Ayu Light Light │ 4 for _ in range(n): │ │
6. Default Dark │ 5 a, b = b, a + b │ │
│ 7. Default Light Light │ 6 return a │ │
│ 8. Dracula Dark │ │ │
│ 9. GitHub Dark │ 1 - print("Hello, " + name) │ │
│ 10. GitHub Light Light │ 1 + print(f"Hello, {name}!") │ │
│ 11. Google Code Light │ │ │
│ 12. Holiday Dark └────────────────────────────────────────────────────────────┘ │
1. ANSI Dark (Matches terminal) │ │ │
│ 2. Atom One Dark │ 1 # function │ │
│ 3. Ayu Dark │ 2 def fibonacci(n): │ │
│ 4. Default Dark │ 3 a, b = 0, 1 │ │
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
6. GitHub Dark │ 5 a, b = b, a + b │ │
│ 7. Holiday Dark │ 6 return a │ │
│ 8. Shades Of Purple Dark │ │ │
│ 9. ANSI Light Light (Incompatible) │ 1 - print("Hello, " + name) │ │
│ 10. Ayu Light Light (Incompatible) │ 1 + print(f"Hello, {name}!") │ │
│ 11. Default Light Light (Incompatible) │ │ │
│ 12. GitHub Light Light (Incompatible) └────────────────────────────────────────────────────────────┘ │
│ ▼ │
│ │
│ (Use Enter to select, Tab to configure scope, Esc to close) │
@@ -40,6 +40,33 @@ exports[`ToolConfirmationQueue > does not render expansion hint when constrainHe
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`ToolConfirmationQueue > renders AskUser tool confirmation with Success color 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Answer Questions │
│ │
│ Review your answers: │
│ │
│ │
│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`ToolConfirmationQueue > renders ExitPlanMode tool confirmation with Success color 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Ready to start implementation? │
│ │
│ Plan content goes here │
│ │
│ ● 1. Yes, automatically accept edits │
│ Approves plan and allows tools to run automatically │
│ 2. Yes, manually accept edits │
│ Approves plan but requires confirmation for each tool │
│ 3. Type your feedback... │
│ │
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`ToolConfirmationQueue > renders expansion hint when content is long and constrained 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Action Required │
@@ -46,4 +46,21 @@ describe('<GeminiMessage /> - Raw Markdown Display Snapshots', () => {
expect(lastFrame()).toMatchSnapshot();
},
);
it('wraps long lines correctly in raw markdown mode', () => {
const terminalWidth = 20;
const text =
'This is a long line that should wrap correctly without truncation';
const { lastFrame } = renderWithProviders(
<GeminiMessage
text={text}
isPending={false}
terminalWidth={terminalWidth}
/>,
{
uiState: { renderMarkdown: false, streamingState: StreamingState.Idle },
},
);
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -47,7 +47,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
? undefined
: Math.max(availableTerminalHeight - 1, 1)
}
terminalWidth={terminalWidth}
terminalWidth={Math.max(terminalWidth - prefixWidth, 0)}
renderMarkdown={renderMarkdown}
/>
<Box
@@ -45,7 +45,7 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
? undefined
: Math.max(availableTerminalHeight - 1, 1)
}
terminalWidth={terminalWidth}
terminalWidth={Math.max(terminalWidth - prefixWidth, 0)}
renderMarkdown={renderMarkdown}
/>
<Box
@@ -4,10 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { describe, it, expect, beforeAll } from 'vitest';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import type {
ToolCallConfirmationDetails,
SerializableConfirmationDetails,
Config,
} from '@google/gemini-cli-core';
import { initializeShellParsers } from '@google/gemini-cli-core';
@@ -24,13 +24,12 @@ describe('ToolConfirmationMessage Redirection', () => {
} as unknown as Config;
it('should display redirection warning and tip for redirected commands', () => {
const confirmationDetails: ToolCallConfirmationDetails = {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
title: 'Confirm Shell Command',
command: 'echo "hello" > test.txt',
rootCommand: 'echo, redirection (>)',
rootCommands: ['echo'],
onConfirm: vi.fn(),
};
const { lastFrame } = renderWithProviders(
@@ -7,7 +7,7 @@
import { describe, it, expect, vi } from 'vitest';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import type {
ToolCallConfirmationDetails,
SerializableConfirmationDetails,
Config,
} from '@google/gemini-cli-core';
import { renderWithProviders } from '../../../test-utils/render.js';
@@ -39,12 +39,11 @@ describe('ToolConfirmationMessage', () => {
} as unknown as Config;
it('should not display urls if prompt and url are the same', () => {
const confirmationDetails: ToolCallConfirmationDetails = {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'info',
title: 'Confirm Web Fetch',
prompt: 'https://example.com',
urls: ['https://example.com'],
onConfirm: vi.fn(),
};
const { lastFrame } = renderWithProviders(
@@ -61,7 +60,7 @@ describe('ToolConfirmationMessage', () => {
});
it('should display urls if prompt and url are different', () => {
const confirmationDetails: ToolCallConfirmationDetails = {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'info',
title: 'Confirm Web Fetch',
prompt:
@@ -69,7 +68,6 @@ describe('ToolConfirmationMessage', () => {
urls: [
'https://raw.githubusercontent.com/google/gemini-react/main/README.md',
],
onConfirm: vi.fn(),
};
const { lastFrame } = renderWithProviders(
@@ -86,14 +84,13 @@ describe('ToolConfirmationMessage', () => {
});
it('should display multiple commands for exec type when provided', () => {
const confirmationDetails: ToolCallConfirmationDetails = {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
title: 'Confirm Multiple Commands',
command: 'echo "hello"', // Primary command
rootCommand: 'echo',
rootCommands: ['echo'],
commands: ['echo "hello"', 'ls -la', 'whoami'], // Multi-command list
onConfirm: vi.fn(),
};
const { lastFrame } = renderWithProviders(
@@ -114,7 +111,7 @@ describe('ToolConfirmationMessage', () => {
});
describe('with folder trust', () => {
const editConfirmationDetails: ToolCallConfirmationDetails = {
const editConfirmationDetails: SerializableConfirmationDetails = {
type: 'edit',
title: 'Confirm Edit',
fileName: 'test.txt',
@@ -122,33 +119,29 @@ describe('ToolConfirmationMessage', () => {
fileDiff: '...diff...',
originalContent: 'a',
newContent: 'b',
onConfirm: vi.fn(),
};
const execConfirmationDetails: ToolCallConfirmationDetails = {
const execConfirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
title: 'Confirm Execution',
command: 'echo "hello"',
rootCommand: 'echo',
rootCommands: ['echo'],
onConfirm: vi.fn(),
};
const infoConfirmationDetails: ToolCallConfirmationDetails = {
const infoConfirmationDetails: SerializableConfirmationDetails = {
type: 'info',
title: 'Confirm Web Fetch',
prompt: 'https://example.com',
urls: ['https://example.com'],
onConfirm: vi.fn(),
};
const mcpConfirmationDetails: ToolCallConfirmationDetails = {
const mcpConfirmationDetails: SerializableConfirmationDetails = {
type: 'mcp',
title: 'Confirm MCP Tool',
serverName: 'test-server',
toolName: 'test-tool',
toolDisplayName: 'Test Tool',
onConfirm: vi.fn(),
};
describe.each([
@@ -214,7 +207,7 @@ describe('ToolConfirmationMessage', () => {
});
describe('enablePermanentToolApproval setting', () => {
const editConfirmationDetails: ToolCallConfirmationDetails = {
const editConfirmationDetails: SerializableConfirmationDetails = {
type: 'edit',
title: 'Confirm Edit',
fileName: 'test.txt',
@@ -222,7 +215,6 @@ describe('ToolConfirmationMessage', () => {
fileDiff: '...diff...',
originalContent: 'a',
newContent: 'b',
onConfirm: vi.fn(),
};
it('should NOT show "Allow for all future sessions" when setting is false (default)', () => {
@@ -275,7 +267,7 @@ describe('ToolConfirmationMessage', () => {
});
describe('Modify with external editor option', () => {
const editConfirmationDetails: ToolCallConfirmationDetails = {
const editConfirmationDetails: SerializableConfirmationDetails = {
type: 'edit',
title: 'Confirm Edit',
fileName: 'test.txt',
@@ -283,7 +275,6 @@ describe('ToolConfirmationMessage', () => {
fileDiff: '...diff...',
originalContent: 'a',
newContent: 'b',
onConfirm: vi.fn(),
};
it('should show "Modify with external editor" when NOT in IDE mode', () => {
@@ -11,7 +11,6 @@ import { DiffRenderer } from './DiffRenderer.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
import {
type SerializableConfirmationDetails,
type ToolCallConfirmationDetails,
type Config,
type ToolConfirmationPayload,
ToolConfirmationOutcome,
@@ -38,9 +37,7 @@ import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js';
export interface ToolConfirmationMessageProps {
callId: string;
confirmationDetails:
| ToolCallConfirmationDetails
| SerializableConfirmationDetails;
confirmationDetails: SerializableConfirmationDetails;
config: Config;
isFocused?: boolean;
availableTerminalHeight?: number;
@@ -1,128 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import type {
ToolCallConfirmationDetails,
Config,
} from '@google/gemini-cli-core';
import { renderWithProviders } from '../../../test-utils/render.js';
import { useToolActions } from '../../contexts/ToolActionsContext.js';
import {
StreamingState,
ToolCallStatus,
type IndividualToolCallDisplay,
} from '../../types.js';
import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { waitFor } from '../../../test-utils/async.js';
vi.mock('../../contexts/ToolActionsContext.js', async (importOriginal) => {
const actual =
await importOriginal<
typeof import('../../contexts/ToolActionsContext.js')
>();
return {
...actual,
useToolActions: vi.fn(),
};
});
describe('ToolConfirmationMessage Overflow', () => {
const mockConfirm = vi.fn();
vi.mocked(useToolActions).mockReturnValue({
confirm: mockConfirm,
cancel: vi.fn(),
isDiffingEnabled: false,
});
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getMessageBus: () => ({
subscribe: vi.fn(),
unsubscribe: vi.fn(),
publish: vi.fn(),
}),
isEventDrivenSchedulerEnabled: () => false,
getTheme: () => ({
status: { warning: 'yellow' },
text: { primary: 'white', secondary: 'gray', link: 'blue' },
border: { default: 'gray' },
ui: { symbol: 'cyan' },
}),
} as unknown as Config;
it('should display "press ctrl-o" hint when content overflows in ToolGroupMessage', async () => {
// Large diff that will definitely overflow
const diffLines = ['--- a/test.txt', '+++ b/test.txt', '@@ -1,20 +1,20 @@'];
for (let i = 0; i < 50; i++) {
diffLines.push(`+ line ${i + 1}`);
}
const fileDiff = diffLines.join('\n');
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'edit',
title: 'Confirm Edit',
fileName: 'test.txt',
filePath: '/test.txt',
fileDiff,
originalContent: '',
newContent: 'lots of lines',
onConfirm: vi.fn(),
};
const toolCalls: IndividualToolCallDisplay[] = [
{
callId: 'test-call-id',
name: 'test-tool',
description: 'a test tool',
status: ToolCallStatus.Confirming,
confirmationDetails,
resultDisplay: undefined,
},
];
const { lastFrame } = renderWithProviders(
<OverflowProvider>
<ToolGroupMessage
groupId={1}
toolCalls={toolCalls}
availableTerminalHeight={15} // Small height to force overflow
terminalWidth={80}
/>
</OverflowProvider>,
{
config: mockConfig,
uiState: {
streamingState: StreamingState.WaitingForConfirmation,
constrainHeight: true,
},
},
);
// ResizeObserver might take a tick
await waitFor(() =>
expect(lastFrame()).toContain('Press ctrl-o to show more lines'),
);
const frame = lastFrame();
expect(frame).toBeDefined();
if (frame) {
expect(frame).toContain('Press ctrl-o to show more lines');
// Ensure it's AFTER the bottom border
const linesOfOutput = frame.split('\n');
const bottomBorderIndex = linesOfOutput.findLastIndex((l) =>
l.includes('╰─'),
);
const hintIndex = linesOfOutput.findIndex((l) =>
l.includes('Press ctrl-o to show more lines'),
);
expect(hintIndex).toBeGreaterThan(bottomBorderIndex);
expect(frame).toMatchSnapshot();
}
});
});
@@ -5,7 +5,6 @@
*/
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import type { IndividualToolCallDisplay } from '../../types.js';
@@ -35,7 +34,6 @@ describe('<ToolGroupMessage />', () => {
const baseProps = {
groupId: 1,
terminalWidth: 80,
isFocused: true,
};
const baseMockConfig = makeFakeConfig({
@@ -45,7 +43,6 @@ describe('<ToolGroupMessage />', () => {
folderTrust: false,
ideMode: false,
enableInteractiveShell: true,
enableEventDrivenScheduler: true,
});
describe('Golden Snapshots', () => {
@@ -64,7 +61,30 @@ describe('<ToolGroupMessage />', () => {
unmount();
});
it('renders multiple tool calls with different statuses', () => {
it('hides confirming tools (standard behavior)', () => {
const toolCalls = [
createToolCall({
callId: 'confirm-tool',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'info',
title: 'Confirm tool',
prompt: 'Do you want to proceed?',
},
}),
];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{ config: baseMockConfig },
);
// Should render nothing because all tools in the group are confirming
expect(lastFrame()).toBe('');
unmount();
});
it('renders multiple tool calls with different statuses (only visible ones)', () => {
const toolCalls = [
createToolCall({
callId: 'tool-1',
@@ -85,68 +105,7 @@ describe('<ToolGroupMessage />', () => {
status: ToolCallStatus.Error,
}),
];
const mockConfig = makeFakeConfig({
model: 'gemini-pro',
targetDir: os.tmpdir(),
enableEventDrivenScheduler: false,
});
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
config: mockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders tool call awaiting confirmation', () => {
const toolCalls = [
createToolCall({
callId: 'tool-confirm',
name: 'confirmation-tool',
description: 'This tool needs confirmation',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'info',
title: 'Confirm Tool Execution',
prompt: 'Are you sure you want to proceed?',
onConfirm: vi.fn(),
},
}),
];
const mockConfig = makeFakeConfig({
model: 'gemini-pro',
targetDir: os.tmpdir(),
enableEventDrivenScheduler: false,
});
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
config: mockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders shell command with yellow border', () => {
const toolCalls = [
createToolCall({
callId: 'shell-1',
name: 'run_shell_command',
description: 'Execute shell command',
status: ToolCallStatus.Success,
}),
];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
@@ -156,7 +115,12 @@ describe('<ToolGroupMessage />', () => {
},
},
);
expect(lastFrame()).toMatchSnapshot();
// pending-tool should be hidden
const output = lastFrame();
expect(output).toContain('successful-tool');
expect(output).not.toContain('pending-tool');
expect(output).toContain('error-tool');
expect(output).toMatchSnapshot();
unmount();
});
@@ -181,22 +145,22 @@ describe('<ToolGroupMessage />', () => {
status: ToolCallStatus.Pending,
}),
];
const mockConfig = makeFakeConfig({
model: 'gemini-pro',
targetDir: os.tmpdir(),
enableEventDrivenScheduler: false,
});
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
config: mockConfig,
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
// write_file (Pending) should be hidden
const output = lastFrame();
expect(output).toContain('read_file');
expect(output).toContain('run_shell_command');
expect(output).not.toContain('write_file');
expect(output).toMatchSnapshot();
unmount();
});
@@ -233,25 +197,6 @@ describe('<ToolGroupMessage />', () => {
unmount();
});
it('renders when not focused', () => {
const toolCalls = [createToolCall()];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
isFocused={false}
/>,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders with narrow terminal width', () => {
const toolCalls = [
createToolCall({
@@ -384,28 +329,6 @@ describe('<ToolGroupMessage />', () => {
});
describe('Border Color Logic', () => {
it('uses yellow border when tools are pending', () => {
const toolCalls = [createToolCall({ status: ToolCallStatus.Pending })];
const mockConfig = makeFakeConfig({
model: 'gemini-pro',
targetDir: os.tmpdir(),
enableEventDrivenScheduler: false,
});
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
config: mockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
// The snapshot will capture the visual appearance including border color
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('uses yellow border for shell commands even when successful', () => {
const toolCalls = [
createToolCall({
@@ -483,241 +406,43 @@ describe('<ToolGroupMessage />', () => {
});
});
describe('Confirmation Handling', () => {
it('shows confirmation dialog for first confirming tool only', () => {
const toolCalls = [
createToolCall({
callId: 'tool-1',
name: 'first-confirm',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'info',
title: 'Confirm First Tool',
prompt: 'Confirm first tool',
onConfirm: vi.fn(),
},
}),
createToolCall({
callId: 'tool-2',
name: 'second-confirm',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'info',
title: 'Confirm Second Tool',
prompt: 'Confirm second tool',
onConfirm: vi.fn(),
},
}),
];
const mockConfig = makeFakeConfig({
model: 'gemini-pro',
targetDir: os.tmpdir(),
enableEventDrivenScheduler: false,
});
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
config: mockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
// Should only show confirmation for the first tool
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders confirmation with permanent approval enabled', () => {
const toolCalls = [
createToolCall({
callId: 'tool-1',
name: 'confirm-tool',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'info',
title: 'Confirm Tool',
prompt: 'Do you want to proceed?',
onConfirm: vi.fn(),
},
}),
];
const settings = createMockSettings({
security: { enablePermanentToolApproval: true },
});
const mockConfig = makeFakeConfig({
model: 'gemini-pro',
targetDir: os.tmpdir(),
enableEventDrivenScheduler: false,
});
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
settings,
config: mockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toContain('Allow for all future sessions');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders confirmation with permanent approval disabled', () => {
const toolCalls = [
createToolCall({
callId: 'confirm-tool',
name: 'confirm-tool',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'info',
title: 'Confirm tool',
prompt: 'Do you want to proceed?',
onConfirm: vi.fn(),
},
}),
];
const mockConfig = makeFakeConfig({
model: 'gemini-pro',
targetDir: os.tmpdir(),
enableEventDrivenScheduler: false,
});
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{ config: mockConfig },
);
expect(lastFrame()).not.toContain('Allow for all future sessions');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
describe('Event-Driven Scheduler', () => {
it('hides confirming tools when event-driven scheduler is enabled', () => {
const toolCalls = [
createToolCall({
callId: 'confirm-tool',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'info',
title: 'Confirm tool',
prompt: 'Do you want to proceed?',
onConfirm: vi.fn(),
},
}),
];
const mockConfig = baseMockConfig;
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{ config: mockConfig },
);
// Should render nothing because all tools in the group are confirming
expect(lastFrame()).toBe('');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('shows only successful tools when mixed with confirming tools', () => {
const toolCalls = [
createToolCall({
callId: 'success-tool',
name: 'success-tool',
status: ToolCallStatus.Success,
}),
createToolCall({
callId: 'confirm-tool',
name: 'confirm-tool',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'info',
title: 'Confirm tool',
prompt: 'Do you want to proceed?',
onConfirm: vi.fn(),
},
}),
];
const mockConfig = baseMockConfig;
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{ config: mockConfig },
);
const output = lastFrame();
expect(output).toContain('success-tool');
expect(output).not.toContain('confirm-tool');
expect(output).not.toContain('Do you want to proceed?');
expect(output).toMatchSnapshot();
unmount();
});
it('renders nothing when only tool is in-progress AskUser with borderBottom=false', () => {
// AskUser tools in progress are rendered by AskUserDialog, not ToolGroupMessage.
// When AskUser is the only tool and borderBottom=false (no border to close),
// the component should render nothing.
const toolCalls = [
createToolCall({
callId: 'ask-user-tool',
name: 'Ask User',
status: ToolCallStatus.Executing,
}),
];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
borderBottom={false}
/>,
{ config: baseMockConfig },
);
// AskUser tools in progress are rendered by AskUserDialog, so we expect nothing.
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
describe('Ask User Filtering', () => {
it.each([
ToolCallStatus.Pending,
ToolCallStatus.Executing,
ToolCallStatus.Confirming,
])('filters out ask_user when status is %s', (status) => {
const toolCalls = [
createToolCall({
callId: `ask-user-${status}`,
name: ASK_USER_DISPLAY_NAME,
status,
}),
];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{ config: baseMockConfig },
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it.each([ToolCallStatus.Success, ToolCallStatus.Error])(
'does NOT filter out ask_user when status is %s',
(status) => {
{
status: ToolCallStatus.Pending,
resultDisplay: 'test result',
shouldHide: true,
},
{
status: ToolCallStatus.Executing,
resultDisplay: 'test result',
shouldHide: true,
},
{
status: ToolCallStatus.Confirming,
resultDisplay: 'test result',
shouldHide: true,
},
{
status: ToolCallStatus.Success,
resultDisplay: 'test result',
shouldHide: false,
},
{ status: ToolCallStatus.Error, resultDisplay: '', shouldHide: true },
{
status: ToolCallStatus.Error,
resultDisplay: 'error message',
shouldHide: false,
},
])(
'filtering logic for status=$status and hasResult=$resultDisplay',
({ status, resultDisplay, shouldHide }) => {
const toolCalls = [
createToolCall({
callId: `ask-user-${status}`,
name: ASK_USER_DISPLAY_NAME,
status,
resultDisplay,
}),
];
@@ -726,7 +451,11 @@ describe('<ToolGroupMessage />', () => {
{ config: baseMockConfig },
);
expect(lastFrame()).toMatchSnapshot();
if (shouldHide) {
expect(lastFrame()).toBe('');
} else {
expect(lastFrame()).toMatchSnapshot();
}
unmount();
},
);
@@ -753,5 +482,30 @@ describe('<ToolGroupMessage />', () => {
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders nothing when only tool is in-progress AskUser with borderBottom=false', () => {
// AskUser tools in progress are rendered by AskUserDialog, not ToolGroupMessage.
// When AskUser is the only tool and borderBottom=false (no border to close),
// the component should render nothing.
const toolCalls = [
createToolCall({
callId: 'ask-user-tool',
name: ASK_USER_DISPLAY_NAME,
status: ToolCallStatus.Executing,
}),
];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
borderBottom={false}
/>,
{ config: baseMockConfig },
);
// AskUser tools in progress are rendered by AskUserDialog, so we expect nothing.
expect(lastFrame()).toBe('');
unmount();
});
});
});
@@ -11,11 +11,10 @@ import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
import { ToolMessage } from './ToolMessage.js';
import { ShellToolMessage } from './ShellToolMessage.js';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { isShellTool, isThisShellFocused } from './ToolShared.js';
import { ASK_USER_DISPLAY_NAME } from '@google/gemini-cli-core';
import { shouldHideAskUserTool } from '@google/gemini-cli-core';
import { ShowMoreLines } from '../ShowMoreLines.js';
import { useUIState } from '../../contexts/UIStateContext.js';
@@ -24,7 +23,6 @@ interface ToolGroupMessageProps {
toolCalls: IndividualToolCallDisplay[];
availableTerminalHeight?: number;
terminalWidth: number;
isFocused?: boolean;
activeShellPtyId?: number | null;
embeddedShellFocused?: boolean;
onShellInputSubmit?: (input: string) => void;
@@ -32,56 +30,44 @@ interface ToolGroupMessageProps {
borderBottom?: boolean;
}
// Helper to identify Ask User tools that are in progress (have their own dialog UI)
const isAskUserInProgress = (t: IndividualToolCallDisplay): boolean =>
t.name === ASK_USER_DISPLAY_NAME &&
[
ToolCallStatus.Pending,
ToolCallStatus.Executing,
ToolCallStatus.Confirming,
].includes(t.status);
// Main component renders the border and maps the tools using ToolMessage
const TOOL_MESSAGE_HORIZONTAL_MARGIN = 4;
const TOOL_CONFIRMATION_INTERNAL_PADDING = 4;
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
toolCalls: allToolCalls,
availableTerminalHeight,
terminalWidth,
isFocused = true,
activeShellPtyId,
embeddedShellFocused,
borderTop: borderTopOverride,
borderBottom: borderBottomOverride,
}) => {
// Filter out in-progress Ask User tools (they have their own AskUserDialog UI)
// Filter out Ask User tools that should be hidden (e.g. in-progress or errors without result)
const toolCalls = useMemo(
() => allToolCalls.filter((t) => !isAskUserInProgress(t)),
() =>
allToolCalls.filter(
(t) => !shouldHideAskUserTool(t.name, t.status, !!t.resultDisplay),
),
[allToolCalls],
);
const config = useConfig();
const { constrainHeight } = useUIState();
const isEventDriven = config.isEventDrivenSchedulerEnabled();
// If Event-Driven Scheduler is enabled, we HIDE tools that are still in
// pre-execution states (Confirming, Pending) from the History log.
// They live in the Global Queue or wait for their turn.
const visibleToolCalls = useMemo(() => {
if (!isEventDriven) {
return toolCalls;
}
// Only show tools that are actually running or finished.
// We explicitly exclude Pending and Confirming to ensure they only
// appear in the Global Queue until they are approved and start executing.
return toolCalls.filter(
(t) =>
t.status !== ToolCallStatus.Pending &&
t.status !== ToolCallStatus.Confirming,
);
}, [toolCalls, isEventDriven]);
// We HIDE tools that are still in pre-execution states (Confirming, Pending)
// from the History log. They live in the Global Queue or wait for their turn.
// Only show tools that are actually running or finished.
// We explicitly exclude Pending and Confirming to ensure they only
// appear in the Global Queue until they are approved and start executing.
const visibleToolCalls = useMemo(
() =>
toolCalls.filter(
(t) =>
t.status !== ToolCallStatus.Pending &&
t.status !== ToolCallStatus.Confirming,
),
[toolCalls],
);
const isEmbeddedShellFocused = visibleToolCalls.some((t) =>
isThisShellFocused(
@@ -110,17 +96,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const staticHeight = /* border */ 2 + /* marginBottom */ 1;
// Inline confirmations are ONLY used when the Global Queue is disabled.
const toolAwaitingApproval = useMemo(
() =>
isEventDriven
? undefined
: toolCalls.find((tc) => tc.status === ToolCallStatus.Confirming),
[toolCalls, isEventDriven],
);
// If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools
// in event-driven mode), only render if we need to close a border from previous
// If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools),
// only render if we need to close a border from previous
// tool groups. borderBottomOverride=true means we must render the closing border;
// undefined or false means there's nothing to display.
if (visibleToolCalls.length === 0 && borderBottomOverride !== true) {
@@ -163,7 +140,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
>
{visibleToolCalls.map((tool, index) => {
const isConfirming = toolAwaitingApproval?.callId === tool.callId;
const isFirst = index === 0;
const isShellToolCall = isShellTool(tool.name);
@@ -171,11 +147,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
...tool,
availableTerminalHeight: availableTerminalHeightPerToolMessage,
terminalWidth: contentWidth,
emphasis: isConfirming
? ('high' as const)
: toolAwaitingApproval
? ('low' as const)
: ('medium' as const),
emphasis: 'medium' as const,
isFirst:
borderTopOverride !== undefined
? borderTopOverride && isFirst
@@ -213,22 +185,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
paddingLeft={1}
paddingRight={1}
>
{tool.status === ToolCallStatus.Confirming &&
isConfirming &&
tool.confirmationDetails && (
<ToolConfirmationMessage
callId={tool.callId}
confirmationDetails={tool.confirmationDetails}
config={config}
isFocused={isFocused}
availableTerminalHeight={
availableTerminalHeightPerToolMessage
}
terminalWidth={
contentWidth - TOOL_CONFIRMATION_INTERNAL_PADDING
}
/>
)}
{tool.outputFile && (
<Box>
<Text color={theme.text.primary}>
@@ -18,7 +18,7 @@ import { theme } from '../../semantic-colors.js';
import {
type Config,
SHELL_TOOL_NAME,
ASK_USER_DISPLAY_NAME,
isCompletedAskUserTool,
type ToolResultDisplay,
} from '@google/gemini-cli-core';
import { useInactivityTimer } from '../../hooks/useInactivityTimer.js';
@@ -205,13 +205,7 @@ export const ToolInfo: React.FC<ToolInfoProps> = ({
}, [emphasis]);
// Hide description for completed Ask User tools (the result display speaks for itself)
const isCompletedAskUser =
name === ASK_USER_DISPLAY_NAME &&
[
ToolCallStatus.Success,
ToolCallStatus.Error,
ToolCallStatus.Canceled,
].includes(status);
const isCompletedAskUser = isCompletedAskUserTool(name, status);
return (
<Box overflow="hidden" height={1} flexGrow={1} flexShrink={1}>
@@ -31,3 +31,12 @@ exports[`<GeminiMessage /> - Raw Markdown Display Snapshots > renders with rende
1 const x = 1;
"
`;
exports[`<GeminiMessage /> - Raw Markdown Display Snapshots > wraps long lines correctly in raw markdown mode 1`] = `
"✦ This is a long
line that should
wrap correctly
without
truncation
"
`;
@@ -1,27 +1,21 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ToolGroupMessage /> > Ask User Filtering > does NOT filter out ask_user when status is Error 1`] = `
exports[`<ToolGroupMessage /> > Ask User Filtering > filtering logic for status='Error' and hasResult='error message' 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ x Ask User │
│ │
Test result
error message
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Ask User Filtering > does NOT filter out ask_user when status is Success 1`] = `
exports[`<ToolGroupMessage /> > Ask User Filtering > filtering logic for status='Success' and hasResult='test result' 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ Ask User │
│ │
Test result │
test result │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Ask User Filtering > filters out ask_user when status is Confirming 1`] = `""`;
exports[`<ToolGroupMessage /> > Ask User Filtering > filters out ask_user when status is Executing 1`] = `""`;
exports[`<ToolGroupMessage /> > Ask User Filtering > filters out ask_user when status is Pending 1`] = `""`;
exports[`<ToolGroupMessage /> > Ask User Filtering > shows other tools when ask_user is filtered out 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ other-tool A tool for testing │
@@ -50,76 +44,6 @@ exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border for shel
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border when tools are pending 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ o test-tool A tool for testing │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Confirmation Handling > renders confirmation with permanent approval disabled 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ? confirm-tool A tool for testing ← │
│ │
│ Test result │
│ Do you want to proceed? │
│ Do you want to proceed? │
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
│ 3. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Confirmation Handling > renders confirmation with permanent approval enabled 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ? confirm-tool A tool for testing ← │
│ │
│ Test result │
│ Do you want to proceed? │
│ Do you want to proceed? │
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
│ 3. Allow for all future sessions │
│ 4. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Confirmation Handling > shows confirmation dialog for first confirming tool only 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ? first-confirm A tool for testing ← │
│ │
│ Test result │
│ Confirm first tool │
│ Do you want to proceed? │
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
│ 3. No, suggest changes (esc) │
│ │
│ │
│ ? second-confirm A tool for testing │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Event-Driven Scheduler > hides confirming tools when event-driven scheduler is enabled 1`] = `""`;
exports[`<ToolGroupMessage /> > Event-Driven Scheduler > renders nothing when only tool is in-progress AskUser with borderBottom=false 1`] = `""`;
exports[`<ToolGroupMessage /> > Event-Driven Scheduler > shows only successful tools when mixed with confirming tools 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ success-tool A tool for testing │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls array 1`] = `""`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled 1`] = `
@@ -144,37 +68,21 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls incl
│ ⊷ run_shell_command Run command │
│ │
│ Test result │
│ │
│ o write_file Write to file │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders multiple tool calls with different statuses 1`] = `
exports[`<ToolGroupMessage /> > Golden Snapshots > renders multiple tool calls with different statuses (only visible ones) 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ successful-tool This tool succeeded │
│ │
│ Test result │
│ │
│ o pending-tool This tool is pending │
│ │
│ Test result │
│ │
│ x error-tool This tool failed │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders shell command with yellow border 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ run_shell_command Execute shell command │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders single successful tool call 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
@@ -183,21 +91,6 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders single successful too
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call awaiting confirmation 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ? confirmation-tool This tool needs confirmation ← │
│ │
│ Test result │
│ Are you sure you want to proceed? │
│ Do you want to proceed? │
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
│ 3. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call with outputFile 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-with-file Tool that saved output to file │
@@ -216,14 +109,6 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where
╰──────────────────────────────────────────────────────────────────────────╯ █"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders when not focused 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal height 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-with-result Tool with output │
@@ -8,6 +8,7 @@ import type React from 'react';
import { useMemo } from 'react';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { useUIState } from '../../contexts/UIStateContext.js';
import { theme } from '../../semantic-colors.js';
import {
interpolateColor,
resolveColor,
@@ -52,8 +53,8 @@ const HalfLinePaddedBoxInternal: React.FC<HalfLinePaddedBoxProps> = ({
backgroundOpacity,
children,
}) => {
const { terminalWidth, terminalBackgroundColor } = useUIState();
const terminalBg = terminalBackgroundColor || 'black';
const { terminalWidth } = useUIState();
const terminalBg = theme.background.primary || 'black';
const isLowColor = isLowColorDepth();
@@ -41,6 +41,7 @@ import {
getTransformedImagePath,
} from './text-buffer.js';
import { cpLen } from '../../utils/textUtils.js';
import { escapePath } from '@google/gemini-cli-core';
const defaultVisualLayout: VisualLayout = {
visualLines: [''],
@@ -1077,14 +1078,16 @@ describe('useTextBuffer', () => {
useTextBuffer({ viewport, escapePastedPaths: true }),
);
// Construct escaped path string: "/path/to/my\ file.txt /path/to/other.txt"
const escapedFile1 = file1.replace(/ /g, '\\ ');
const filePaths = `${escapedFile1} ${file2}`;
const filePaths = `${escapePath(file1)} ${file2}`;
act(() => result.current.insert(filePaths, { paste: true }));
expect(getBufferState(result).text).toBe(`@${escapedFile1} @${file2} `);
expect(getBufferState(result).text).toBe(
`@${escapePath(file1)} @${file2} `,
);
});
it('should only prepend @ to valid paths in multi-path paste', () => {
it('should not prepend @ unless all paths are valid', () => {
const validFile = path.join(tempDir, 'valid.txt');
const invalidFile = path.join(tempDir, 'invalid.jpg');
fs.writeFileSync(validFile, '');
@@ -1098,7 +1101,7 @@ describe('useTextBuffer', () => {
);
const filePaths = `${validFile} ${invalidFile}`;
act(() => result.current.insert(filePaths, { paste: true }));
expect(getBufferState(result).text).toBe(`@${validFile} ${invalidFile} `);
expect(getBufferState(result).text).toBe(`${validFile} ${invalidFile}`);
});
});
@@ -2869,12 +2872,26 @@ describe('Unicode helper functions', () => {
});
});
const mockPlatform = (platform: string) => {
vi.stubGlobal(
'process',
Object.create(process, {
platform: {
get: () => platform,
},
}),
);
};
describe('Transformation Utilities', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('getTransformedImagePath', () => {
beforeEach(() => mockPlatform('linux'));
it('should transform a simple image path', () => {
expect(getTransformedImagePath('@test.png')).toBe('[Image test.png]');
});
@@ -2905,11 +2922,6 @@ describe('Transformation Utilities', () => {
expect(getTransformedImagePath(input)).toBe('[Image image2x.png]');
});
it('should handle Windows-style backslash paths on any platform', () => {
const input = '@C:\\Users\\foo\\screenshots\\image2x.png';
expect(getTransformedImagePath(input)).toBe('[Image image2x.png]');
});
it('should handle escaped spaces in paths', () => {
const input = '@path/to/my\\ file.png';
expect(getTransformedImagePath(input)).toBe('[Image my file.png]');
@@ -1657,8 +1657,9 @@ export type TextBufferAction =
| { type: 'vim_change_big_word_end'; payload: { count: number } }
| { type: 'vim_delete_line'; payload: { count: number } }
| { type: 'vim_change_line'; payload: { count: number } }
| { type: 'vim_delete_to_end_of_line' }
| { type: 'vim_change_to_end_of_line' }
| { type: 'vim_delete_to_end_of_line'; payload: { count: number } }
| { type: 'vim_delete_to_start_of_line' }
| { type: 'vim_change_to_end_of_line'; payload: { count: number } }
| {
type: 'vim_change_movement';
payload: { movement: 'h' | 'j' | 'k' | 'l'; count: number };
@@ -1688,6 +1689,11 @@ export type TextBufferAction =
| { type: 'vim_move_to_last_line' }
| { type: 'vim_move_to_line'; payload: { lineNumber: number } }
| { type: 'vim_escape_insert_mode' }
| { type: 'vim_delete_to_first_nonwhitespace' }
| { type: 'vim_change_to_start_of_line' }
| { type: 'vim_change_to_first_nonwhitespace' }
| { type: 'vim_delete_to_first_line'; payload: { count: number } }
| { type: 'vim_delete_to_last_line'; payload: { count: number } }
| {
type: 'toggle_paste_expansion';
payload: { id: string; row: number; col: number };
@@ -2437,6 +2443,7 @@ function textBufferReducerLogic(
case 'vim_delete_line':
case 'vim_change_line':
case 'vim_delete_to_end_of_line':
case 'vim_delete_to_start_of_line':
case 'vim_change_to_end_of_line':
case 'vim_change_movement':
case 'vim_move_left':
@@ -2463,6 +2470,11 @@ function textBufferReducerLogic(
case 'vim_move_to_last_line':
case 'vim_move_to_line':
case 'vim_escape_insert_mode':
case 'vim_delete_to_first_nonwhitespace':
case 'vim_change_to_start_of_line':
case 'vim_change_to_first_nonwhitespace':
case 'vim_delete_to_first_line':
case 'vim_delete_to_last_line':
return handleVimAction(state, action as VimAction);
case 'toggle_paste_expansion': {
@@ -2802,15 +2814,7 @@ export function useTextBuffer({
paste &&
escapePastedPaths
) {
let potentialPath = ch.trim();
const quoteMatch = potentialPath.match(/^'(.*)'$/);
if (quoteMatch) {
potentialPath = quoteMatch[1];
}
potentialPath = potentialPath.trim();
const processed = parsePastedPaths(potentialPath);
const processed = parsePastedPaths(ch.trim());
if (processed) {
textToInsert = processed;
}
@@ -2945,12 +2949,36 @@ export function useTextBuffer({
dispatch({ type: 'vim_change_line', payload: { count } });
}, []);
const vimDeleteToEndOfLine = useCallback((): void => {
dispatch({ type: 'vim_delete_to_end_of_line' });
const vimDeleteToEndOfLine = useCallback((count: number = 1): void => {
dispatch({ type: 'vim_delete_to_end_of_line', payload: { count } });
}, []);
const vimChangeToEndOfLine = useCallback((): void => {
dispatch({ type: 'vim_change_to_end_of_line' });
const vimDeleteToStartOfLine = useCallback((): void => {
dispatch({ type: 'vim_delete_to_start_of_line' });
}, []);
const vimChangeToEndOfLine = useCallback((count: number = 1): void => {
dispatch({ type: 'vim_change_to_end_of_line', payload: { count } });
}, []);
const vimDeleteToFirstNonWhitespace = useCallback((): void => {
dispatch({ type: 'vim_delete_to_first_nonwhitespace' });
}, []);
const vimChangeToStartOfLine = useCallback((): void => {
dispatch({ type: 'vim_change_to_start_of_line' });
}, []);
const vimChangeToFirstNonWhitespace = useCallback((): void => {
dispatch({ type: 'vim_change_to_first_nonwhitespace' });
}, []);
const vimDeleteToFirstLine = useCallback((count: number): void => {
dispatch({ type: 'vim_delete_to_first_line', payload: { count } });
}, []);
const vimDeleteToLastLine = useCallback((count: number): void => {
dispatch({ type: 'vim_delete_to_last_line', payload: { count } });
}, []);
const vimChangeMovement = useCallback(
@@ -3510,7 +3538,13 @@ export function useTextBuffer({
vimDeleteLine,
vimChangeLine,
vimDeleteToEndOfLine,
vimDeleteToStartOfLine,
vimChangeToEndOfLine,
vimDeleteToFirstNonWhitespace,
vimChangeToStartOfLine,
vimChangeToFirstNonWhitespace,
vimDeleteToFirstLine,
vimDeleteToLastLine,
vimChangeMovement,
vimMoveLeft,
vimMoveRight,
@@ -3592,7 +3626,13 @@ export function useTextBuffer({
vimDeleteLine,
vimChangeLine,
vimDeleteToEndOfLine,
vimDeleteToStartOfLine,
vimChangeToEndOfLine,
vimDeleteToFirstNonWhitespace,
vimChangeToStartOfLine,
vimChangeToFirstNonWhitespace,
vimDeleteToFirstLine,
vimDeleteToLastLine,
vimChangeMovement,
vimMoveLeft,
vimMoveRight,
@@ -3832,12 +3872,38 @@ export interface TextBuffer {
vimChangeLine: (count: number) => void;
/**
* Delete from cursor to end of line (vim 'D' command)
* With count > 1, deletes to end of current line plus (count-1) additional lines
*/
vimDeleteToEndOfLine: () => void;
vimDeleteToEndOfLine: (count?: number) => void;
/**
* Delete from start of line to cursor (vim 'd0' command)
*/
vimDeleteToStartOfLine: () => void;
/**
* Change from cursor to end of line (vim 'C' command)
* With count > 1, changes to end of current line plus (count-1) additional lines
*/
vimChangeToEndOfLine: () => void;
vimChangeToEndOfLine: (count?: number) => void;
/**
* Delete from cursor to first non-whitespace character (vim 'd^' command)
*/
vimDeleteToFirstNonWhitespace: () => void;
/**
* Change from cursor to start of line (vim 'c0' command)
*/
vimChangeToStartOfLine: () => void;
/**
* Change from cursor to first non-whitespace character (vim 'c^' command)
*/
vimChangeToFirstNonWhitespace: () => void;
/**
* Delete from current line to first line (vim 'dgg' command)
*/
vimDeleteToFirstLine: (count: number) => void;
/**
* Delete from current line to last line (vim 'dG' command)
*/
vimDeleteToLastLine: (count: number) => void;
/**
* Change movement operations (vim 'ch', 'cj', 'ck', 'cl' commands)
*/
@@ -469,6 +469,24 @@ describe('vim-buffer-actions', () => {
expect(result.cursorCol).toBe(3); // Position of 'h'
});
it('vim_move_to_first_nonwhitespace should go to column 0 on whitespace-only line', () => {
const state = createTestState([' '], 0, 3);
const action = { type: 'vim_move_to_first_nonwhitespace' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(0);
});
it('vim_move_to_first_nonwhitespace should go to column 0 on empty line', () => {
const state = createTestState([''], 0, 0);
const action = { type: 'vim_move_to_first_nonwhitespace' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(0);
});
it('vim_move_to_first_line should move to row 0', () => {
const state = createTestState(['line1', 'line2', 'line3'], 2, 5);
const action = { type: 'vim_move_to_first_line' as const };
@@ -725,7 +743,10 @@ describe('vim-buffer-actions', () => {
describe('vim_delete_to_end_of_line', () => {
it('should delete from cursor to end of line', () => {
const state = createTestState(['hello world'], 0, 5);
const action = { type: 'vim_delete_to_end_of_line' as const };
const action = {
type: 'vim_delete_to_end_of_line' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
@@ -735,12 +756,401 @@ describe('vim-buffer-actions', () => {
it('should do nothing at end of line', () => {
const state = createTestState(['hello'], 0, 5);
const action = { type: 'vim_delete_to_end_of_line' as const };
const action = {
type: 'vim_delete_to_end_of_line' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello');
});
it('should delete to end of line plus additional lines with count > 1', () => {
const state = createTestState(
['line one', 'line two', 'line three'],
0,
5,
);
const action = {
type: 'vim_delete_to_end_of_line' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// 2D at position 5 on "line one" should delete "one" + entire "line two"
expect(result.lines).toEqual(['line ', 'line three']);
expect(result.cursorCol).toBe(5);
});
it('should handle count exceeding available lines', () => {
const state = createTestState(['line one', 'line two'], 0, 5);
const action = {
type: 'vim_delete_to_end_of_line' as const,
payload: { count: 5 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// Should delete to end of available lines
expect(result.lines).toEqual(['line ']);
});
});
describe('vim_delete_to_first_nonwhitespace', () => {
it('should delete from cursor backwards to first non-whitespace', () => {
const state = createTestState([' hello world'], 0, 10);
const action = { type: 'vim_delete_to_first_nonwhitespace' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// Delete from 'h' (col 4) to cursor (col 10), leaving " world"
expect(result.lines[0]).toBe(' world');
expect(result.cursorCol).toBe(4);
});
it('should delete from cursor forwards when cursor is in whitespace', () => {
const state = createTestState([' hello'], 0, 2);
const action = { type: 'vim_delete_to_first_nonwhitespace' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// Delete from cursor (col 2) to first non-ws (col 4), leaving " hello"
expect(result.lines[0]).toBe(' hello');
expect(result.cursorCol).toBe(2);
});
it('should do nothing when cursor is at first non-whitespace', () => {
const state = createTestState([' hello'], 0, 4);
const action = { type: 'vim_delete_to_first_nonwhitespace' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe(' hello');
});
it('should delete to column 0 on whitespace-only line', () => {
const state = createTestState([' '], 0, 2);
const action = { type: 'vim_delete_to_first_nonwhitespace' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// On whitespace-only line, ^ goes to col 0, so d^ deletes cols 0-2
expect(result.lines[0]).toBe(' ');
expect(result.cursorCol).toBe(0);
});
});
describe('vim_delete_to_first_line', () => {
it('should delete from current line to first line (dgg)', () => {
const state = createTestState(
['line1', 'line2', 'line3', 'line4'],
2,
0,
);
const action = {
type: 'vim_delete_to_first_line' as const,
payload: { count: 0 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// Delete lines 0, 1, 2 (current), leaving line4
expect(result.lines).toEqual(['line4']);
expect(result.cursorRow).toBe(0);
});
it('should delete from current line to specified line (d5gg)', () => {
const state = createTestState(
['line1', 'line2', 'line3', 'line4', 'line5'],
4,
0,
);
const action = {
type: 'vim_delete_to_first_line' as const,
payload: { count: 2 }, // Delete to line 2 (1-based)
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// Delete lines 1-4 (line2 to line5), leaving line1
expect(result.lines).toEqual(['line1']);
expect(result.cursorRow).toBe(0);
});
it('should keep one empty line when deleting all lines', () => {
const state = createTestState(['line1', 'line2'], 1, 0);
const action = {
type: 'vim_delete_to_first_line' as const,
payload: { count: 0 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['']);
});
});
describe('vim_delete_to_last_line', () => {
it('should delete from current line to last line (dG)', () => {
const state = createTestState(
['line1', 'line2', 'line3', 'line4'],
1,
0,
);
const action = {
type: 'vim_delete_to_last_line' as const,
payload: { count: 0 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// Delete lines 1, 2, 3 (from current to last), leaving line1
expect(result.lines).toEqual(['line1']);
expect(result.cursorRow).toBe(0);
});
it('should delete from current line to specified line (d3G)', () => {
const state = createTestState(
['line1', 'line2', 'line3', 'line4', 'line5'],
0,
0,
);
const action = {
type: 'vim_delete_to_last_line' as const,
payload: { count: 3 }, // Delete to line 3 (1-based)
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// Delete lines 0-2 (line1 to line3), leaving line4 and line5
expect(result.lines).toEqual(['line4', 'line5']);
expect(result.cursorRow).toBe(0);
});
it('should keep one empty line when deleting all lines', () => {
const state = createTestState(['line1', 'line2'], 0, 0);
const action = {
type: 'vim_delete_to_last_line' as const,
payload: { count: 0 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['']);
});
});
describe('vim_change_to_start_of_line', () => {
it('should delete from start of line to cursor (c0)', () => {
const state = createTestState(['hello world'], 0, 6);
const action = { type: 'vim_change_to_start_of_line' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('world');
expect(result.cursorCol).toBe(0);
});
it('should do nothing at start of line', () => {
const state = createTestState(['hello'], 0, 0);
const action = { type: 'vim_change_to_start_of_line' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello');
});
});
describe('vim_change_to_first_nonwhitespace', () => {
it('should delete from first non-whitespace to cursor (c^)', () => {
const state = createTestState([' hello world'], 0, 10);
const action = { type: 'vim_change_to_first_nonwhitespace' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe(' world');
expect(result.cursorCol).toBe(4);
});
it('should delete backwards when cursor before first non-whitespace', () => {
const state = createTestState([' hello'], 0, 2);
const action = { type: 'vim_change_to_first_nonwhitespace' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe(' hello');
expect(result.cursorCol).toBe(2);
});
it('should handle whitespace-only line', () => {
const state = createTestState([' '], 0, 3);
const action = { type: 'vim_change_to_first_nonwhitespace' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe(' ');
expect(result.cursorCol).toBe(0);
});
});
describe('vim_change_to_end_of_line', () => {
it('should delete from cursor to end of line (C)', () => {
const state = createTestState(['hello world'], 0, 6);
const action = {
type: 'vim_change_to_end_of_line' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello ');
expect(result.cursorCol).toBe(6);
});
it('should delete multiple lines with count (2C)', () => {
const state = createTestState(['line1 hello', 'line2', 'line3'], 0, 6);
const action = {
type: 'vim_change_to_end_of_line' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line1 ', 'line3']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(6);
});
it('should delete remaining lines when count exceeds available (3C on 2 lines)', () => {
const state = createTestState(['hello world', 'end'], 0, 6);
const action = {
type: 'vim_change_to_end_of_line' as const,
payload: { count: 3 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['hello ']);
expect(result.cursorCol).toBe(6);
});
it('should handle count at last line', () => {
const state = createTestState(['first', 'last line'], 1, 5);
const action = {
type: 'vim_change_to_end_of_line' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['first', 'last ']);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(5);
});
});
describe('vim_change_to_first_line', () => {
it('should delete from first line to current line (cgg)', () => {
const state = createTestState(['line1', 'line2', 'line3'], 2, 3);
const action = {
type: 'vim_delete_to_first_line' as const,
payload: { count: 0 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['']);
expect(result.cursorRow).toBe(0);
});
it('should delete from line 1 to target line (c3gg)', () => {
const state = createTestState(
['line1', 'line2', 'line3', 'line4', 'line5'],
0,
0,
);
const action = {
type: 'vim_delete_to_first_line' as const,
payload: { count: 3 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line4', 'line5']);
expect(result.cursorRow).toBe(0);
});
it('should handle cursor below target line', () => {
// Cursor on line 4 (index 3), target line 2 (index 1)
// Should delete lines 2-4 (indices 1-3), leaving line1 and line5
const state = createTestState(
['line1', 'line2', 'line3', 'line4', 'line5'],
3,
0,
);
const action = {
type: 'vim_delete_to_first_line' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line1', 'line5']);
expect(result.cursorRow).toBe(1);
});
});
describe('vim_change_to_last_line', () => {
it('should delete from current line to last line (cG)', () => {
const state = createTestState(['line1', 'line2', 'line3'], 0, 3);
const action = {
type: 'vim_delete_to_last_line' as const,
payload: { count: 0 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['']);
expect(result.cursorRow).toBe(0);
});
it('should delete from cursor to target line (c2G)', () => {
const state = createTestState(
['line1', 'line2', 'line3', 'line4'],
0,
0,
);
const action = {
type: 'vim_delete_to_last_line' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line3', 'line4']);
expect(result.cursorRow).toBe(0);
});
it('should handle cursor above target', () => {
// Cursor on line 2 (index 1), target line 3 (index 2)
// Should delete lines 2-3 (indices 1-2), leaving line1 and line4
const state = createTestState(
['line1', 'line2', 'line3', 'line4'],
1,
0,
);
const action = {
type: 'vim_delete_to_last_line' as const,
payload: { count: 3 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line1', 'line4']);
expect(result.cursorRow).toBe(1);
});
});
});
@@ -922,11 +1332,127 @@ describe('vim-buffer-actions', () => {
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// The movement 'j' with count 2 changes 2 lines starting from cursor row
// Since we're at cursor position 2, it changes lines starting from current row
expect(result.lines).toEqual(['line1', 'line2', 'line3']); // No change because count > available lines
// In VIM, 2cj deletes current line + 2 lines below = 3 lines total
// Since there are exactly 3 lines, all are deleted
expect(result.lines).toEqual(['']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(2);
expect(result.cursorCol).toBe(0);
});
it('should handle Unicode characters in cj (down)', () => {
const state = createTestState(
['hello 🎉 world', 'line2 émoji', 'line3'],
0,
0,
);
const action = {
type: 'vim_change_movement' as const,
payload: { movement: 'j' as const, count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line3']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
it('should handle Unicode characters in ck (up)', () => {
const state = createTestState(
['line1', 'hello 🎉 world', 'line3 émoji'],
2,
0,
);
const action = {
type: 'vim_change_movement' as const,
payload: { movement: 'k' as const, count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line1']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
it('should handle cj on first line of 2 lines (delete all)', () => {
const state = createTestState(['line1', 'line2'], 0, 0);
const action = {
type: 'vim_change_movement' as const,
payload: { movement: 'j' as const, count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
it('should handle cj on last line (delete only current line)', () => {
const state = createTestState(['line1', 'line2', 'line3'], 2, 0);
const action = {
type: 'vim_change_movement' as const,
payload: { movement: 'j' as const, count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line1', 'line2']);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
});
it('should handle ck on first line (delete only current line)', () => {
const state = createTestState(['line1', 'line2', 'line3'], 0, 0);
const action = {
type: 'vim_change_movement' as const,
payload: { movement: 'k' as const, count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line2', 'line3']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
it('should handle 2cj from middle line', () => {
const state = createTestState(
['line1', 'line2', 'line3', 'line4', 'line5'],
1,
0,
);
const action = {
type: 'vim_change_movement' as const,
payload: { movement: 'j' as const, count: 2 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// 2cj from line 1: delete lines 1, 2, 3 (current + 2 below)
expect(result.lines).toEqual(['line1', 'line5']);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
});
it('should handle 2ck from middle line', () => {
const state = createTestState(
['line1', 'line2', 'line3', 'line4', 'line5'],
3,
0,
);
const action = {
type: 'vim_change_movement' as const,
payload: { movement: 'k' as const, count: 2 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// 2ck from line 3: delete lines 1, 2, 3 (current + 2 above)
expect(result.lines).toEqual(['line1', 'line5']);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
});
});
});
@@ -39,7 +39,13 @@ export type VimAction = Extract<
| { type: 'vim_delete_line' }
| { type: 'vim_change_line' }
| { type: 'vim_delete_to_end_of_line' }
| { type: 'vim_delete_to_start_of_line' }
| { type: 'vim_delete_to_first_nonwhitespace' }
| { type: 'vim_change_to_end_of_line' }
| { type: 'vim_change_to_start_of_line' }
| { type: 'vim_change_to_first_nonwhitespace' }
| { type: 'vim_delete_to_first_line' }
| { type: 'vim_delete_to_last_line' }
| { type: 'vim_change_movement' }
| { type: 'vim_move_left' }
| { type: 'vim_move_right' }
@@ -387,21 +393,253 @@ export function handleVimAction(
case 'vim_delete_to_end_of_line':
case 'vim_change_to_end_of_line': {
const { count } = action.payload;
const currentLine = lines[cursorRow] || '';
if (cursorCol < cpLen(currentLine)) {
const totalLines = lines.length;
if (count === 1) {
// Single line: delete from cursor to end of current line
if (cursorCol < cpLen(currentLine)) {
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
cursorRow,
cpLen(currentLine),
'',
);
}
return state;
} else {
// Multi-line: delete from cursor to end of current line, plus (count-1) entire lines below
// For example, 2D = delete to EOL + delete next line entirely
const linesToDelete = Math.min(count - 1, totalLines - cursorRow - 1);
const endRow = cursorRow + linesToDelete;
if (endRow === cursorRow) {
// No additional lines to delete, just delete to EOL
if (cursorCol < cpLen(currentLine)) {
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
cursorRow,
cpLen(currentLine),
'',
);
}
return state;
}
// Delete from cursor position to end of endRow (including newlines)
const nextState = detachExpandedPaste(pushUndo(state));
const endLine = lines[endRow] || '';
return replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
endRow,
cpLen(endLine),
'',
);
}
}
case 'vim_delete_to_start_of_line': {
if (cursorCol > 0) {
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
cpLen(currentLine),
0,
cursorRow,
cursorCol,
'',
);
}
return state;
}
case 'vim_delete_to_first_nonwhitespace': {
// Delete from cursor to first non-whitespace character (vim 'd^')
const currentLine = lines[cursorRow] || '';
const lineCodePoints = toCodePoints(currentLine);
let firstNonWs = 0;
while (
firstNonWs < lineCodePoints.length &&
/\s/.test(lineCodePoints[firstNonWs])
) {
firstNonWs++;
}
// If line is all whitespace, firstNonWs would be lineCodePoints.length
// In VIM, ^ on whitespace-only line goes to column 0
if (firstNonWs >= lineCodePoints.length) {
firstNonWs = 0;
}
// Delete between cursor and first non-whitespace (whichever direction)
if (cursorCol !== firstNonWs) {
const startCol = Math.min(cursorCol, firstNonWs);
const endCol = Math.max(cursorCol, firstNonWs);
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
startCol,
cursorRow,
endCol,
'',
);
}
return state;
}
case 'vim_change_to_start_of_line': {
// Change from cursor to start of line (vim 'c0')
if (cursorCol > 0) {
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
0,
cursorRow,
cursorCol,
'',
);
}
return state;
}
case 'vim_change_to_first_nonwhitespace': {
// Change from cursor to first non-whitespace character (vim 'c^')
const currentLine = lines[cursorRow] || '';
const lineCodePoints = toCodePoints(currentLine);
let firstNonWs = 0;
while (
firstNonWs < lineCodePoints.length &&
/\s/.test(lineCodePoints[firstNonWs])
) {
firstNonWs++;
}
// If line is all whitespace, firstNonWs would be lineCodePoints.length
// In VIM, ^ on whitespace-only line goes to column 0
if (firstNonWs >= lineCodePoints.length) {
firstNonWs = 0;
}
// Change between cursor and first non-whitespace (whichever direction)
if (cursorCol !== firstNonWs) {
const startCol = Math.min(cursorCol, firstNonWs);
const endCol = Math.max(cursorCol, firstNonWs);
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
startCol,
cursorRow,
endCol,
'',
);
}
return state;
}
case 'vim_delete_to_first_line': {
// Delete from first line (or line N if count given) to current line (vim 'dgg' or 'd5gg')
// count is the target line number (1-based), or 0 for first line
const { count } = action.payload;
const totalLines = lines.length;
// Determine target row (0-based)
// count=0 means go to first line, count=N means go to line N (1-based)
let targetRow: number;
if (count > 0) {
targetRow = Math.min(count - 1, totalLines - 1);
} else {
targetRow = 0;
}
// Determine the range to delete (from min to max row, inclusive)
const startRow = Math.min(cursorRow, targetRow);
const endRow = Math.max(cursorRow, targetRow);
const linesToDelete = endRow - startRow + 1;
if (linesToDelete >= totalLines) {
// Deleting all lines - keep one empty line
const nextState = detachExpandedPaste(pushUndo(state));
return {
...nextState,
lines: [''],
cursorRow: 0,
cursorCol: 0,
preferredCol: null,
};
}
const nextState = detachExpandedPaste(pushUndo(state));
const newLines = [...nextState.lines];
newLines.splice(startRow, linesToDelete);
// Cursor goes to start of the deleted range, clamped to valid bounds
const newCursorRow = Math.min(startRow, newLines.length - 1);
return {
...nextState,
lines: newLines,
cursorRow: newCursorRow,
cursorCol: 0,
preferredCol: null,
};
}
case 'vim_delete_to_last_line': {
// Delete from current line to last line (vim 'dG') or to line N (vim 'd5G')
// count is the target line number (1-based), or 0 for last line
const { count } = action.payload;
const totalLines = lines.length;
// Determine target row (0-based)
// count=0 means go to last line, count=N means go to line N (1-based)
let targetRow: number;
if (count > 0) {
targetRow = Math.min(count - 1, totalLines - 1);
} else {
targetRow = totalLines - 1;
}
// Determine the range to delete (from min to max row, inclusive)
const startRow = Math.min(cursorRow, targetRow);
const endRow = Math.max(cursorRow, targetRow);
const linesToDelete = endRow - startRow + 1;
if (linesToDelete >= totalLines) {
// Deleting all lines - keep one empty line
const nextState = detachExpandedPaste(pushUndo(state));
return {
...nextState,
lines: [''],
cursorRow: 0,
cursorCol: 0,
preferredCol: null,
};
}
const nextState = detachExpandedPaste(pushUndo(state));
const newLines = [...nextState.lines];
newLines.splice(startRow, linesToDelete);
// Move cursor to the start of the deleted range (or last line if needed)
const newCursorRow = Math.min(startRow, newLines.length - 1);
return {
...nextState,
lines: newLines,
cursorRow: newCursorRow,
cursorCol: 0,
preferredCol: null,
};
}
case 'vim_change_movement': {
const { movement, count } = action.payload;
const totalLines = lines.length;
@@ -422,88 +660,65 @@ export function handleVimAction(
}
case 'j': {
// Down
const linesToChange = Math.min(count, totalLines - cursorRow);
// Down - delete/change current line + count lines below
const linesToChange = Math.min(count + 1, totalLines - cursorRow);
if (linesToChange > 0) {
if (totalLines === 1) {
const currentLine = state.lines[0] || '';
return replaceRangeInternal(
detachExpandedPaste(pushUndo(state)),
0,
0,
0,
cpLen(currentLine),
'',
);
} else {
if (linesToChange >= totalLines) {
// Deleting all lines - keep one empty line
const nextState = detachExpandedPaste(pushUndo(state));
const { startOffset, endOffset } = getLineRangeOffsets(
cursorRow,
linesToChange,
nextState.lines,
);
const { startRow, startCol, endRow, endCol } =
getPositionFromOffsets(startOffset, endOffset, nextState.lines);
return replaceRangeInternal(
nextState,
startRow,
startCol,
endRow,
endCol,
'',
);
return {
...nextState,
lines: [''],
cursorRow: 0,
cursorCol: 0,
preferredCol: null,
};
}
const nextState = detachExpandedPaste(pushUndo(state));
const newLines = [...nextState.lines];
newLines.splice(cursorRow, linesToChange);
return {
...nextState,
lines: newLines,
cursorRow: Math.min(cursorRow, newLines.length - 1),
cursorCol: 0,
preferredCol: null,
};
}
return state;
}
case 'k': {
// Up
const upLines = Math.min(count, cursorRow + 1);
if (upLines > 0) {
if (state.lines.length === 1) {
const currentLine = state.lines[0] || '';
return replaceRangeInternal(
detachExpandedPaste(pushUndo(state)),
0,
0,
0,
cpLen(currentLine),
'',
);
} else {
const startRow = Math.max(0, cursorRow - count + 1);
const linesToChange = cursorRow - startRow + 1;
// Up - delete/change current line + count lines above
const startRow = Math.max(0, cursorRow - count);
const linesToChange = cursorRow - startRow + 1;
if (linesToChange > 0) {
if (linesToChange >= totalLines) {
// Deleting all lines - keep one empty line
const nextState = detachExpandedPaste(pushUndo(state));
const { startOffset, endOffset } = getLineRangeOffsets(
startRow,
linesToChange,
nextState.lines,
);
const {
startRow: newStartRow,
startCol,
endRow,
endCol,
} = getPositionFromOffsets(
startOffset,
endOffset,
nextState.lines,
);
const resultState = replaceRangeInternal(
nextState,
newStartRow,
startCol,
endRow,
endCol,
'',
);
return {
...resultState,
cursorRow: startRow,
...nextState,
lines: [''],
cursorRow: 0,
cursorCol: 0,
preferredCol: null,
};
}
const nextState = detachExpandedPaste(pushUndo(state));
const newLines = [...nextState.lines];
newLines.splice(startRow, linesToChange);
return {
...nextState,
lines: newLines,
cursorRow: Math.min(startRow, newLines.length - 1),
cursorCol: 0,
preferredCol: null,
};
}
return state;
}
@@ -910,6 +1125,11 @@ export function handleVimAction(
col++;
}
// If line is all whitespace or empty, ^ goes to column 0 (standard Vim behavior)
if (col >= lineCodePoints.length) {
col = 0;
}
return {
...state,
cursorCol: col,
@@ -0,0 +1,167 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Component, type ReactNode } from 'react';
import { renderHook, render } from '../../test-utils/render.js';
import { act } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SettingsContext, useSettingsStore } from './SettingsContext.js';
import {
type LoadedSettings,
SettingScope,
type LoadedSettingsSnapshot,
type SettingsFile,
createTestMergedSettings,
} from '../../config/settings.js';
const createMockSettingsFile = (path: string): SettingsFile => ({
path,
settings: {},
originalSettings: {},
});
const mockSnapshot: LoadedSettingsSnapshot = {
system: createMockSettingsFile('/system'),
systemDefaults: createMockSettingsFile('/defaults'),
user: createMockSettingsFile('/user'),
workspace: createMockSettingsFile('/workspace'),
isTrusted: true,
errors: [],
merged: createTestMergedSettings({
ui: { theme: 'default-theme' },
}),
};
class ErrorBoundary extends Component<
{ children: ReactNode; onError: (error: Error) => void },
{ hasError: boolean }
> {
constructor(props: { children: ReactNode; onError: (error: Error) => void }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(_error: Error) {
return { hasError: true };
}
override componentDidCatch(error: Error) {
this.props.onError(error);
}
override render() {
if (this.state.hasError) {
return null;
}
return this.props.children;
}
}
const TestHarness = () => {
useSettingsStore();
return null;
};
describe('SettingsContext', () => {
let mockLoadedSettings: LoadedSettings;
let listeners: Array<() => void> = [];
beforeEach(() => {
listeners = [];
mockLoadedSettings = {
subscribe: vi.fn((listener: () => void) => {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
}),
getSnapshot: vi.fn(() => mockSnapshot),
setValue: vi.fn(),
} as unknown as LoadedSettings;
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<SettingsContext.Provider value={mockLoadedSettings}>
{children}
</SettingsContext.Provider>
);
it('should provide the correct initial state', () => {
const { result } = renderHook(() => useSettingsStore(), { wrapper });
expect(result.current.settings.merged).toEqual(mockSnapshot.merged);
expect(result.current.settings.isTrusted).toBe(true);
});
it('should allow accessing settings for a specific scope', () => {
const { result } = renderHook(() => useSettingsStore(), { wrapper });
const userSettings = result.current.settings.forScope(SettingScope.User);
expect(userSettings).toBe(mockSnapshot.user);
const workspaceSettings = result.current.settings.forScope(
SettingScope.Workspace,
);
expect(workspaceSettings).toBe(mockSnapshot.workspace);
});
it('should trigger re-renders when settings change (external event)', () => {
const { result } = renderHook(() => useSettingsStore(), { wrapper });
expect(result.current.settings.merged.ui?.theme).toBe('default-theme');
const newSnapshot = {
...mockSnapshot,
merged: { ui: { theme: 'new-theme' } },
};
(
mockLoadedSettings.getSnapshot as ReturnType<typeof vi.fn>
).mockReturnValue(newSnapshot);
// Trigger the listeners (simulate coreEvents emission)
act(() => {
listeners.forEach((l) => l());
});
expect(result.current.settings.merged.ui?.theme).toBe('new-theme');
});
it('should call store.setValue when setSetting is called', () => {
const { result } = renderHook(() => useSettingsStore(), { wrapper });
act(() => {
result.current.setSetting(SettingScope.User, 'ui.theme', 'dark');
});
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'ui.theme',
'dark',
);
});
it('should throw error if used outside provider', () => {
const onError = vi.fn();
// Suppress console.error (React logs error boundary info)
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary onError={onError}>
<TestHarness />
</ErrorBoundary>,
);
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
message: 'useSettingsStore must be used within a SettingsProvider',
}),
);
consoleSpy.mockRestore();
});
});
@@ -4,17 +4,81 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useContext } from 'react';
import type { LoadedSettings } from '../../config/settings.js';
import React, { useContext, useMemo, useSyncExternalStore } from 'react';
import type {
LoadableSettingScope,
LoadedSettings,
LoadedSettingsSnapshot,
SettingsFile,
} from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
export const SettingsContext = React.createContext<LoadedSettings | undefined>(
undefined,
);
export const useSettings = () => {
export const useSettings = (): LoadedSettings => {
const context = useContext(SettingsContext);
if (context === undefined) {
throw new Error('useSettings must be used within a SettingsProvider');
}
return context;
};
export interface SettingsState extends LoadedSettingsSnapshot {
forScope: (scope: LoadableSettingScope) => SettingsFile;
}
export interface SettingsStoreValue {
settings: SettingsState;
setSetting: (
scope: LoadableSettingScope,
key: string,
value: unknown,
) => void;
}
// Components that call this hook will re render when a settings change event is emitted
export const useSettingsStore = (): SettingsStoreValue => {
const store = useContext(SettingsContext);
if (store === undefined) {
throw new Error('useSettingsStore must be used within a SettingsProvider');
}
// React passes a listener fn into the subscribe function
// When the listener runs, it re renders the component if the snapshot changed
const snapshot = useSyncExternalStore(
(listener) => store.subscribe(listener),
() => store.getSnapshot(),
);
const settings: SettingsState = useMemo(
() => ({
...snapshot,
forScope: (scope: LoadableSettingScope) => {
switch (scope) {
case SettingScope.User:
return snapshot.user;
case SettingScope.Workspace:
return snapshot.workspace;
case SettingScope.System:
return snapshot.system;
case SettingScope.SystemDefaults:
return snapshot.systemDefaults;
default:
throw new Error(`Invalid scope: ${scope}`);
}
},
}),
[snapshot],
);
return useMemo(
() => ({
settings,
setSetting: (scope: LoadableSettingScope, key: string, value: unknown) =>
store.setValue(scope, key, value),
}),
[settings, store],
);
};
@@ -29,6 +29,11 @@ vi.mock('ink', () => ({
useStdin: () => ({
stdin: mockStdin,
}),
useStdout: () => ({
stdout: {
write: vi.fn(),
},
}),
}));
const TestComponent = ({ onColor }: { onColor: (c: string) => void }) => {
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useStdin } from 'ink';
import { useStdin, useStdout } from 'ink';
import type React from 'react';
import {
createContext,
@@ -20,6 +20,7 @@ export type TerminalEventHandler = (event: string) => void;
interface TerminalContextValue {
subscribe: (handler: TerminalEventHandler) => void;
unsubscribe: (handler: TerminalEventHandler) => void;
queryTerminalBackground: () => Promise<void>;
}
const TerminalContext = createContext<TerminalContextValue | undefined>(
@@ -38,6 +39,7 @@ export function useTerminalContext() {
export function TerminalProvider({ children }: { children: React.ReactNode }) {
const { stdin } = useStdin();
const { stdout } = useStdout();
const subscribers = useRef<Set<TerminalEventHandler>>(new Set()).current;
const bufferRef = useRef('');
@@ -55,6 +57,23 @@ export function TerminalProvider({ children }: { children: React.ReactNode }) {
[subscribers],
);
const queryTerminalBackground = useCallback(
async () =>
new Promise<void>((resolve) => {
const handler = () => {
unsubscribe(handler);
resolve();
};
subscribe(handler);
TerminalCapabilityManager.queryBackgroundColor(stdout);
setTimeout(() => {
unsubscribe(handler);
resolve();
}, 100);
}),
[stdout, subscribe, unsubscribe],
);
useEffect(() => {
const handleData = (data: Buffer | string) => {
bufferRef.current +=
@@ -89,7 +108,9 @@ export function TerminalProvider({ children }: { children: React.ReactNode }) {
}, [stdin, subscribers]);
return (
<TerminalContext.Provider value={{ subscribe, unsubscribe }}>
<TerminalContext.Provider
value={{ subscribe, unsubscribe, queryTerminalBackground }}
>
{children}
</TerminalContext.Provider>
);
@@ -14,7 +14,6 @@ import {
ToolConfirmationOutcome,
MessageBusType,
IdeClient,
type ToolCallConfirmationDetails,
} from '@google/gemini-cli-core';
import { ToolCallStatus, type IndividualToolCallDisplay } from '../types.js';
@@ -50,21 +49,9 @@ describe('ToolActionsContext', () => {
resultDisplay: undefined,
confirmationDetails: { type: 'info', title: 'title', prompt: 'prompt' },
},
{
callId: 'legacy-call',
name: 'legacy-tool',
description: 'desc',
status: ToolCallStatus.Confirming,
resultDisplay: undefined,
confirmationDetails: {
type: 'info',
title: 'legacy',
prompt: 'prompt',
onConfirm: vi.fn(),
} as ToolCallConfirmationDetails,
},
{
callId: 'edit-call',
correlationId: 'corr-edit',
name: 'edit-tool',
description: 'desc',
status: ToolCallStatus.Confirming,
@@ -77,8 +64,7 @@ describe('ToolActionsContext', () => {
fileDiff: 'diff',
originalContent: 'old',
newContent: 'new',
onConfirm: vi.fn(),
} as ToolCallConfirmationDetails,
},
},
];
@@ -92,7 +78,7 @@ describe('ToolActionsContext', () => {
</ToolActionsProvider>
);
it('publishes to MessageBus for tools with correlationId (Modern Path)', async () => {
it('publishes to MessageBus for tools with correlationId', async () => {
const { result } = renderHook(() => useToolActions(), { wrapper });
await result.current.confirm(
@@ -110,27 +96,6 @@ describe('ToolActionsContext', () => {
});
});
it('calls onConfirm for legacy tools (Legacy Path)', async () => {
const { result } = renderHook(() => useToolActions(), { wrapper });
const legacyDetails = mockToolCalls[1]
.confirmationDetails as ToolCallConfirmationDetails;
await result.current.confirm(
'legacy-call',
ToolConfirmationOutcome.ProceedOnce,
);
if (legacyDetails && 'onConfirm' in legacyDetails) {
expect(legacyDetails.onConfirm).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
undefined,
);
} else {
throw new Error('Expected onConfirm to be present');
}
expect(mockMessageBus.publish).not.toHaveBeenCalled();
});
it('handles cancel by calling confirm with Cancel outcome', async () => {
const { result } = renderHook(() => useToolActions(), { wrapper });
@@ -170,13 +135,11 @@ describe('ToolActionsContext', () => {
'/f.txt',
'accepted',
);
const editDetails = mockToolCalls[2]
.confirmationDetails as ToolCallConfirmationDetails;
if (editDetails && 'onConfirm' in editDetails) {
expect(editDetails.onConfirm).toHaveBeenCalled();
} else {
throw new Error('Expected onConfirm to be present');
}
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
correlationId: 'corr-edit',
}),
);
});
it('updates isDiffingEnabled when IdeClient status changes', async () => {
@@ -18,7 +18,6 @@ import {
MessageBusType,
type Config,
type ToolConfirmationPayload,
type ToolCallConfirmationDetails,
debugLogger,
} from '@google/gemini-cli-core';
import type { IndividualToolCallDisplay } from '../types.js';
@@ -113,8 +112,7 @@ export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
await ideClient?.resolveDiffFromCli(details.filePath, cliOutcome);
}
// 2. Dispatch
// PATH A: Event Bus (Modern)
// 2. Dispatch via Event Bus
if (tool.correlationId) {
await config.getMessageBus().publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
@@ -127,20 +125,7 @@ export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
return;
}
// PATH B: Legacy Callback (Adapter or Old Scheduler)
if (
details &&
'onConfirm' in details &&
typeof details.onConfirm === 'function'
) {
await (details as ToolCallConfirmationDetails).onConfirm(
outcome,
payload,
);
return;
}
debugLogger.warn(`ToolActions: No confirmation mechanism for ${callId}`);
debugLogger.warn(`ToolActions: No correlationId for ${callId}`);
},
[config, ideClient, toolCalls, isDiffingEnabled],
);
@@ -20,7 +20,10 @@ import type { SessionInfo } from '../../utils/sessionUtils.js';
import { type NewAgentsChoice } from '../components/NewAgentsNotification.js';
export interface UIActions {
handleThemeSelect: (themeName: string, scope: LoadableSettingScope) => void;
handleThemeSelect: (
themeName: string,
scope: LoadableSettingScope,
) => Promise<void>;
closeThemeDialog: () => void;
handleThemeHighlight: (themeName: string | undefined) => void;
handleAuthSelect: (
@@ -68,6 +71,10 @@ export interface UIActions {
handleApiKeyCancel: () => void;
setBannerVisible: (visible: boolean) => void;
setShortcutsHelpVisible: (visible: boolean) => void;
setCleanUiDetailsVisible: (visible: boolean) => void;
toggleCleanUiDetailsVisible: () => void;
revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
handleWarning: (message: string) => void;
setEmbeddedShellFocused: (value: boolean) => void;
dismissBackgroundShell: (pid: number) => void;
setActiveBackgroundShellPid: (pid: number) => void;
@@ -120,6 +120,7 @@ export interface UIState {
ctrlDPressedOnce: boolean;
showEscapePrompt: boolean;
shortcutsHelpVisible: boolean;
cleanUiDetailsVisible: boolean;
elapsedTime: number;
currentLoadingPhrase: string | undefined;
historyRemountKey: number;
@@ -1,97 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`useReactToolScheduler > should handle live output updates 1`] = `
{
"callId": "liveCall",
"contentLength": 12,
"data": undefined,
"error": undefined,
"errorType": undefined,
"outputFile": undefined,
"responseParts": [
{
"functionResponse": {
"id": "liveCall",
"name": "mockToolWithLiveOutput",
"response": {
"output": "Final output",
},
},
},
],
"resultDisplay": "Final display",
}
`;
exports[`useReactToolScheduler > should handle tool requiring confirmation - approved 1`] = `
{
"callId": "callConfirm",
"contentLength": 16,
"data": undefined,
"error": undefined,
"errorType": undefined,
"outputFile": undefined,
"responseParts": [
{
"functionResponse": {
"id": "callConfirm",
"name": "mockToolRequiresConfirmation",
"response": {
"output": "Confirmed output",
},
},
},
],
"resultDisplay": "Confirmed display",
}
`;
exports[`useReactToolScheduler > should handle tool requiring confirmation - cancelled by user 1`] = `
{
"callId": "callConfirmCancel",
"contentLength": 59,
"error": undefined,
"errorType": undefined,
"responseParts": [
{
"functionResponse": {
"id": "callConfirmCancel",
"name": "mockToolRequiresConfirmation",
"response": {
"error": "[Operation Cancelled] Reason: User cancelled the operation.",
},
},
},
],
"resultDisplay": {
"fileDiff": "Mock tool requires confirmation",
"fileName": "mockToolRequiresConfirmation.ts",
"filePath": undefined,
"newContent": undefined,
"originalContent": undefined,
},
}
`;
exports[`useReactToolScheduler > should schedule and execute a tool call successfully 1`] = `
{
"callId": "call1",
"contentLength": 11,
"data": undefined,
"error": undefined,
"errorType": undefined,
"outputFile": undefined,
"responseParts": [
{
"functionResponse": {
"id": "call1",
"name": "mockTool",
"response": {
"output": "Tool output",
},
},
},
],
"resultDisplay": "Formatted tool output",
}
`;
@@ -145,6 +145,7 @@ describe('handleAtCommand', () => {
afterEach(async () => {
abortController.abort();
await fsPromises.rm(testRootDir, { recursive: true, force: true });
vi.unstubAllGlobals();
});
it('should pass through query if no @ command is present', async () => {
@@ -319,6 +320,46 @@ describe('handleAtCommand', () => {
);
}, 10000);
it('should correctly handle double-quoted paths with spaces', async () => {
// Mock platform to win32 so unescapePath strips quotes
vi.stubGlobal(
'process',
Object.create(process, {
platform: {
get: () => 'win32',
},
}),
);
const fileContent = 'Content of file with spaces';
const filePath = await createTestFile(
path.join(testRootDir, 'my folder', 'my file.txt'),
fileContent,
);
// On Windows, the user might provide: @"path/to/my file.txt"
const query = `@"${filePath}"`;
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 126,
signal: abortController.signal,
});
const relativePath = getRelativePath(filePath);
expect(result).toEqual({
processedQuery: [
{ text: `@${relativePath}` },
{ text: '\n--- Content from referenced files ---' },
{ text: `\nContent from @${relativePath}:\n` },
{ text: fileContent },
{ text: '\n--- End of content ---' },
],
});
});
it('should correctly handle file paths with narrow non-breaking space (NNBSP)', async () => {
const nnbsp = '\u202F';
const fileContent = 'NNBSP file content.';
@@ -31,12 +31,13 @@ const REF_CONTENT_FOOTER = `\n${REFERENCE_CONTENT_END}`;
* Regex source for the path/command part of an @ reference.
* It uses strict ASCII whitespace delimiters to allow Unicode characters like NNBSP in filenames.
*
* 1. \\. matches any escaped character (e.g., \ ).
* 2. [^ \t\n\r,;!?()\[\]{}.] matches any character that is NOT a delimiter and NOT a period.
* 3. \.(?!$|[ \t\n\r]) matches a period ONLY if it is NOT followed by whitespace or end-of-string.
* 1. "(?:[^"]*)" matches a double-quoted string (for Windows paths with spaces).
* 2. \\. matches any escaped character (e.g., \ ).
* 3. [^ \t\n\r,;!?()\[\]{}.] matches any character that is NOT a delimiter and NOT a period.
* 4. \.(?!$|[ \t\n\r]) matches a period ONLY if it is NOT followed by whitespace or end-of-string.
*/
export const AT_COMMAND_PATH_REGEX_SOURCE =
'(?:\\\\.|[^ \\t\\n\\r,;!?()\\[\\]{}.]|\\.(?!$|[ \\t\\n\\r]))+';
'(?:(?:"(?:[^"]*)")|(?:\\\\.|[^ \\t\\n\\r,;!?()\\[\\]{}.]|\\.(?!$|[ \\t\\n\\r])))+';
interface HandleAtCommandParams {
query: string;
@@ -85,8 +86,8 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
});
}
// unescapePath expects the @ symbol to be present, and will handle it.
const atPath = unescapePath(fullMatch);
// We strip the @ before unescaping so that unescapePath can handle quoted paths correctly on Windows.
const atPath = '@' + unescapePath(fullMatch.substring(1));
parts.push({ type: 'atPath', content: atPath });
lastIndex = matchIndex + fullMatch.length;
@@ -18,10 +18,13 @@ import { FileCommandLoader } from '../../services/FileCommandLoader.js';
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
import {
type GeminiClient,
type UserFeedbackPayload,
SlashCommandStatus,
makeFakeConfig,
coreEvents,
CoreEvent,
} from '@google/gemini-cli-core';
import { SlashCommandConflictHandler } from '../../services/SlashCommandConflictHandler.js';
const {
logSlashCommand,
@@ -182,6 +185,26 @@ describe('useSlashCommandProcessor', () => {
mockFileLoadCommands.mockResolvedValue(Object.freeze(fileCommands));
mockMcpLoadCommands.mockResolvedValue(Object.freeze(mcpCommands));
const conflictHandler = new SlashCommandConflictHandler();
conflictHandler.start();
const handleFeedback = (payload: UserFeedbackPayload) => {
let type = MessageType.INFO;
if (payload.severity === 'error') {
type = MessageType.ERROR;
} else if (payload.severity === 'warning') {
type = MessageType.WARNING;
}
mockAddItem(
{
type,
text: payload.message,
},
Date.now(),
);
};
coreEvents.on(CoreEvent.UserFeedback, handleFeedback);
let result!: { current: ReturnType<typeof useSlashCommandProcessor> };
let unmount!: () => void;
let rerender!: (props?: unknown) => void;
@@ -228,7 +251,11 @@ describe('useSlashCommandProcessor', () => {
rerender = hook.rerender;
});
unmountHook = async () => unmount();
unmountHook = async () => {
conflictHandler.stop();
coreEvents.off(CoreEvent.UserFeedback, handleFeedback);
unmount();
};
await waitFor(() => {
expect(result.current.slashCommands).toBeDefined();
@@ -1052,4 +1079,119 @@ describe('useSlashCommandProcessor', () => {
expect(result.current.slashCommands).toEqual([newCommand]),
);
});
describe('Conflict Notifications', () => {
it('should display a warning when a command conflict occurs', async () => {
const builtinCommand = createTestCommand({ name: 'deploy' });
const extensionCommand = createTestCommand(
{
name: 'deploy',
extensionName: 'firebase',
},
CommandKind.FILE,
);
const result = await setupProcessorHook({
builtinCommands: [builtinCommand],
fileCommands: [extensionCommand],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(2));
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('Command conflicts detected'),
}),
expect.any(Number),
);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining(
"- Command '/deploy' from extension 'firebase' was renamed",
),
}),
expect.any(Number),
);
});
it('should deduplicate conflict warnings across re-renders', async () => {
const builtinCommand = createTestCommand({ name: 'deploy' });
const extensionCommand = createTestCommand(
{
name: 'deploy',
extensionName: 'firebase',
},
CommandKind.FILE,
);
const result = await setupProcessorHook({
builtinCommands: [builtinCommand],
fileCommands: [extensionCommand],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(2));
// First notification
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('Command conflicts detected'),
}),
expect.any(Number),
);
mockAddItem.mockClear();
// Trigger a reload or re-render
await act(async () => {
result.current.commandContext.ui.reloadCommands();
});
// Wait a bit for effect to run
await new Promise((resolve) => setTimeout(resolve, 100));
// Should NOT have notified again
expect(mockAddItem).not.toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('Command conflicts detected'),
}),
expect.any(Number),
);
});
it('should correctly identify the winner extension in the message', async () => {
const ext1Command = createTestCommand(
{
name: 'deploy',
extensionName: 'firebase',
},
CommandKind.FILE,
);
const ext2Command = createTestCommand(
{
name: 'deploy',
extensionName: 'aws',
},
CommandKind.FILE,
);
const result = await setupProcessorHook({
fileCommands: [ext1Command, ext2Command],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(2));
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining("conflicts with extension 'firebase'"),
}),
expect.any(Number),
);
});
});
});
@@ -329,6 +329,11 @@ export const useSlashCommandProcessor = (
],
controller.signal,
);
if (controller.signal.aborted) {
return;
}
setCommands(commandService.getCommands());
})();
+2 -5
View File
@@ -7,7 +7,6 @@
import {
type ToolCall,
type Status as CoreStatus,
type ToolCallConfirmationDetails,
type SerializableConfirmationDetails,
type ToolResultDisplay,
debugLogger,
@@ -76,10 +75,8 @@ export function mapToDisplay(
};
let resultDisplay: ToolResultDisplay | undefined = undefined;
let confirmationDetails:
| ToolCallConfirmationDetails
| SerializableConfirmationDetails
| undefined = undefined;
let confirmationDetails: SerializableConfirmationDetails | undefined =
undefined;
let outputFile: string | undefined = undefined;
let ptyId: number | undefined = undefined;
let correlationId: string | undefined = undefined;
@@ -32,6 +32,7 @@ import {
GeminiEventType as ServerGeminiEventType,
ToolErrorType,
ToolConfirmationOutcome,
MessageBusType,
tokenLimit,
debugLogger,
coreEvents,
@@ -49,6 +50,11 @@ const mockSendMessageStream = vi
.fn()
.mockReturnValue((async function* () {})());
const mockStartChat = vi.fn();
const mockMessageBus = {
publish: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
};
const MockedGeminiClientClass = vi.hoisted(() =>
vi.fn().mockImplementation(function (this: any, _config: any) {
@@ -246,11 +252,11 @@ describe('useGeminiStream', () => {
getContentGenerator: vi.fn(),
isInteractive: () => false,
getExperiments: () => {},
isEventDrivenSchedulerEnabled: vi.fn(() => false),
getMaxSessionTurns: vi.fn(() => 100),
isJitContextEnabled: vi.fn(() => false),
getGlobalMemory: vi.fn(() => ''),
getUserMemory: vi.fn(() => ''),
getMessageBus: vi.fn(() => mockMessageBus),
getIdeMode: vi.fn(() => false),
getEnableHooks: vi.fn(() => false),
} as unknown as Config;
@@ -400,7 +406,6 @@ describe('useGeminiStream', () => {
toolName: string,
callId: string,
confirmationType: 'edit' | 'info',
mockOnConfirm: Mock,
status: TrackedToolCall['status'] = 'awaiting_approval',
): TrackedWaitingToolCall => ({
request: {
@@ -417,7 +422,6 @@ describe('useGeminiStream', () => {
? {
type: 'edit',
title: 'Confirm Edit',
onConfirm: mockOnConfirm,
fileName: 'file.txt',
filePath: '/test/file.txt',
fileDiff: 'fake diff',
@@ -427,7 +431,6 @@ describe('useGeminiStream', () => {
: {
type: 'info',
title: `${toolName} confirmation`,
onConfirm: mockOnConfirm,
prompt: `Execute ${toolName}?`,
},
tool: {
@@ -439,6 +442,7 @@ describe('useGeminiStream', () => {
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
correlationId: `corr-${callId}`,
});
// Helper to render hook with default parameters - reduces boilerplate
@@ -1764,10 +1768,9 @@ describe('useGeminiStream', () => {
describe('handleApprovalModeChange', () => {
it('should auto-approve all pending tool calls when switching to YOLO mode', async () => {
const mockOnConfirm = vi.fn().mockResolvedValue(undefined);
const awaitingApprovalToolCalls: TrackedToolCall[] = [
createMockToolCall('replace', 'call1', 'edit', mockOnConfirm),
createMockToolCall('read_file', 'call2', 'info', mockOnConfirm),
createMockToolCall('replace', 'call1', 'edit'),
createMockToolCall('read_file', 'call2', 'info'),
];
const { result } = renderTestHook(awaitingApprovalToolCalls);
@@ -1777,21 +1780,27 @@ describe('useGeminiStream', () => {
});
// Both tool calls should be auto-approved
expect(mockOnConfirm).toHaveBeenCalledTimes(2);
expect(mockOnConfirm).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
expect(mockMessageBus.publish).toHaveBeenCalledTimes(2);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-call1',
outcome: ToolConfirmationOutcome.ProceedOnce,
}),
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
correlationId: 'corr-call2',
outcome: ToolConfirmationOutcome.ProceedOnce,
}),
);
});
it('should only auto-approve edit tools when switching to AUTO_EDIT mode', async () => {
const mockOnConfirmReplace = vi.fn().mockResolvedValue(undefined);
const mockOnConfirmWrite = vi.fn().mockResolvedValue(undefined);
const mockOnConfirmRead = vi.fn().mockResolvedValue(undefined);
const awaitingApprovalToolCalls: TrackedToolCall[] = [
createMockToolCall('replace', 'call1', 'edit', mockOnConfirmReplace),
createMockToolCall('write_file', 'call2', 'edit', mockOnConfirmWrite),
createMockToolCall('read_file', 'call3', 'info', mockOnConfirmRead),
createMockToolCall('replace', 'call1', 'edit'),
createMockToolCall('write_file', 'call2', 'edit'),
createMockToolCall('read_file', 'call3', 'info'),
];
const { result } = renderTestHook(awaitingApprovalToolCalls);
@@ -1801,21 +1810,21 @@ describe('useGeminiStream', () => {
});
// Only replace and write_file should be auto-approved
expect(mockOnConfirmReplace).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
expect(mockMessageBus.publish).toHaveBeenCalledTimes(2);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({ correlationId: 'corr-call1' }),
);
expect(mockOnConfirmWrite).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({ correlationId: 'corr-call2' }),
);
expect(mockMessageBus.publish).not.toHaveBeenCalledWith(
expect.objectContaining({ correlationId: 'corr-call3' }),
);
// read_file should not be auto-approved
expect(mockOnConfirmRead).not.toHaveBeenCalled();
});
it('should not auto-approve any tools when switching to REQUIRE_CONFIRMATION mode', async () => {
const mockOnConfirm = vi.fn().mockResolvedValue(undefined);
const awaitingApprovalToolCalls: TrackedToolCall[] = [
createMockToolCall('replace', 'call1', 'edit', mockOnConfirm),
createMockToolCall('replace', 'call1', 'edit'),
];
const { result } = renderTestHook(awaitingApprovalToolCalls);
@@ -1825,21 +1834,19 @@ describe('useGeminiStream', () => {
});
// No tools should be auto-approved
expect(mockOnConfirm).not.toHaveBeenCalled();
expect(mockMessageBus.publish).not.toHaveBeenCalled();
});
it('should handle errors gracefully when auto-approving tool calls', async () => {
const debuggerSpy = vi
.spyOn(debugLogger, 'warn')
.mockImplementation(() => {});
const mockOnConfirmSuccess = vi.fn().mockResolvedValue(undefined);
const mockOnConfirmError = vi
.fn()
.mockRejectedValue(new Error('Approval failed'));
mockMessageBus.publish.mockRejectedValueOnce(new Error('Bus error'));
const awaitingApprovalToolCalls: TrackedToolCall[] = [
createMockToolCall('replace', 'call1', 'edit', mockOnConfirmSuccess),
createMockToolCall('write_file', 'call2', 'edit', mockOnConfirmError),
createMockToolCall('replace', 'call1', 'edit'),
createMockToolCall('write_file', 'call2', 'edit'),
];
const { result } = renderTestHook(awaitingApprovalToolCalls);
@@ -1848,13 +1855,10 @@ describe('useGeminiStream', () => {
await result.current.handleApprovalModeChange(ApprovalMode.YOLO);
});
// Both confirmation methods should be called
expect(mockOnConfirmSuccess).toHaveBeenCalled();
expect(mockOnConfirmError).toHaveBeenCalled();
// Error should be logged
// Both should be attempted despite first error
expect(mockMessageBus.publish).toHaveBeenCalledTimes(2);
expect(debuggerSpy).toHaveBeenCalledWith(
'Failed to auto-approve tool call call2:',
'Failed to auto-approve tool call call1:',
expect.any(Error),
);
@@ -1883,6 +1887,7 @@ describe('useGeminiStream', () => {
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
correlationId: 'corr-1',
} as unknown as TrackedWaitingToolCall,
];
@@ -1894,83 +1899,9 @@ describe('useGeminiStream', () => {
});
});
it('should skip tool calls without onConfirm method in confirmationDetails', async () => {
const awaitingApprovalToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'call1',
name: 'replace',
args: { old_string: 'old', new_string: 'new' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
confirmationDetails: {
type: 'edit',
title: 'Confirm Edit',
// No onConfirm method
fileName: 'file.txt',
filePath: '/test/file.txt',
fileDiff: 'fake diff',
originalContent: 'old',
newContent: 'new',
} as any,
tool: {
name: 'replace',
displayName: 'replace',
description: 'Replace text',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
} as TrackedWaitingToolCall,
];
const { result } = renderTestHook(awaitingApprovalToolCalls);
// Should not throw an error
await act(async () => {
await result.current.handleApprovalModeChange(ApprovalMode.YOLO);
});
});
it('should only process tool calls with awaiting_approval status', async () => {
const mockOnConfirmAwaiting = vi.fn().mockResolvedValue(undefined);
const mockOnConfirmExecuting = vi.fn().mockResolvedValue(undefined);
const mixedStatusToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'call1',
name: 'replace',
args: { old_string: 'old', new_string: 'new' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
status: 'awaiting_approval',
responseSubmittedToGemini: false,
confirmationDetails: {
type: 'edit',
title: 'Confirm Edit',
onConfirm: mockOnConfirmAwaiting,
fileName: 'file.txt',
filePath: '/test/file.txt',
fileDiff: 'fake diff',
originalContent: 'old',
newContent: 'new',
},
tool: {
name: 'replace',
displayName: 'replace',
description: 'Replace text',
build: vi.fn(),
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
} as TrackedWaitingToolCall,
createMockToolCall('replace', 'call1', 'edit'),
{
request: {
callId: 'call2',
@@ -1992,6 +1923,7 @@ describe('useGeminiStream', () => {
} as unknown as AnyToolInvocation,
startTime: Date.now(),
liveOutput: 'Writing...',
correlationId: 'corr-call2',
} as TrackedExecutingToolCall,
];
@@ -2001,9 +1933,14 @@ describe('useGeminiStream', () => {
await result.current.handleApprovalModeChange(ApprovalMode.YOLO);
});
// Only the awaiting_approval tool should be processed
expect(mockOnConfirmAwaiting).toHaveBeenCalledTimes(1);
expect(mockOnConfirmExecuting).not.toHaveBeenCalled();
// Only the awaiting_approval tool should be processed.
expect(mockMessageBus.publish).toHaveBeenCalledTimes(1);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({ correlationId: 'corr-call1' }),
);
expect(mockMessageBus.publish).not.toHaveBeenCalledWith(
expect.objectContaining({ correlationId: 'corr-call2' }),
);
});
});
+10 -4
View File
@@ -20,6 +20,7 @@ import {
ApprovalMode,
parseAndFormatApiError,
ToolConfirmationOutcome,
MessageBusType,
promptIdContext,
tokenLimit,
debugLogger,
@@ -1408,10 +1409,15 @@ export const useGeminiStream = (
// Process pending tool calls sequentially to reduce UI chaos
for (const call of awaitingApprovalCalls) {
const details = call.confirmationDetails;
if (details && 'onConfirm' in details) {
if (call.correlationId) {
try {
await details.onConfirm(ToolConfirmationOutcome.ProceedOnce);
await config.getMessageBus().publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: call.correlationId,
confirmed: true,
requiresUserConfirmation: false,
outcome: ToolConfirmationOutcome.ProceedOnce,
});
} catch (error) {
debugLogger.warn(
`Failed to auto-approve tool call ${call.request.callId}:`,
@@ -1422,7 +1428,7 @@ export const useGeminiStream = (
}
}
},
[toolCalls],
[config, toolCalls],
);
const handleCompletedTools = useCallback(
@@ -1,77 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CoreToolScheduler } from '@google/gemini-cli-core';
import type { Config } from '@google/gemini-cli-core';
import { renderHook } from '../../test-utils/render.js';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { useReactToolScheduler } from './useReactToolScheduler.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
CoreToolScheduler: vi.fn(),
};
});
const mockCoreToolScheduler = vi.mocked(CoreToolScheduler);
describe('useReactToolScheduler', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('only creates one instance of CoreToolScheduler even if props change', () => {
const onComplete = vi.fn();
const getPreferredEditor = vi.fn();
const config = {} as Config;
const { rerender } = renderHook(
(props) =>
useReactToolScheduler(
props.onComplete,
props.config,
props.getPreferredEditor,
),
{
initialProps: {
onComplete,
config,
getPreferredEditor,
},
},
);
expect(mockCoreToolScheduler).toHaveBeenCalledTimes(1);
// Rerender with a new onComplete function
const newOnComplete = vi.fn();
rerender({
onComplete: newOnComplete,
config,
getPreferredEditor,
});
expect(mockCoreToolScheduler).toHaveBeenCalledTimes(1);
// Rerender with a new getPreferredEditor function
const newGetPreferredEditor = vi.fn();
rerender({
onComplete: newOnComplete,
config,
getPreferredEditor: newGetPreferredEditor,
});
expect(mockCoreToolScheduler).toHaveBeenCalledTimes(1);
rerender({
onComplete: newOnComplete,
config,
getPreferredEditor: newGetPreferredEditor,
});
expect(mockCoreToolScheduler).toHaveBeenCalledTimes(1);
});
});
@@ -1,221 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Config,
ToolCallRequestInfo,
OutputUpdateHandler,
AllToolCallsCompleteHandler,
ToolCallsUpdateHandler,
ToolCall,
EditorType,
CompletedToolCall,
ExecutingToolCall,
ScheduledToolCall,
ValidatingToolCall,
WaitingToolCall,
CancelledToolCall,
} from '@google/gemini-cli-core';
import { CoreToolScheduler } from '@google/gemini-cli-core';
import { useCallback, useState, useMemo, useEffect, useRef } from 'react';
export type ScheduleFn = (
request: ToolCallRequestInfo | ToolCallRequestInfo[],
signal: AbortSignal,
) => Promise<void>;
export type MarkToolsAsSubmittedFn = (callIds: string[]) => void;
export type CancelAllFn = (signal: AbortSignal) => void;
export type TrackedScheduledToolCall = ScheduledToolCall & {
responseSubmittedToGemini?: boolean;
};
export type TrackedValidatingToolCall = ValidatingToolCall & {
responseSubmittedToGemini?: boolean;
};
export type TrackedWaitingToolCall = WaitingToolCall & {
responseSubmittedToGemini?: boolean;
};
export type TrackedExecutingToolCall = ExecutingToolCall & {
responseSubmittedToGemini?: boolean;
};
export type TrackedCompletedToolCall = CompletedToolCall & {
responseSubmittedToGemini?: boolean;
};
export type TrackedCancelledToolCall = CancelledToolCall & {
responseSubmittedToGemini?: boolean;
};
export type TrackedToolCall =
| TrackedScheduledToolCall
| TrackedValidatingToolCall
| TrackedWaitingToolCall
| TrackedExecutingToolCall
| TrackedCompletedToolCall
| TrackedCancelledToolCall;
/**
* Legacy scheduler implementation based on CoreToolScheduler callbacks.
*
* This is currently the default implementation used by useGeminiStream.
* It will be phased out once the event-driven scheduler migration is complete.
*/
export function useReactToolScheduler(
onComplete: (tools: CompletedToolCall[]) => Promise<void>,
config: Config,
getPreferredEditor: () => EditorType | undefined,
): [
TrackedToolCall[],
ScheduleFn,
MarkToolsAsSubmittedFn,
React.Dispatch<React.SetStateAction<TrackedToolCall[]>>,
CancelAllFn,
number,
] {
const [toolCallsForDisplay, setToolCallsForDisplay] = useState<
TrackedToolCall[]
>([]);
const [lastToolOutputTime, setLastToolOutputTime] = useState<number>(0);
const onCompleteRef = useRef(onComplete);
const getPreferredEditorRef = useRef(getPreferredEditor);
useEffect(() => {
onCompleteRef.current = onComplete;
}, [onComplete]);
useEffect(() => {
getPreferredEditorRef.current = getPreferredEditor;
}, [getPreferredEditor]);
const outputUpdateHandler: OutputUpdateHandler = useCallback(
(toolCallId, outputChunk) => {
setLastToolOutputTime(Date.now());
setToolCallsForDisplay((prevCalls) =>
prevCalls.map((tc) => {
if (tc.request.callId === toolCallId && tc.status === 'executing') {
const executingTc = tc;
return { ...executingTc, liveOutput: outputChunk };
}
return tc;
}),
);
},
[],
);
const allToolCallsCompleteHandler: AllToolCallsCompleteHandler = useCallback(
async (completedToolCalls) => {
await onCompleteRef.current(completedToolCalls);
},
[],
);
const toolCallsUpdateHandler: ToolCallsUpdateHandler = useCallback(
(allCoreToolCalls: ToolCall[]) => {
setToolCallsForDisplay((prevTrackedCalls) => {
const prevCallsMap = new Map(
prevTrackedCalls.map((c) => [c.request.callId, c]),
);
return allCoreToolCalls.map((coreTc): TrackedToolCall => {
const existingTrackedCall = prevCallsMap.get(coreTc.request.callId);
const responseSubmittedToGemini =
existingTrackedCall?.responseSubmittedToGemini ?? false;
if (coreTc.status === 'executing') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const liveOutput = (existingTrackedCall as TrackedExecutingToolCall)
?.liveOutput;
return {
...coreTc,
responseSubmittedToGemini,
liveOutput,
};
} else if (
coreTc.status === 'success' ||
coreTc.status === 'error' ||
coreTc.status === 'cancelled'
) {
return {
...coreTc,
responseSubmittedToGemini,
};
} else {
return {
...coreTc,
responseSubmittedToGemini,
};
}
});
});
},
[setToolCallsForDisplay],
);
const stableGetPreferredEditor = useCallback(
() => getPreferredEditorRef.current(),
[],
);
const scheduler = useMemo(
() =>
new CoreToolScheduler({
outputUpdateHandler,
onAllToolCallsComplete: allToolCallsCompleteHandler,
onToolCallsUpdate: toolCallsUpdateHandler,
getPreferredEditor: stableGetPreferredEditor,
config,
}),
[
config,
outputUpdateHandler,
allToolCallsCompleteHandler,
toolCallsUpdateHandler,
stableGetPreferredEditor,
],
);
const schedule: ScheduleFn = useCallback(
(
request: ToolCallRequestInfo | ToolCallRequestInfo[],
signal: AbortSignal,
) => {
setToolCallsForDisplay([]);
return scheduler.schedule(request, signal);
},
[scheduler, setToolCallsForDisplay],
);
const markToolsAsSubmitted: MarkToolsAsSubmittedFn = useCallback(
(callIdsToMark: string[]) => {
setToolCallsForDisplay((prevCalls) =>
prevCalls.map((tc) =>
callIdsToMark.includes(tc.request.callId)
? { ...tc, responseSubmittedToGemini: true }
: tc,
),
);
},
[],
);
const cancelAllToolCalls = useCallback(
(signal: AbortSignal) => {
scheduler.cancelAll(signal);
},
[scheduler],
);
return [
toolCallsForDisplay,
schedule,
markToolsAsSubmitted,
setToolCallsForDisplay,
cancelAllToolCalls,
lastToolOutputTime,
];
}
@@ -12,7 +12,7 @@ import {
SHELL_SILENT_WORKING_TITLE_DELAY_MS,
} from '../constants.js';
import type { StreamingState } from '../types.js';
import { type TrackedToolCall } from './useReactToolScheduler.js';
import { type TrackedToolCall } from './useToolScheduler.js';
interface ShellInactivityStatusProps {
activePtyId: number | string | null | undefined;
@@ -16,7 +16,11 @@ import type { UIState } from '../contexts/UIStateContext.js';
vi.mock('../themes/theme-manager.js', () => ({
themeManager: {
getActiveTheme: vi.fn(),
setTerminalBackground: vi.fn(),
getAllThemes: vi.fn(() => []),
setActiveTheme: vi.fn(),
},
DEFAULT_THEME: { name: 'Default' },
}));
vi.mock('../themes/holiday.js', () => ({
@@ -0,0 +1,201 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
import { useSuspend } from './useSuspend.js';
import {
writeToStdout,
disableMouseEvents,
enableMouseEvents,
enterAlternateScreen,
exitAlternateScreen,
enableLineWrapping,
disableLineWrapping,
} from '@google/gemini-cli-core';
import {
cleanupTerminalOnExit,
terminalCapabilityManager,
} from '../utils/terminalCapabilityManager.js';
vi.mock('@google/gemini-cli-core', async () => {
const actual = await vi.importActual('@google/gemini-cli-core');
return {
...actual,
writeToStdout: vi.fn(),
disableMouseEvents: vi.fn(),
enableMouseEvents: vi.fn(),
enterAlternateScreen: vi.fn(),
exitAlternateScreen: vi.fn(),
enableLineWrapping: vi.fn(),
disableLineWrapping: vi.fn(),
};
});
vi.mock('../utils/terminalCapabilityManager.js', () => ({
cleanupTerminalOnExit: vi.fn(),
terminalCapabilityManager: {
enableSupportedModes: vi.fn(),
},
}));
describe('useSuspend', () => {
const originalPlatform = process.platform;
let killSpy: Mock;
const setPlatform = (platform: NodeJS.Platform) => {
Object.defineProperty(process, 'platform', {
value: platform,
configurable: true,
});
};
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
killSpy = vi
.spyOn(process, 'kill')
.mockReturnValue(true) as unknown as Mock;
// Default tests to a POSIX platform so suspend path assertions are stable.
setPlatform('linux');
});
afterEach(() => {
vi.useRealTimers();
killSpy.mockRestore();
setPlatform(originalPlatform);
});
it('cleans terminal state on suspend and restores/repaints on resume in alternate screen mode', () => {
const handleWarning = vi.fn();
const setRawMode = vi.fn();
const refreshStatic = vi.fn();
const setForceRerenderKey = vi.fn();
const enableSupportedModes =
terminalCapabilityManager.enableSupportedModes as unknown as Mock;
const { result, unmount } = renderHook(() =>
useSuspend({
handleWarning,
setRawMode,
refreshStatic,
setForceRerenderKey,
shouldUseAlternateScreen: true,
}),
);
act(() => {
result.current.handleSuspend();
});
expect(handleWarning).toHaveBeenCalledWith(
'Press Ctrl+Z again to suspend. Undo has moved to Cmd + Z or Alt/Opt + Z.',
);
act(() => {
result.current.handleSuspend();
});
expect(exitAlternateScreen).toHaveBeenCalledTimes(1);
expect(enableLineWrapping).toHaveBeenCalledTimes(1);
expect(writeToStdout).toHaveBeenCalledWith('\x1b[2J\x1b[H');
expect(disableMouseEvents).toHaveBeenCalledTimes(1);
expect(cleanupTerminalOnExit).toHaveBeenCalledTimes(1);
expect(setRawMode).toHaveBeenCalledWith(false);
expect(killSpy).toHaveBeenCalledWith(0, 'SIGTSTP');
act(() => {
process.emit('SIGCONT');
vi.runAllTimers();
});
expect(enterAlternateScreen).toHaveBeenCalledTimes(1);
expect(disableLineWrapping).toHaveBeenCalledTimes(1);
expect(enableSupportedModes).toHaveBeenCalledTimes(1);
expect(enableMouseEvents).toHaveBeenCalledTimes(1);
expect(setRawMode).toHaveBeenCalledWith(true);
expect(refreshStatic).toHaveBeenCalledTimes(1);
expect(setForceRerenderKey).toHaveBeenCalledTimes(1);
unmount();
});
it('does not toggle alternate screen or mouse restore when alternate screen mode is disabled', () => {
const handleWarning = vi.fn();
const setRawMode = vi.fn();
const refreshStatic = vi.fn();
const setForceRerenderKey = vi.fn();
const { result, unmount } = renderHook(() =>
useSuspend({
handleWarning,
setRawMode,
refreshStatic,
setForceRerenderKey,
shouldUseAlternateScreen: false,
}),
);
act(() => {
result.current.handleSuspend();
result.current.handleSuspend();
process.emit('SIGCONT');
vi.runAllTimers();
});
expect(exitAlternateScreen).not.toHaveBeenCalled();
expect(enterAlternateScreen).not.toHaveBeenCalled();
expect(enableLineWrapping).not.toHaveBeenCalled();
expect(disableLineWrapping).not.toHaveBeenCalled();
expect(enableMouseEvents).not.toHaveBeenCalled();
unmount();
});
it('warns and skips suspension on windows', () => {
setPlatform('win32');
const handleWarning = vi.fn();
const setRawMode = vi.fn();
const refreshStatic = vi.fn();
const setForceRerenderKey = vi.fn();
const { result, unmount } = renderHook(() =>
useSuspend({
handleWarning,
setRawMode,
refreshStatic,
setForceRerenderKey,
shouldUseAlternateScreen: true,
}),
);
act(() => {
result.current.handleSuspend();
});
handleWarning.mockClear();
act(() => {
result.current.handleSuspend();
});
expect(handleWarning).toHaveBeenCalledWith(
'Ctrl+Z suspend is not supported on Windows.',
);
expect(killSpy).not.toHaveBeenCalled();
expect(cleanupTerminalOnExit).not.toHaveBeenCalled();
unmount();
});
});
+155
View File
@@ -0,0 +1,155 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useRef, useEffect, useCallback } from 'react';
import {
writeToStdout,
disableMouseEvents,
enableMouseEvents,
enterAlternateScreen,
exitAlternateScreen,
enableLineWrapping,
disableLineWrapping,
} from '@google/gemini-cli-core';
import process from 'node:process';
import {
cleanupTerminalOnExit,
terminalCapabilityManager,
} from '../utils/terminalCapabilityManager.js';
import { WARNING_PROMPT_DURATION_MS } from '../constants.js';
interface UseSuspendProps {
handleWarning: (message: string) => void;
setRawMode: (mode: boolean) => void;
refreshStatic: () => void;
setForceRerenderKey: (updater: (prev: number) => number) => void;
shouldUseAlternateScreen: boolean;
}
export function useSuspend({
handleWarning,
setRawMode,
refreshStatic,
setForceRerenderKey,
shouldUseAlternateScreen,
}: UseSuspendProps) {
const [ctrlZPressCount, setCtrlZPressCount] = useState(0);
const ctrlZTimerRef = useRef<NodeJS.Timeout | null>(null);
const onResumeHandlerRef = useRef<(() => void) | null>(null);
useEffect(
() => () => {
if (ctrlZTimerRef.current) {
clearTimeout(ctrlZTimerRef.current);
ctrlZTimerRef.current = null;
}
if (onResumeHandlerRef.current) {
process.off('SIGCONT', onResumeHandlerRef.current);
onResumeHandlerRef.current = null;
}
},
[],
);
useEffect(() => {
if (ctrlZTimerRef.current) {
clearTimeout(ctrlZTimerRef.current);
ctrlZTimerRef.current = null;
}
if (ctrlZPressCount > 1) {
setCtrlZPressCount(0);
if (process.platform === 'win32') {
handleWarning('Ctrl+Z suspend is not supported on Windows.');
return;
}
if (shouldUseAlternateScreen) {
// Leave alternate buffer before suspension so the shell stays usable.
exitAlternateScreen();
enableLineWrapping();
writeToStdout('\x1b[2J\x1b[H');
}
// Cleanup before suspend.
writeToStdout('\x1b[?25h'); // Show cursor
disableMouseEvents();
cleanupTerminalOnExit();
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
setRawMode(false);
const onResume = () => {
try {
// Restore terminal state.
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.ref();
}
setRawMode(true);
if (shouldUseAlternateScreen) {
enterAlternateScreen();
disableLineWrapping();
writeToStdout('\x1b[2J\x1b[H');
}
terminalCapabilityManager.enableSupportedModes();
writeToStdout('\x1b[?25l'); // Hide cursor
if (shouldUseAlternateScreen) {
enableMouseEvents();
}
// Force Ink to do a complete repaint by:
// 1. Emitting a resize event (tricks Ink into full redraw)
// 2. Remounting components via state changes
process.stdout.emit('resize');
// Give a tick for resize to process, then trigger remount
setImmediate(() => {
refreshStatic();
setForceRerenderKey((prev) => prev + 1);
});
} finally {
if (onResumeHandlerRef.current === onResume) {
onResumeHandlerRef.current = null;
}
}
};
if (onResumeHandlerRef.current) {
process.off('SIGCONT', onResumeHandlerRef.current);
}
onResumeHandlerRef.current = onResume;
process.once('SIGCONT', onResume);
process.kill(0, 'SIGTSTP');
} else if (ctrlZPressCount > 0) {
handleWarning(
'Press Ctrl+Z again to suspend. Undo has moved to Cmd + Z or Alt/Opt + Z.',
);
ctrlZTimerRef.current = setTimeout(() => {
setCtrlZPressCount(0);
ctrlZTimerRef.current = null;
}, WARNING_PROMPT_DURATION_MS);
}
}, [
ctrlZPressCount,
handleWarning,
setRawMode,
refreshStatic,
setForceRerenderKey,
shouldUseAlternateScreen,
]);
const handleSuspend = useCallback(() => {
setCtrlZPressCount((prev) => prev + 1);
}, []);
return { handleSuspend };
}
@@ -15,6 +15,7 @@ const mockWrite = vi.fn();
const mockSubscribe = vi.fn();
const mockUnsubscribe = vi.fn();
const mockHandleThemeSelect = vi.fn();
const mockQueryTerminalBackground = vi.fn();
vi.mock('ink', async () => ({
useStdout: () => ({
@@ -28,6 +29,7 @@ vi.mock('../contexts/TerminalContext.js', () => ({
useTerminalContext: () => ({
subscribe: mockSubscribe,
unsubscribe: mockUnsubscribe,
queryTerminalBackground: mockQueryTerminalBackground,
}),
}));
@@ -52,6 +54,7 @@ vi.mock('../themes/theme-manager.js', async () => {
themeManager: {
isDefaultTheme: (name: string) =>
name === 'default' || name === 'default-light',
setTerminalBackground: vi.fn(),
},
DEFAULT_THEME: { name: 'default' },
};
@@ -78,6 +81,7 @@ describe('useTerminalTheme', () => {
mockSubscribe.mockClear();
mockUnsubscribe.mockClear();
mockHandleThemeSelect.mockClear();
mockQueryTerminalBackground.mockClear();
// Reset any settings modifications
mockSettings.merged.ui.autoThemeSwitching = true;
mockSettings.merged.ui.theme = 'default';
@@ -89,37 +93,37 @@ describe('useTerminalTheme', () => {
});
it('should subscribe to terminal background events on mount', () => {
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config));
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config, vi.fn()));
expect(mockSubscribe).toHaveBeenCalled();
});
it('should unsubscribe on unmount', () => {
const { unmount } = renderHook(() =>
useTerminalTheme(mockHandleThemeSelect, config),
useTerminalTheme(mockHandleThemeSelect, config, vi.fn()),
);
unmount();
expect(mockUnsubscribe).toHaveBeenCalled();
});
it('should poll for terminal background', () => {
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config));
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config, vi.fn()));
// Fast-forward time (1 minute)
vi.advanceTimersByTime(60000);
expect(mockWrite).toHaveBeenCalledWith('\x1b]11;?\x1b\\');
expect(mockQueryTerminalBackground).toHaveBeenCalled();
});
it('should not poll if terminal background is undefined at startup', () => {
config.getTerminalBackground = vi.fn().mockReturnValue(undefined);
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config));
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config, vi.fn()));
// Poll should not happen
vi.advanceTimersByTime(60000);
expect(mockWrite).not.toHaveBeenCalled();
expect(mockQueryTerminalBackground).not.toHaveBeenCalled();
});
it('should switch to light theme when background is light', () => {
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config));
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config, vi.fn()));
const handler = mockSubscribe.mock.calls[0][0];
@@ -137,7 +141,7 @@ describe('useTerminalTheme', () => {
// Start with light theme
mockSettings.merged.ui.theme = 'default-light';
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config));
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config, vi.fn()));
const handler = mockSubscribe.mock.calls[0][0];
@@ -156,11 +160,11 @@ describe('useTerminalTheme', () => {
it('should not switch theme if autoThemeSwitching is disabled', () => {
mockSettings.merged.ui.autoThemeSwitching = false;
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config));
renderHook(() => useTerminalTheme(mockHandleThemeSelect, config, vi.fn()));
// Poll should not happen
vi.advanceTimersByTime(60000);
expect(mockWrite).not.toHaveBeenCalled();
expect(mockQueryTerminalBackground).not.toHaveBeenCalled();
mockSettings.merged.ui.autoThemeSwitching = true;
});
@@ -5,7 +5,6 @@
*/
import { useEffect } from 'react';
import { useStdout } from 'ink';
import {
getLuminance,
parseColor,
@@ -22,10 +21,11 @@ import type { UIActions } from '../contexts/UIActionsContext.js';
export function useTerminalTheme(
handleThemeSelect: UIActions['handleThemeSelect'],
config: Config,
refreshStatic: () => void,
) {
const { stdout } = useStdout();
const settings = useSettings();
const { subscribe, unsubscribe } = useTerminalContext();
const { subscribe, unsubscribe, queryTerminalBackground } =
useTerminalContext();
useEffect(() => {
if (settings.merged.ui.autoThemeSwitching === false) {
@@ -44,7 +44,7 @@ export function useTerminalTheme(
return;
}
stdout.write('\x1b]11;?\x1b\\');
void queryTerminalBackground();
}, settings.merged.ui.terminalBackgroundPollingInterval * 1000);
const handleTerminalBackground = (colorStr: string) => {
@@ -58,6 +58,8 @@ export function useTerminalTheme(
const hexColor = parseColor(match[1], match[2], match[3]);
const luminance = getLuminance(hexColor);
config.setTerminalBackground(hexColor);
themeManager.setTerminalBackground(hexColor);
refreshStatic();
const currentThemeName = settings.merged.ui.theme;
@@ -69,7 +71,7 @@ export function useTerminalTheme(
);
if (newTheme) {
handleThemeSelect(newTheme, SettingScope.User);
void handleThemeSelect(newTheme, SettingScope.User);
}
};
@@ -83,10 +85,11 @@ export function useTerminalTheme(
settings.merged.ui.theme,
settings.merged.ui.autoThemeSwitching,
settings.merged.ui.terminalBackgroundPollingInterval,
stdout,
config,
handleThemeSelect,
subscribe,
unsubscribe,
queryTerminalBackground,
refreshStatic,
]);
}
+15 -4
View File
@@ -13,12 +13,16 @@ import type {
import { MessageType } from '../types.js';
import process from 'node:process';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { useTerminalContext } from '../contexts/TerminalContext.js';
interface UseThemeCommandReturn {
isThemeDialogOpen: boolean;
openThemeDialog: () => void;
closeThemeDialog: () => void;
handleThemeSelect: (themeName: string, scope: LoadableSettingScope) => void;
handleThemeSelect: (
themeName: string,
scope: LoadableSettingScope,
) => Promise<void>;
handleThemeHighlight: (themeName: string | undefined) => void;
}
@@ -30,8 +34,9 @@ export const useThemeCommand = (
): UseThemeCommandReturn => {
const [isThemeDialogOpen, setIsThemeDialogOpen] =
useState(!!initialThemeError);
const { queryTerminalBackground } = useTerminalContext();
const openThemeDialog = useCallback(() => {
const openThemeDialog = useCallback(async () => {
if (process.env['NO_COLOR']) {
addItem(
{
@@ -42,8 +47,14 @@ export const useThemeCommand = (
);
return;
}
// Ensure we have an up to date terminal background color when opening the
// theme dialog as the user may have just changed it before opening the
// dialog.
await queryTerminalBackground();
setIsThemeDialogOpen(true);
}, [addItem]);
}, [addItem, queryTerminalBackground]);
const applyTheme = useCallback(
(themeName: string | undefined) => {
@@ -72,7 +83,7 @@ export const useThemeCommand = (
}, [applyTheme, loadedSettings]);
const handleThemeSelect = useCallback(
(themeName: string, scope: LoadableSettingScope) => {
async (themeName: string, scope: LoadableSettingScope) => {
try {
const mergedCustomThemes = {
...(loadedSettings.user.settings.ui?.customThemes || {}),
@@ -1,525 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
import { useToolExecutionScheduler } from './useToolExecutionScheduler.js';
import {
MessageBusType,
ToolConfirmationOutcome,
Scheduler,
type Config,
type MessageBus,
type CompletedToolCall,
type ToolCallConfirmationDetails,
type ToolCallsUpdateMessage,
type AnyDeclarativeTool,
type AnyToolInvocation,
ROOT_SCHEDULER_ID,
} from '@google/gemini-cli-core';
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
// Mock Core Scheduler
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
Scheduler: vi.fn().mockImplementation(() => ({
schedule: vi.fn().mockResolvedValue([]),
cancelAll: vi.fn(),
})),
};
});
const createMockTool = (
overrides: Partial<AnyDeclarativeTool> = {},
): AnyDeclarativeTool =>
({
name: 'test_tool',
displayName: 'Test Tool',
description: 'A test tool',
kind: 'function',
parameterSchema: {},
isOutputMarkdown: false,
build: vi.fn(),
...overrides,
}) as AnyDeclarativeTool;
const createMockInvocation = (
overrides: Partial<AnyToolInvocation> = {},
): AnyToolInvocation =>
({
getDescription: () => 'Executing test tool',
shouldConfirmExecute: vi.fn(),
execute: vi.fn(),
params: {},
toolLocations: [],
...overrides,
}) as AnyToolInvocation;
describe('useToolExecutionScheduler', () => {
let mockConfig: Config;
let mockMessageBus: MessageBus;
beforeEach(() => {
vi.clearAllMocks();
mockMessageBus = createMockMessageBus() as unknown as MessageBus;
mockConfig = {
getMessageBus: () => mockMessageBus,
} as unknown as Config;
});
afterEach(() => {
vi.clearAllMocks();
});
it('initializes with empty tool calls', () => {
const { result } = renderHook(() =>
useToolExecutionScheduler(
vi.fn().mockResolvedValue(undefined),
mockConfig,
() => undefined,
),
);
const [toolCalls] = result.current;
expect(toolCalls).toEqual([]);
});
it('updates tool calls when MessageBus emits TOOL_CALLS_UPDATE', () => {
const { result } = renderHook(() =>
useToolExecutionScheduler(
vi.fn().mockResolvedValue(undefined),
mockConfig,
() => undefined,
),
);
const mockToolCall = {
status: 'executing' as const,
request: {
callId: 'call-1',
name: 'test_tool',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
tool: createMockTool(),
invocation: createMockInvocation(),
liveOutput: 'Loading...',
};
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [mockToolCall],
schedulerId: ROOT_SCHEDULER_ID,
} as ToolCallsUpdateMessage);
});
const [toolCalls] = result.current;
expect(toolCalls).toHaveLength(1);
// Expect Core Object structure, not Display Object
expect(toolCalls[0]).toMatchObject({
request: { callId: 'call-1', name: 'test_tool' },
status: 'executing', // Core status
liveOutput: 'Loading...',
responseSubmittedToGemini: false,
});
});
it('injects onConfirm callback for awaiting_approval tools (Adapter Pattern)', async () => {
const { result } = renderHook(() =>
useToolExecutionScheduler(
vi.fn().mockResolvedValue(undefined),
mockConfig,
() => undefined,
),
);
const mockToolCall = {
status: 'awaiting_approval' as const,
request: {
callId: 'call-1',
name: 'test_tool',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
tool: createMockTool(),
invocation: createMockInvocation({
getDescription: () => 'Confirming test tool',
}),
confirmationDetails: { type: 'info', title: 'Confirm', prompt: 'Sure?' },
correlationId: 'corr-123',
};
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [mockToolCall],
schedulerId: ROOT_SCHEDULER_ID,
} as ToolCallsUpdateMessage);
});
const [toolCalls] = result.current;
const call = toolCalls[0];
if (call.status !== 'awaiting_approval') {
throw new Error('Expected status to be awaiting_approval');
}
const confirmationDetails =
call.confirmationDetails as ToolCallConfirmationDetails;
expect(confirmationDetails).toBeDefined();
expect(typeof confirmationDetails.onConfirm).toBe('function');
// Test that onConfirm publishes to MessageBus
const publishSpy = vi.spyOn(mockMessageBus, 'publish');
await confirmationDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce);
expect(publishSpy).toHaveBeenCalledWith({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-123',
confirmed: true,
requiresUserConfirmation: false,
outcome: ToolConfirmationOutcome.ProceedOnce,
payload: undefined,
});
});
it('injects onConfirm with payload (Inline Edit support)', async () => {
const { result } = renderHook(() =>
useToolExecutionScheduler(
vi.fn().mockResolvedValue(undefined),
mockConfig,
() => undefined,
),
);
const mockToolCall = {
status: 'awaiting_approval' as const,
request: {
callId: 'call-1',
name: 'test_tool',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
tool: createMockTool(),
invocation: createMockInvocation(),
confirmationDetails: { type: 'edit', title: 'Edit', filePath: 'test.ts' },
correlationId: 'corr-edit',
};
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [mockToolCall],
schedulerId: ROOT_SCHEDULER_ID,
} as ToolCallsUpdateMessage);
});
const [toolCalls] = result.current;
const call = toolCalls[0];
if (call.status !== 'awaiting_approval') {
throw new Error('Expected awaiting_approval');
}
const confirmationDetails =
call.confirmationDetails as ToolCallConfirmationDetails;
const publishSpy = vi.spyOn(mockMessageBus, 'publish');
const mockPayload = { newContent: 'updated code' };
await confirmationDetails.onConfirm(
ToolConfirmationOutcome.ProceedOnce,
mockPayload,
);
expect(publishSpy).toHaveBeenCalledWith({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-edit',
confirmed: true,
requiresUserConfirmation: false,
outcome: ToolConfirmationOutcome.ProceedOnce,
payload: mockPayload,
});
});
it('preserves responseSubmittedToGemini flag across updates', () => {
const { result } = renderHook(() =>
useToolExecutionScheduler(
vi.fn().mockResolvedValue(undefined),
mockConfig,
() => undefined,
),
);
const mockToolCall = {
status: 'success' as const,
request: {
callId: 'call-1',
name: 'test',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
tool: createMockTool(),
invocation: createMockInvocation(),
response: {
callId: 'call-1',
resultDisplay: 'OK',
responseParts: [],
error: undefined,
errorType: undefined,
},
};
// 1. Initial success
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [mockToolCall],
schedulerId: ROOT_SCHEDULER_ID,
} as ToolCallsUpdateMessage);
});
// 2. Mark as submitted
act(() => {
const [, , markAsSubmitted] = result.current;
markAsSubmitted(['call-1']);
});
expect(result.current[0][0].responseSubmittedToGemini).toBe(true);
// 3. Receive another update (should preserve the true flag)
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [mockToolCall],
schedulerId: ROOT_SCHEDULER_ID,
} as ToolCallsUpdateMessage);
});
expect(result.current[0][0].responseSubmittedToGemini).toBe(true);
});
it('updates lastToolOutputTime when tools are executing', () => {
vi.useFakeTimers();
const { result } = renderHook(() =>
useToolExecutionScheduler(
vi.fn().mockResolvedValue(undefined),
mockConfig,
() => undefined,
),
);
const startTime = Date.now();
vi.advanceTimersByTime(1000);
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [
{
status: 'executing' as const,
request: {
callId: 'call-1',
name: 'test',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
tool: createMockTool(),
invocation: createMockInvocation(),
},
],
schedulerId: ROOT_SCHEDULER_ID,
} as ToolCallsUpdateMessage);
});
const [, , , , , lastOutputTime] = result.current;
expect(lastOutputTime).toBeGreaterThan(startTime);
vi.useRealTimers();
});
it('delegates cancelAll to the Core Scheduler', () => {
const { result } = renderHook(() =>
useToolExecutionScheduler(
vi.fn().mockResolvedValue(undefined),
mockConfig,
() => undefined,
),
);
const [, , , , cancelAll] = result.current;
const signal = new AbortController().signal;
// We need to find the mock instance of Scheduler
// Since we used vi.mock at top level, we can get it from vi.mocked(Scheduler)
const schedulerInstance = vi.mocked(Scheduler).mock.results[0].value;
cancelAll(signal);
expect(schedulerInstance.cancelAll).toHaveBeenCalled();
});
it('resolves the schedule promise when scheduler resolves', async () => {
const onComplete = vi.fn().mockResolvedValue(undefined);
const completedToolCall = {
status: 'success' as const,
request: {
callId: 'call-1',
name: 'test',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
tool: createMockTool(),
invocation: createMockInvocation(),
response: {
callId: 'call-1',
responseParts: [],
resultDisplay: 'Success',
error: undefined,
errorType: undefined,
},
};
// Mock the specific return value for this test
const { Scheduler } = await import('@google/gemini-cli-core');
vi.mocked(Scheduler).mockImplementation(
() =>
({
schedule: vi.fn().mockResolvedValue([completedToolCall]),
cancelAll: vi.fn(),
}) as unknown as Scheduler,
);
const { result } = renderHook(() =>
useToolExecutionScheduler(onComplete, mockConfig, () => undefined),
);
const [, schedule] = result.current;
const signal = new AbortController().signal;
let completedResult: CompletedToolCall[] = [];
await act(async () => {
completedResult = await schedule(
{
callId: 'call-1',
name: 'test',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
signal,
);
});
expect(completedResult).toEqual([completedToolCall]);
expect(onComplete).toHaveBeenCalledWith([completedToolCall]);
});
it('setToolCallsForDisplay re-groups tools by schedulerId (Multi-Scheduler support)', () => {
const { result } = renderHook(() =>
useToolExecutionScheduler(
vi.fn().mockResolvedValue(undefined),
mockConfig,
() => undefined,
),
);
const callRoot = {
status: 'success' as const,
request: {
callId: 'call-root',
name: 'test',
args: {},
isClientInitiated: false,
prompt_id: 'p1',
},
tool: createMockTool(),
invocation: createMockInvocation(),
response: {
callId: 'call-root',
responseParts: [],
resultDisplay: 'OK',
error: undefined,
errorType: undefined,
},
schedulerId: ROOT_SCHEDULER_ID,
};
const callSub = {
...callRoot,
request: { ...callRoot.request, callId: 'call-sub' },
schedulerId: 'subagent-1',
};
// 1. Populate state with multiple schedulers
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [callRoot],
schedulerId: ROOT_SCHEDULER_ID,
} as ToolCallsUpdateMessage);
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [callSub],
schedulerId: 'subagent-1',
} as ToolCallsUpdateMessage);
});
let [toolCalls] = result.current;
expect(toolCalls).toHaveLength(2);
expect(
toolCalls.find((t) => t.request.callId === 'call-root')?.schedulerId,
).toBe(ROOT_SCHEDULER_ID);
expect(
toolCalls.find((t) => t.request.callId === 'call-sub')?.schedulerId,
).toBe('subagent-1');
// 2. Call setToolCallsForDisplay (e.g., simulate a manual update or clear)
act(() => {
const [, , , setToolCalls] = result.current;
setToolCalls((prev) =>
prev.map((t) => ({ ...t, responseSubmittedToGemini: true })),
);
});
// 3. Verify that tools are still present and maintain their scheduler IDs
// The internal map should have been re-grouped.
[toolCalls] = result.current;
expect(toolCalls).toHaveLength(2);
expect(toolCalls.every((t) => t.responseSubmittedToGemini)).toBe(true);
const updatedRoot = toolCalls.find((t) => t.request.callId === 'call-root');
const updatedSub = toolCalls.find((t) => t.request.callId === 'call-sub');
expect(updatedRoot?.schedulerId).toBe(ROOT_SCHEDULER_ID);
expect(updatedSub?.schedulerId).toBe('subagent-1');
// 4. Verify that a subsequent update to ONE scheduler doesn't wipe the other
act(() => {
void mockMessageBus.publish({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [{ ...callRoot, status: 'executing' }],
schedulerId: ROOT_SCHEDULER_ID,
} as ToolCallsUpdateMessage);
});
[toolCalls] = result.current;
expect(toolCalls).toHaveLength(2);
expect(
toolCalls.find((t) => t.request.callId === 'call-root')?.status,
).toBe('executing');
expect(
toolCalls.find((t) => t.request.callId === 'call-sub')?.schedulerId,
).toBe('subagent-1');
});
});
@@ -1,253 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
type Config,
type MessageBus,
type ToolCallRequestInfo,
type ToolCall,
type CompletedToolCall,
type ToolConfirmationPayload,
MessageBusType,
ToolConfirmationOutcome,
Scheduler,
type EditorType,
type ToolCallsUpdateMessage,
ROOT_SCHEDULER_ID,
} from '@google/gemini-cli-core';
import { useCallback, useState, useMemo, useEffect, useRef } from 'react';
// Re-exporting types compatible with legacy hook expectations
export type ScheduleFn = (
request: ToolCallRequestInfo | ToolCallRequestInfo[],
signal: AbortSignal,
) => Promise<CompletedToolCall[]>;
export type MarkToolsAsSubmittedFn = (callIds: string[]) => void;
export type CancelAllFn = (signal: AbortSignal) => void;
/**
* The shape expected by useGeminiStream.
* It matches the Core ToolCall structure + the UI metadata flag.
*/
export type TrackedToolCall = ToolCall & {
responseSubmittedToGemini?: boolean;
};
/**
* Modern tool scheduler hook using the event-driven Core Scheduler.
*
* This hook acts as an Adapter between the new MessageBus-driven Core
* and the legacy callback-based UI components.
*/
export function useToolExecutionScheduler(
onComplete: (tools: CompletedToolCall[]) => Promise<void>,
config: Config,
getPreferredEditor: () => EditorType | undefined,
): [
TrackedToolCall[],
ScheduleFn,
MarkToolsAsSubmittedFn,
React.Dispatch<React.SetStateAction<TrackedToolCall[]>>,
CancelAllFn,
number,
] {
// State stores tool calls organized by their originating schedulerId
const [toolCallsMap, setToolCallsMap] = useState<
Record<string, TrackedToolCall[]>
>({});
const [lastToolOutputTime, setLastToolOutputTime] = useState<number>(0);
const messageBus = useMemo(() => config.getMessageBus(), [config]);
const onCompleteRef = useRef(onComplete);
useEffect(() => {
onCompleteRef.current = onComplete;
}, [onComplete]);
const getPreferredEditorRef = useRef(getPreferredEditor);
useEffect(() => {
getPreferredEditorRef.current = getPreferredEditor;
}, [getPreferredEditor]);
const scheduler = useMemo(
() =>
new Scheduler({
config,
messageBus,
getPreferredEditor: () => getPreferredEditorRef.current(),
schedulerId: ROOT_SCHEDULER_ID,
}),
[config, messageBus],
);
const internalAdaptToolCalls = useCallback(
(coreCalls: ToolCall[], prevTracked: TrackedToolCall[]) =>
adaptToolCalls(coreCalls, prevTracked, messageBus),
[messageBus],
);
useEffect(() => {
const handler = (event: ToolCallsUpdateMessage) => {
// Update output timer for UI spinners (Side Effect)
if (event.toolCalls.some((tc) => tc.status === 'executing')) {
setLastToolOutputTime(Date.now());
}
setToolCallsMap((prev) => {
const adapted = internalAdaptToolCalls(
event.toolCalls,
prev[event.schedulerId] ?? [],
);
return {
...prev,
[event.schedulerId]: adapted,
};
});
};
messageBus.subscribe(MessageBusType.TOOL_CALLS_UPDATE, handler);
return () => {
messageBus.unsubscribe(MessageBusType.TOOL_CALLS_UPDATE, handler);
};
}, [messageBus, internalAdaptToolCalls]);
const schedule: ScheduleFn = useCallback(
async (request, signal) => {
// Clear state for new run
setToolCallsMap({});
// 1. Await Core Scheduler directly
const results = await scheduler.schedule(request, signal);
// 2. Trigger legacy reinjection logic (useGeminiStream loop)
// Since this hook instance owns the "root" scheduler, we always trigger
// onComplete when it finishes its batch.
await onCompleteRef.current(results);
return results;
},
[scheduler],
);
const cancelAll: CancelAllFn = useCallback(
(_signal) => {
scheduler.cancelAll();
},
[scheduler],
);
const markToolsAsSubmitted: MarkToolsAsSubmittedFn = useCallback(
(callIdsToMark: string[]) => {
setToolCallsMap((prevMap) => {
const nextMap = { ...prevMap };
for (const [sid, calls] of Object.entries(nextMap)) {
nextMap[sid] = calls.map((tc) =>
callIdsToMark.includes(tc.request.callId)
? { ...tc, responseSubmittedToGemini: true }
: tc,
);
}
return nextMap;
});
},
[],
);
// Flatten the map for the UI components that expect a single list of tools.
const toolCalls = useMemo(
() => Object.values(toolCallsMap).flat(),
[toolCallsMap],
);
// Provide a setter that maintains compatibility with legacy [].
const setToolCallsForDisplay = useCallback(
(action: React.SetStateAction<TrackedToolCall[]>) => {
setToolCallsMap((prev) => {
const currentFlattened = Object.values(prev).flat();
const nextFlattened =
typeof action === 'function' ? action(currentFlattened) : action;
if (nextFlattened.length === 0) {
return {};
}
// Re-group by schedulerId to preserve multi-scheduler state
const nextMap: Record<string, TrackedToolCall[]> = {};
for (const call of nextFlattened) {
// All tool calls should have a schedulerId from the core.
// Default to ROOT_SCHEDULER_ID as a safeguard.
const sid = call.schedulerId ?? ROOT_SCHEDULER_ID;
if (!nextMap[sid]) {
nextMap[sid] = [];
}
nextMap[sid].push(call);
}
return nextMap;
});
},
[],
);
return [
toolCalls,
schedule,
markToolsAsSubmitted,
setToolCallsForDisplay,
cancelAll,
lastToolOutputTime,
];
}
/**
* ADAPTER: Merges UI metadata (submitted flag) and injects legacy callbacks.
*/
function adaptToolCalls(
coreCalls: ToolCall[],
prevTracked: TrackedToolCall[],
messageBus: MessageBus,
): TrackedToolCall[] {
const prevMap = new Map(prevTracked.map((t) => [t.request.callId, t]));
return coreCalls.map((coreCall): TrackedToolCall => {
const prev = prevMap.get(coreCall.request.callId);
const responseSubmittedToGemini = prev?.responseSubmittedToGemini ?? false;
// Inject onConfirm adapter for tools awaiting approval.
// The Core provides data-only (serializable) confirmationDetails. We must
// inject the legacy callback function that proxies responses back to the
// MessageBus.
if (coreCall.status === 'awaiting_approval' && coreCall.correlationId) {
const correlationId = coreCall.correlationId;
return {
...coreCall,
confirmationDetails: {
...coreCall.confirmationDetails,
onConfirm: async (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => {
await messageBus.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId,
confirmed: outcome !== ToolConfirmationOutcome.Cancel,
requiresUserConfirmation: false,
outcome,
payload,
});
},
},
responseSubmittedToGemini,
};
}
return {
...coreCall,
responseSubmittedToGemini,
};
});
}
File diff suppressed because it is too large Load Diff
+222 -48
View File
@@ -4,67 +4,241 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Config,
EditorType,
CompletedToolCall,
ToolCallRequestInfo,
import {
type Config,
type ToolCallRequestInfo,
type ToolCall,
type CompletedToolCall,
MessageBusType,
ROOT_SCHEDULER_ID,
Scheduler,
type EditorType,
type ToolCallsUpdateMessage,
} from '@google/gemini-cli-core';
import {
type TrackedScheduledToolCall,
type TrackedValidatingToolCall,
type TrackedWaitingToolCall,
type TrackedExecutingToolCall,
type TrackedCompletedToolCall,
type TrackedCancelledToolCall,
type MarkToolsAsSubmittedFn,
type CancelAllFn,
} from './useReactToolScheduler.js';
import {
useToolExecutionScheduler,
type TrackedToolCall,
} from './useToolExecutionScheduler.js';
import { useCallback, useState, useMemo, useEffect, useRef } from 'react';
// Re-export specific state types from Legacy, as the structures are compatible
// and useGeminiStream relies on them for narrowing.
export type {
TrackedToolCall,
TrackedScheduledToolCall,
TrackedValidatingToolCall,
TrackedWaitingToolCall,
TrackedExecutingToolCall,
TrackedCompletedToolCall,
TrackedCancelledToolCall,
MarkToolsAsSubmittedFn,
CancelAllFn,
};
// Unified Schedule function (Promise<void> | Promise<CompletedToolCall[]>)
// Re-exporting types compatible with hook expectations
export type ScheduleFn = (
request: ToolCallRequestInfo | ToolCallRequestInfo[],
signal: AbortSignal,
) => Promise<void | CompletedToolCall[]>;
) => Promise<CompletedToolCall[]>;
export type UseToolSchedulerReturn = [
export type MarkToolsAsSubmittedFn = (callIds: string[]) => void;
export type CancelAllFn = (signal: AbortSignal) => void;
/**
* The shape expected by useGeminiStream.
* It matches the Core ToolCall structure + the UI metadata flag.
*/
export type TrackedToolCall = ToolCall & {
responseSubmittedToGemini?: boolean;
};
// Narrowed types for specific statuses (used by useGeminiStream)
export type TrackedScheduledToolCall = Extract<
TrackedToolCall,
{ status: 'scheduled' }
>;
export type TrackedValidatingToolCall = Extract<
TrackedToolCall,
{ status: 'validating' }
>;
export type TrackedWaitingToolCall = Extract<
TrackedToolCall,
{ status: 'awaiting_approval' }
>;
export type TrackedExecutingToolCall = Extract<
TrackedToolCall,
{ status: 'executing' }
>;
export type TrackedCompletedToolCall = Extract<
TrackedToolCall,
{ status: 'success' | 'error' }
>;
export type TrackedCancelledToolCall = Extract<
TrackedToolCall,
{ status: 'cancelled' }
>;
/**
* Modern tool scheduler hook using the event-driven Core Scheduler.
*/
export function useToolScheduler(
onComplete: (tools: CompletedToolCall[]) => Promise<void>,
config: Config,
getPreferredEditor: () => EditorType | undefined,
): [
TrackedToolCall[],
ScheduleFn,
MarkToolsAsSubmittedFn,
React.Dispatch<React.SetStateAction<TrackedToolCall[]>>,
CancelAllFn,
number,
];
] {
// State stores tool calls organized by their originating schedulerId
const [toolCallsMap, setToolCallsMap] = useState<
Record<string, TrackedToolCall[]>
>({});
const [lastToolOutputTime, setLastToolOutputTime] = useState<number>(0);
const messageBus = useMemo(() => config.getMessageBus(), [config]);
const onCompleteRef = useRef(onComplete);
useEffect(() => {
onCompleteRef.current = onComplete;
}, [onComplete]);
const getPreferredEditorRef = useRef(getPreferredEditor);
useEffect(() => {
getPreferredEditorRef.current = getPreferredEditor;
}, [getPreferredEditor]);
const scheduler = useMemo(
() =>
new Scheduler({
config,
messageBus,
getPreferredEditor: () => getPreferredEditorRef.current(),
schedulerId: ROOT_SCHEDULER_ID,
}),
[config, messageBus],
);
const internalAdaptToolCalls = useCallback(
(coreCalls: ToolCall[], prevTracked: TrackedToolCall[]) =>
adaptToolCalls(coreCalls, prevTracked),
[],
);
useEffect(() => {
const handler = (event: ToolCallsUpdateMessage) => {
// Update output timer for UI spinners (Side Effect)
if (event.toolCalls.some((tc) => tc.status === 'executing')) {
setLastToolOutputTime(Date.now());
}
setToolCallsMap((prev) => {
const adapted = internalAdaptToolCalls(
event.toolCalls,
prev[event.schedulerId] ?? [],
);
return {
...prev,
[event.schedulerId]: adapted,
};
});
};
messageBus.subscribe(MessageBusType.TOOL_CALLS_UPDATE, handler);
return () => {
messageBus.unsubscribe(MessageBusType.TOOL_CALLS_UPDATE, handler);
};
}, [messageBus, internalAdaptToolCalls]);
const schedule: ScheduleFn = useCallback(
async (request, signal) => {
// Clear state for new run
setToolCallsMap({});
// 1. Await Core Scheduler directly
const results = await scheduler.schedule(request, signal);
// 2. Trigger legacy reinjection logic (useGeminiStream loop)
// Since this hook instance owns the "root" scheduler, we always trigger
// onComplete when it finishes its batch.
await onCompleteRef.current(results);
return results;
},
[scheduler],
);
const cancelAll: CancelAllFn = useCallback(
(_signal) => {
scheduler.cancelAll();
},
[scheduler],
);
const markToolsAsSubmitted: MarkToolsAsSubmittedFn = useCallback(
(callIdsToMark: string[]) => {
setToolCallsMap((prevMap) => {
const nextMap = { ...prevMap };
for (const [sid, calls] of Object.entries(nextMap)) {
nextMap[sid] = calls.map((tc) =>
callIdsToMark.includes(tc.request.callId)
? { ...tc, responseSubmittedToGemini: true }
: tc,
);
}
return nextMap;
});
},
[],
);
// Flatten the map for the UI components that expect a single list of tools.
const toolCalls = useMemo(
() => Object.values(toolCallsMap).flat(),
[toolCallsMap],
);
// Provide a setter that maintains compatibility with legacy [].
const setToolCallsForDisplay = useCallback(
(action: React.SetStateAction<TrackedToolCall[]>) => {
setToolCallsMap((prev) => {
const currentFlattened = Object.values(prev).flat();
const nextFlattened =
typeof action === 'function' ? action(currentFlattened) : action;
if (nextFlattened.length === 0) {
return {};
}
// Re-group by schedulerId to preserve multi-scheduler state
const nextMap: Record<string, TrackedToolCall[]> = {};
for (const call of nextFlattened) {
// All tool calls should have a schedulerId from the core.
// Default to ROOT_SCHEDULER_ID as a safeguard.
const sid = call.schedulerId ?? ROOT_SCHEDULER_ID;
if (!nextMap[sid]) {
nextMap[sid] = [];
}
nextMap[sid].push(call);
}
return nextMap;
});
},
[],
);
return [
toolCalls,
schedule,
markToolsAsSubmitted,
setToolCallsForDisplay,
cancelAll,
lastToolOutputTime,
];
}
/**
* Hook that uses the Event-Driven scheduler for tool execution.
* ADAPTER: Merges UI metadata (submitted flag).
*/
export function useToolScheduler(
onComplete: (tools: CompletedToolCall[]) => Promise<void>,
config: Config,
getPreferredEditor: () => EditorType | undefined,
): UseToolSchedulerReturn {
return useToolExecutionScheduler(
onComplete,
config,
getPreferredEditor,
) as UseToolSchedulerReturn;
function adaptToolCalls(
coreCalls: ToolCall[],
prevTracked: TrackedToolCall[],
): TrackedToolCall[] {
const prevMap = new Map(prevTracked.map((t) => [t.request.callId, t]));
return coreCalls.map((coreCall): TrackedToolCall => {
const prev = prevMap.get(coreCall.request.callId);
const responseSubmittedToGemini = prev?.responseSubmittedToGemini ?? false;
return {
...coreCall,
responseSubmittedToGemini,
};
});
}
@@ -9,7 +9,7 @@ import { renderHook } from '../../test-utils/render.js';
import { useTurnActivityMonitor } from './useTurnActivityMonitor.js';
import { StreamingState } from '../types.js';
import { hasRedirection } from '@google/gemini-cli-core';
import { type TrackedToolCall } from './useReactToolScheduler.js';
import { type TrackedToolCall } from './useToolScheduler.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -7,7 +7,7 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { StreamingState } from '../types.js';
import { hasRedirection } from '@google/gemini-cli-core';
import { type TrackedToolCall } from './useReactToolScheduler.js';
import { type TrackedToolCall } from './useToolScheduler.js';
export interface TurnActivityStatus {
operationStartTime: number;
+4
View File
@@ -1708,6 +1708,7 @@ describe('useVim hook', () => {
cursorRow: 0,
cursorCol: 6,
actionType: 'vim_delete_to_end_of_line' as const,
count: 1,
expectedLines: ['hello '],
expectedCursorRow: 0,
expectedCursorCol: 6,
@@ -1719,6 +1720,7 @@ describe('useVim hook', () => {
cursorRow: 0,
cursorCol: 11,
actionType: 'vim_delete_to_end_of_line' as const,
count: 1,
expectedLines: ['hello world'],
expectedCursorRow: 0,
expectedCursorCol: 11,
@@ -1730,6 +1732,7 @@ describe('useVim hook', () => {
cursorRow: 0,
cursorCol: 6,
actionType: 'vim_change_to_end_of_line' as const,
count: 1,
expectedLines: ['hello '],
expectedCursorRow: 0,
expectedCursorCol: 6,
@@ -1741,6 +1744,7 @@ describe('useVim hook', () => {
cursorRow: 0,
cursorCol: 0,
actionType: 'vim_change_to_end_of_line' as const,
count: 1,
expectedLines: [''],
expectedCursorRow: 0,
expectedCursorCol: 0,
+325 -23
View File
@@ -44,19 +44,33 @@ const CMD_TYPES = {
UP: 'ck',
RIGHT: 'cl',
},
DELETE_MOVEMENT: {
LEFT: 'dh',
DOWN: 'dj',
UP: 'dk',
RIGHT: 'dl',
},
DELETE_TO_SOL: 'd0',
DELETE_TO_FIRST_NONWS: 'd^',
CHANGE_TO_SOL: 'c0',
CHANGE_TO_FIRST_NONWS: 'c^',
DELETE_TO_FIRST_LINE: 'dgg',
DELETE_TO_LAST_LINE: 'dG',
CHANGE_TO_FIRST_LINE: 'cgg',
CHANGE_TO_LAST_LINE: 'cG',
} as const;
// Helper function to clear pending state
const createClearPendingState = () => ({
count: 0,
pendingOperator: null as 'g' | 'd' | 'c' | null,
pendingOperator: null as 'g' | 'd' | 'c' | 'dg' | 'cg' | null,
});
// State and action types for useReducer
type VimState = {
mode: VimMode;
count: number;
pendingOperator: 'g' | 'd' | 'c' | null;
pendingOperator: 'g' | 'd' | 'c' | 'dg' | 'cg' | null;
lastCommand: { type: string; count: number } | null;
};
@@ -65,7 +79,10 @@ type VimAction =
| { type: 'SET_COUNT'; count: number }
| { type: 'INCREMENT_COUNT'; digit: number }
| { type: 'CLEAR_COUNT' }
| { type: 'SET_PENDING_OPERATOR'; operator: 'g' | 'd' | 'c' | null }
| {
type: 'SET_PENDING_OPERATOR';
operator: 'g' | 'd' | 'c' | 'dg' | 'cg' | null;
}
| {
type: 'SET_LAST_COMMAND';
command: { type: string; count: number } | null;
@@ -279,12 +296,73 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
}
case CMD_TYPES.DELETE_TO_EOL: {
buffer.vimDeleteToEndOfLine();
buffer.vimDeleteToEndOfLine(count);
break;
}
case CMD_TYPES.DELETE_TO_SOL: {
buffer.vimDeleteToStartOfLine();
break;
}
case CMD_TYPES.DELETE_MOVEMENT.LEFT:
case CMD_TYPES.DELETE_MOVEMENT.DOWN:
case CMD_TYPES.DELETE_MOVEMENT.UP:
case CMD_TYPES.DELETE_MOVEMENT.RIGHT: {
const movementMap: Record<string, 'h' | 'j' | 'k' | 'l'> = {
[CMD_TYPES.DELETE_MOVEMENT.LEFT]: 'h',
[CMD_TYPES.DELETE_MOVEMENT.DOWN]: 'j',
[CMD_TYPES.DELETE_MOVEMENT.UP]: 'k',
[CMD_TYPES.DELETE_MOVEMENT.RIGHT]: 'l',
};
const movementType = movementMap[cmdType];
if (movementType) {
buffer.vimChangeMovement(movementType, count);
}
break;
}
case CMD_TYPES.CHANGE_TO_EOL: {
buffer.vimChangeToEndOfLine();
buffer.vimChangeToEndOfLine(count);
updateMode('INSERT');
break;
}
case CMD_TYPES.DELETE_TO_FIRST_NONWS: {
buffer.vimDeleteToFirstNonWhitespace();
break;
}
case CMD_TYPES.CHANGE_TO_SOL: {
buffer.vimChangeToStartOfLine();
updateMode('INSERT');
break;
}
case CMD_TYPES.CHANGE_TO_FIRST_NONWS: {
buffer.vimChangeToFirstNonWhitespace();
updateMode('INSERT');
break;
}
case CMD_TYPES.DELETE_TO_FIRST_LINE: {
buffer.vimDeleteToFirstLine(count);
break;
}
case CMD_TYPES.DELETE_TO_LAST_LINE: {
buffer.vimDeleteToLastLine(count);
break;
}
case CMD_TYPES.CHANGE_TO_FIRST_LINE: {
buffer.vimDeleteToFirstLine(count);
updateMode('INSERT');
break;
}
case CMD_TYPES.CHANGE_TO_LAST_LINE: {
buffer.vimDeleteToLastLine(count);
updateMode('INSERT');
break;
}
@@ -324,6 +402,14 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
return false; // Let InputPrompt handle completion
}
// Let InputPrompt handle Ctrl+U (kill line left) and Ctrl+K (kill line right)
if (
normalizedKey.ctrl &&
(normalizedKey.name === 'u' || normalizedKey.name === 'k')
) {
return false;
}
// Let InputPrompt handle Ctrl+V for clipboard image pasting
if (normalizedKey.ctrl && normalizedKey.name === 'v') {
return false; // Let InputPrompt handle clipboard functionality
@@ -403,6 +489,37 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
[getCurrentCount, dispatch, buffer, updateMode],
);
/**
* Handles delete movement commands (dh, dj, dk, dl)
* @param movement - The movement direction
* @returns boolean indicating if command was handled
*/
const handleDeleteMovement = useCallback(
(movement: 'h' | 'j' | 'k' | 'l'): boolean => {
const count = getCurrentCount();
dispatch({ type: 'CLEAR_COUNT' });
// Note: vimChangeMovement performs the same deletion operation as what we need.
// The only difference between 'change' and 'delete' is that 'change' enters
// INSERT mode after deletion, which is handled here (we simply don't call updateMode).
buffer.vimChangeMovement(movement, count);
const cmdTypeMap = {
h: CMD_TYPES.DELETE_MOVEMENT.LEFT,
j: CMD_TYPES.DELETE_MOVEMENT.DOWN,
k: CMD_TYPES.DELETE_MOVEMENT.UP,
l: CMD_TYPES.DELETE_MOVEMENT.RIGHT,
};
dispatch({
type: 'SET_LAST_COMMAND',
command: { type: cmdTypeMap[movement], count },
});
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
return true;
},
[getCurrentCount, dispatch, buffer],
);
/**
* Handles operator-motion commands (dw/cw, db/cb, de/ce)
* @param operator - The operator type ('d' for delete, 'c' for change)
@@ -510,7 +627,10 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
switch (normalizedKey.sequence) {
case 'h': {
// Check if this is part of a change command (ch)
// Check if this is part of a delete or change command (dh/ch)
if (state.pendingOperator === 'd') {
return handleDeleteMovement('h');
}
if (state.pendingOperator === 'c') {
return handleChangeMovement('h');
}
@@ -522,7 +642,10 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
}
case 'j': {
// Check if this is part of a change command (cj)
// Check if this is part of a delete or change command (dj/cj)
if (state.pendingOperator === 'd') {
return handleDeleteMovement('j');
}
if (state.pendingOperator === 'c') {
return handleChangeMovement('j');
}
@@ -534,7 +657,10 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
}
case 'k': {
// Check if this is part of a change command (ck)
// Check if this is part of a delete or change command (dk/ck)
if (state.pendingOperator === 'd') {
return handleDeleteMovement('k');
}
if (state.pendingOperator === 'c') {
return handleChangeMovement('k');
}
@@ -546,7 +672,10 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
}
case 'l': {
// Check if this is part of a change command (cl)
// Check if this is part of a delete or change command (dl/cl)
if (state.pendingOperator === 'd') {
return handleDeleteMovement('l');
}
if (state.pendingOperator === 'c') {
return handleChangeMovement('l');
}
@@ -691,6 +820,30 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
}
case '0': {
// Check if this is part of a delete command (d0)
if (state.pendingOperator === 'd') {
buffer.vimDeleteToStartOfLine();
dispatch({
type: 'SET_LAST_COMMAND',
command: { type: CMD_TYPES.DELETE_TO_SOL, count: 1 },
});
dispatch({ type: 'CLEAR_COUNT' });
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
return true;
}
// Check if this is part of a change command (c0)
if (state.pendingOperator === 'c') {
buffer.vimChangeToStartOfLine();
dispatch({
type: 'SET_LAST_COMMAND',
command: { type: CMD_TYPES.CHANGE_TO_SOL, count: 1 },
});
dispatch({ type: 'CLEAR_COUNT' });
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
updateMode('INSERT');
return true;
}
// Move to start of line
buffer.vimMoveToLineStart();
dispatch({ type: 'CLEAR_COUNT' });
@@ -698,13 +851,64 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
}
case '$': {
// Move to end of line
// Check if this is part of a delete command (d$)
if (state.pendingOperator === 'd') {
buffer.vimDeleteToEndOfLine(repeatCount);
dispatch({
type: 'SET_LAST_COMMAND',
command: { type: CMD_TYPES.DELETE_TO_EOL, count: repeatCount },
});
dispatch({ type: 'CLEAR_COUNT' });
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
return true;
}
// Check if this is part of a change command (c$)
if (state.pendingOperator === 'c') {
buffer.vimChangeToEndOfLine(repeatCount);
dispatch({
type: 'SET_LAST_COMMAND',
command: { type: CMD_TYPES.CHANGE_TO_EOL, count: repeatCount },
});
dispatch({ type: 'CLEAR_COUNT' });
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
updateMode('INSERT');
return true;
}
// Move to end of line (with count, move down count-1 lines first)
if (repeatCount > 1) {
buffer.vimMoveDown(repeatCount - 1);
}
buffer.vimMoveToLineEnd();
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case '^': {
// Check if this is part of a delete command (d^)
if (state.pendingOperator === 'd') {
buffer.vimDeleteToFirstNonWhitespace();
dispatch({
type: 'SET_LAST_COMMAND',
command: { type: CMD_TYPES.DELETE_TO_FIRST_NONWS, count: 1 },
});
dispatch({ type: 'CLEAR_COUNT' });
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
return true;
}
// Check if this is part of a change command (c^)
if (state.pendingOperator === 'c') {
buffer.vimChangeToFirstNonWhitespace();
dispatch({
type: 'SET_LAST_COMMAND',
command: { type: CMD_TYPES.CHANGE_TO_FIRST_NONWS, count: 1 },
});
dispatch({ type: 'CLEAR_COUNT' });
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
updateMode('INSERT');
return true;
}
// Move to first non-whitespace character
buffer.vimMoveToFirstNonWhitespace();
dispatch({ type: 'CLEAR_COUNT' });
@@ -712,19 +916,94 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
}
case 'g': {
if (state.pendingOperator === 'g') {
// Second 'g' - go to first line (gg command)
buffer.vimMoveToFirstLine();
if (state.pendingOperator === 'd') {
// 'dg' - need another 'g' for 'dgg' command
dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'dg' });
return true;
}
if (state.pendingOperator === 'c') {
// 'cg' - need another 'g' for 'cgg' command
dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'cg' });
return true;
}
if (state.pendingOperator === 'dg') {
// 'dgg' command - delete from first line (or line N) to current line
// Pass state.count directly (0 means first line, N means line N)
buffer.vimDeleteToFirstLine(state.count);
dispatch({
type: 'SET_LAST_COMMAND',
command: {
type: CMD_TYPES.DELETE_TO_FIRST_LINE,
count: state.count,
},
});
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
if (state.pendingOperator === 'cg') {
// 'cgg' command - change from first line (or line N) to current line
buffer.vimDeleteToFirstLine(state.count);
dispatch({
type: 'SET_LAST_COMMAND',
command: {
type: CMD_TYPES.CHANGE_TO_FIRST_LINE,
count: state.count,
},
});
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
dispatch({ type: 'CLEAR_COUNT' });
updateMode('INSERT');
return true;
}
if (state.pendingOperator === 'g') {
// Second 'g' - go to line N (gg command), or first line if no count
if (state.count > 0) {
buffer.vimMoveToLine(state.count);
} else {
buffer.vimMoveToFirstLine();
}
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
dispatch({ type: 'CLEAR_COUNT' });
} else {
// First 'g' - wait for second g
// First 'g' - wait for second g (don't clear count yet)
dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'g' });
}
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case 'G': {
// Check if this is part of a delete command (dG)
if (state.pendingOperator === 'd') {
// Pass state.count directly (0 means last line, N means line N)
buffer.vimDeleteToLastLine(state.count);
dispatch({
type: 'SET_LAST_COMMAND',
command: {
type: CMD_TYPES.DELETE_TO_LAST_LINE,
count: state.count,
},
});
dispatch({ type: 'CLEAR_COUNT' });
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
return true;
}
// Check if this is part of a change command (cG)
if (state.pendingOperator === 'c') {
buffer.vimDeleteToLastLine(state.count);
dispatch({
type: 'SET_LAST_COMMAND',
command: {
type: CMD_TYPES.CHANGE_TO_LAST_LINE,
count: state.count,
},
});
dispatch({ type: 'CLEAR_COUNT' });
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
updateMode('INSERT');
return true;
}
if (state.count > 0) {
// Go to specific line number (1-based) when a count was provided
buffer.vimMoveToLine(state.count);
@@ -789,34 +1068,44 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
}
case 'D': {
// Delete from cursor to end of line (equivalent to d$)
executeCommand(CMD_TYPES.DELETE_TO_EOL, 1);
// Delete from cursor to end of line (with count, delete to end of N lines)
executeCommand(CMD_TYPES.DELETE_TO_EOL, repeatCount);
dispatch({
type: 'SET_LAST_COMMAND',
command: { type: CMD_TYPES.DELETE_TO_EOL, count: 1 },
command: { type: CMD_TYPES.DELETE_TO_EOL, count: repeatCount },
});
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case 'C': {
// Change from cursor to end of line (equivalent to c$)
executeCommand(CMD_TYPES.CHANGE_TO_EOL, 1);
// Change from cursor to end of line (with count, change to end of N lines)
executeCommand(CMD_TYPES.CHANGE_TO_EOL, repeatCount);
dispatch({
type: 'SET_LAST_COMMAND',
command: { type: CMD_TYPES.CHANGE_TO_EOL, count: 1 },
command: { type: CMD_TYPES.CHANGE_TO_EOL, count: repeatCount },
});
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case 'u': {
// Undo last change
for (let i = 0; i < repeatCount; i++) {
buffer.undo();
}
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case '.': {
// Repeat last command
// Repeat last command (use current count if provided, otherwise use original count)
if (state.lastCommand) {
const cmdData = state.lastCommand;
const count = state.count > 0 ? state.count : cmdData.count;
// All repeatable commands are now handled by executeCommand
executeCommand(cmdData.type, cmdData.count);
executeCommand(cmdData.type, count);
}
dispatch({ type: 'CLEAR_COUNT' });
@@ -827,6 +1116,9 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
// Check for arrow keys (they have different sequences but known names)
if (normalizedKey.name === 'left') {
// Left arrow - same as 'h'
if (state.pendingOperator === 'd') {
return handleDeleteMovement('h');
}
if (state.pendingOperator === 'c') {
return handleChangeMovement('h');
}
@@ -839,6 +1131,9 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
if (normalizedKey.name === 'down') {
// Down arrow - same as 'j'
if (state.pendingOperator === 'd') {
return handleDeleteMovement('j');
}
if (state.pendingOperator === 'c') {
return handleChangeMovement('j');
}
@@ -851,6 +1146,9 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
if (normalizedKey.name === 'up') {
// Up arrow - same as 'k'
if (state.pendingOperator === 'd') {
return handleDeleteMovement('k');
}
if (state.pendingOperator === 'c') {
return handleChangeMovement('k');
}
@@ -863,6 +1161,9 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
if (normalizedKey.name === 'right') {
// Right arrow - same as 'l'
if (state.pendingOperator === 'd') {
return handleDeleteMovement('l');
}
if (state.pendingOperator === 'c') {
return handleChangeMovement('l');
}
@@ -895,6 +1196,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
dispatch,
getCurrentCount,
handleChangeMovement,
handleDeleteMovement,
handleOperatorMotion,
buffer,
executeCommand,
+18 -5
View File
@@ -331,12 +331,25 @@ describe('keyMatchers', () => {
negative: [createKey('d'), createKey('c', { ctrl: true })],
},
{
command: Command.SHOW_MORE_LINES,
command: Command.SUSPEND_APP,
positive: [
createKey('s', { ctrl: true }),
createKey('o', { ctrl: true }),
createKey('z', { ctrl: true }),
createKey('z', { ctrl: true, shift: true }),
],
negative: [
createKey('z'),
createKey('y', { ctrl: true }),
createKey('z', { alt: true }),
],
},
{
command: Command.SHOW_MORE_LINES,
positive: [createKey('o', { ctrl: true })],
negative: [
createKey('s', { ctrl: true }),
createKey('s'),
createKey('l', { ctrl: true }),
],
negative: [createKey('s'), createKey('l', { ctrl: true })],
},
// Shell commands
@@ -358,7 +371,7 @@ describe('keyMatchers', () => {
{
command: Command.FOCUS_SHELL_INPUT,
positive: [createKey('tab')],
negative: [createKey('f', { ctrl: true }), createKey('f')],
negative: [createKey('f6'), createKey('f', { ctrl: true })],
},
{
command: Command.TOGGLE_YOLO,
@@ -14,7 +14,18 @@ import type { ExtensionUpdateAction } from '../state/extensions.js';
*/
export function createNonInteractiveUI(): CommandContext['ui'] {
return {
addItem: (_item, _timestamp) => 0,
addItem: (item, _timestamp) => {
if ('text' in item && item.text) {
if (item.type === 'error') {
process.stderr.write(`Error: ${item.text}\n`);
} else if (item.type === 'warning') {
process.stderr.write(`Warning: ${item.text}\n`);
} else if (item.type === 'info') {
process.stdout.write(`${item.text}\n`);
}
}
return 0;
},
clear: () => {},
setDebugMessage: (_message) => {},
loadHistory: (_newHistory) => {},
+46 -156
View File
@@ -6,149 +6,7 @@
import { debugLogger } from '@google/gemini-cli-core';
import tinygradient from 'tinygradient';
// Mapping from common CSS color names (lowercase) to hex codes (lowercase)
// Excludes names directly supported by Ink
export const CSS_NAME_TO_HEX_MAP: Readonly<Record<string, string>> = {
aliceblue: '#f0f8ff',
antiquewhite: '#faebd7',
aqua: '#00ffff',
aquamarine: '#7fffd4',
azure: '#f0ffff',
beige: '#f5f5dc',
bisque: '#ffe4c4',
blanchedalmond: '#ffebcd',
blueviolet: '#8a2be2',
brown: '#a52a2a',
burlywood: '#deb887',
cadetblue: '#5f9ea0',
chartreuse: '#7fff00',
chocolate: '#d2691e',
coral: '#ff7f50',
cornflowerblue: '#6495ed',
cornsilk: '#fff8dc',
crimson: '#dc143c',
darkblue: '#00008b',
darkcyan: '#008b8b',
darkgoldenrod: '#b8860b',
darkgray: '#a9a9a9',
darkgrey: '#a9a9a9',
darkgreen: '#006400',
darkkhaki: '#bdb76b',
darkmagenta: '#8b008b',
darkolivegreen: '#556b2f',
darkorange: '#ff8c00',
darkorchid: '#9932cc',
darkred: '#8b0000',
darksalmon: '#e9967a',
darkseagreen: '#8fbc8f',
darkslateblue: '#483d8b',
darkslategray: '#2f4f4f',
darkslategrey: '#2f4f4f',
darkturquoise: '#00ced1',
darkviolet: '#9400d3',
deeppink: '#ff1493',
deepskyblue: '#00bfff',
dimgray: '#696969',
dimgrey: '#696969',
dodgerblue: '#1e90ff',
firebrick: '#b22222',
floralwhite: '#fffaf0',
forestgreen: '#228b22',
fuchsia: '#ff00ff',
gainsboro: '#dcdcdc',
ghostwhite: '#f8f8ff',
gold: '#ffd700',
goldenrod: '#daa520',
greenyellow: '#adff2f',
honeydew: '#f0fff0',
hotpink: '#ff69b4',
indianred: '#cd5c5c',
indigo: '#4b0082',
ivory: '#fffff0',
khaki: '#f0e68c',
lavender: '#e6e6fa',
lavenderblush: '#fff0f5',
lawngreen: '#7cfc00',
lemonchiffon: '#fffacd',
lightblue: '#add8e6',
lightcoral: '#f08080',
lightcyan: '#e0ffff',
lightgoldenrodyellow: '#fafad2',
lightgray: '#d3d3d3',
lightgrey: '#d3d3d3',
lightgreen: '#90ee90',
lightpink: '#ffb6c1',
lightsalmon: '#ffa07a',
lightseagreen: '#20b2aa',
lightskyblue: '#87cefa',
lightslategray: '#778899',
lightslategrey: '#778899',
lightsteelblue: '#b0c4de',
lightyellow: '#ffffe0',
lime: '#00ff00',
limegreen: '#32cd32',
linen: '#faf0e6',
maroon: '#800000',
mediumaquamarine: '#66cdaa',
mediumblue: '#0000cd',
mediumorchid: '#ba55d3',
mediumpurple: '#9370db',
mediumseagreen: '#3cb371',
mediumslateblue: '#7b68ee',
mediumspringgreen: '#00fa9a',
mediumturquoise: '#48d1cc',
mediumvioletred: '#c71585',
midnightblue: '#191970',
mintcream: '#f5fffa',
mistyrose: '#ffe4e1',
moccasin: '#ffe4b5',
navajowhite: '#ffdead',
navy: '#000080',
oldlace: '#fdf5e6',
olive: '#808000',
olivedrab: '#6b8e23',
orange: '#ffa500',
orangered: '#ff4500',
orchid: '#da70d6',
palegoldenrod: '#eee8aa',
palegreen: '#98fb98',
paleturquoise: '#afeeee',
palevioletred: '#db7093',
papayawhip: '#ffefd5',
peachpuff: '#ffdab9',
peru: '#cd853f',
pink: '#ffc0cb',
plum: '#dda0dd',
powderblue: '#b0e0e6',
purple: '#800080',
rebeccapurple: '#663399',
rosybrown: '#bc8f8f',
royalblue: '#4169e1',
saddlebrown: '#8b4513',
salmon: '#fa8072',
sandybrown: '#f4a460',
seagreen: '#2e8b57',
seashell: '#fff5ee',
sienna: '#a0522d',
silver: '#c0c0c0',
skyblue: '#87ceeb',
slateblue: '#6a5acd',
slategray: '#708090',
slategrey: '#708090',
snow: '#fffafa',
springgreen: '#00ff7f',
steelblue: '#4682b4',
tan: '#d2b48c',
teal: '#008080',
thistle: '#d8bfd8',
tomato: '#ff6347',
turquoise: '#40e0d0',
violet: '#ee82ee',
wheat: '#f5deb3',
whitesmoke: '#f5f5f5',
yellowgreen: '#9acd32',
};
import tinycolor from 'tinycolor2';
// Define the set of Ink's named colors for quick lookup
export const INK_SUPPORTED_NAMES = new Set([
@@ -172,6 +30,13 @@ export const INK_SUPPORTED_NAMES = new Set([
'whitebright',
]);
// Use tinycolor's built-in names map for CSS colors, excluding ones Ink supports
export const CSS_NAME_TO_HEX_MAP = Object.fromEntries(
Object.entries(tinycolor.names)
.filter(([name]) => !INK_SUPPORTED_NAMES.has(name))
.map(([name, hex]) => [name, `#${hex}`]),
);
/**
* Checks if a color string is valid (hex, Ink-supported color name, or CSS color name).
* This function uses the same validation logic as the Theme class's _resolveColor method
@@ -217,12 +82,19 @@ export function resolveColor(colorValue: string): string | undefined {
return undefined;
}
}
// Handle hex codes without #
if (/^[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/.test(colorValue)) {
return `#${lowerColor}`;
}
// 2. Check if it's an Ink supported name (lowercase)
else if (INK_SUPPORTED_NAMES.has(lowerColor)) {
if (INK_SUPPORTED_NAMES.has(lowerColor)) {
return lowerColor; // Use Ink name directly
}
// 3. Check if it's a known CSS name we can map to hex
else if (CSS_NAME_TO_HEX_MAP[lowerColor]) {
if (CSS_NAME_TO_HEX_MAP[lowerColor]) {
return CSS_NAME_TO_HEX_MAP[lowerColor]; // Use mapped hex
}
@@ -286,27 +158,45 @@ export function getThemeTypeFromBackgroundColor(
return undefined;
}
const luminance = getLuminance(backgroundColor);
const resolvedColor = resolveColor(backgroundColor);
if (!resolvedColor) {
return undefined;
}
const luminance = getLuminance(resolvedColor);
return luminance > 128 ? 'light' : 'dark';
}
// Mapping for ANSI bright colors that are not in tinycolor's standard CSS names
export const INK_NAME_TO_HEX_MAP: Readonly<Record<string, string>> = {
blackbright: '#555555',
redbright: '#ff5555',
greenbright: '#55ff55',
yellowbright: '#ffff55',
bluebright: '#5555ff',
magentabright: '#ff55ff',
cyanbright: '#55ffff',
whitebright: '#ffffff',
};
/**
* Calculates the relative luminance of a color.
* See https://www.w3.org/TR/WCAG20/#relativeluminancedef
*
* @param backgroundColor Hex color string (with or without #)
* @param color Color string (hex or Ink-supported name)
* @returns Luminance value (0-255)
*/
export function getLuminance(backgroundColor: string): number {
let hex = backgroundColor.replace(/^#/, '');
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
export function getLuminance(color: string): number {
const resolved = color.toLowerCase();
const hex = INK_NAME_TO_HEX_MAP[resolved] || resolved;
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
const colorObj = tinycolor(hex);
if (!colorObj.isValid()) {
return 0;
}
// tinycolor returns 0-1, we need 0-255
return colorObj.getLuminance() * 255;
}
// Hysteresis thresholds to prevent flickering when the background color
@@ -59,6 +59,7 @@ describe('ThemeManager', () => {
// Reset themeManager state
themeManager.loadCustomThemes({});
themeManager.setActiveTheme(DEFAULT_THEME.name);
themeManager.setTerminalBackground(undefined);
});
afterEach(() => {
@@ -238,4 +239,114 @@ describe('ThemeManager', () => {
expect(themeManager.isCustomTheme('SettingsTheme')).toBe(true);
});
});
describe('terminalBackground override', () => {
it('should store and retrieve terminal background', () => {
themeManager.setTerminalBackground('#123456');
expect(themeManager.getTerminalBackground()).toBe('#123456');
themeManager.setTerminalBackground(undefined);
expect(themeManager.getTerminalBackground()).toBeUndefined();
});
it('should override background.primary in semantic colors when terminal background is set', () => {
const color = '#1a1a1a';
themeManager.setTerminalBackground(color);
const semanticColors = themeManager.getSemanticColors();
expect(semanticColors.background.primary).toBe(color);
});
it('should override Background in colors when terminal background is set', () => {
const color = '#1a1a1a';
themeManager.setTerminalBackground(color);
const colors = themeManager.getColors();
expect(colors.Background).toBe(color);
});
it('should re-calculate dependent semantic colors when terminal background is set', () => {
themeManager.setTerminalBackground('#000000');
const semanticColors = themeManager.getSemanticColors();
// border.default should be interpolated from background (#000000) and Gray
// ui.dark should be interpolated from Gray and background (#000000)
expect(semanticColors.border.default).toBeDefined();
expect(semanticColors.ui.dark).toBeDefined();
expect(semanticColors.border.default).not.toBe(
DEFAULT_THEME.semanticColors.border.default,
);
});
it('should return original semantic colors when terminal background is NOT set', () => {
themeManager.setTerminalBackground(undefined);
const semanticColors = themeManager.getSemanticColors();
expect(semanticColors).toEqual(DEFAULT_THEME.semanticColors);
});
it('should NOT override background when theme is incompatible (Light theme on Dark terminal)', () => {
themeManager.setActiveTheme('Default Light');
const darkTerminalBg = '#000000';
themeManager.setTerminalBackground(darkTerminalBg);
const semanticColors = themeManager.getSemanticColors();
expect(semanticColors.background.primary).toBe(
themeManager.getTheme('Default Light')!.colors.Background,
);
const colors = themeManager.getColors();
expect(colors.Background).toBe(
themeManager.getTheme('Default Light')!.colors.Background,
);
});
it('should NOT override background when theme is incompatible (Dark theme on Light terminal)', () => {
themeManager.setActiveTheme('Default');
const lightTerminalBg = '#FFFFFF';
themeManager.setTerminalBackground(lightTerminalBg);
const semanticColors = themeManager.getSemanticColors();
expect(semanticColors.background.primary).toBe(
themeManager.getTheme('Default')!.colors.Background,
);
const colors = themeManager.getColors();
expect(colors.Background).toBe(
themeManager.getTheme('Default')!.colors.Background,
);
});
it('should override background for custom theme when compatible', () => {
themeManager.loadCustomThemes({
MyDark: {
name: 'MyDark',
type: 'custom',
Background: '#000000',
Foreground: '#ffffff',
},
});
themeManager.setActiveTheme('MyDark');
const darkTerminalBg = '#1a1a1a';
themeManager.setTerminalBackground(darkTerminalBg);
const semanticColors = themeManager.getSemanticColors();
expect(semanticColors.background.primary).toBe(darkTerminalBg);
});
it('should NOT override background for custom theme when incompatible', () => {
themeManager.loadCustomThemes({
MyLight: {
name: 'MyLight',
type: 'custom',
Background: '#ffffff',
Foreground: '#000000',
},
});
themeManager.setActiveTheme('MyLight');
const darkTerminalBg = '#000000';
themeManager.setTerminalBackground(darkTerminalBg);
const semanticColors = themeManager.getSemanticColors();
expect(semanticColors.background.primary).toBe('#ffffff');
});
});
});

Some files were not shown because too many files have changed in this diff Show More