mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-23 03:24:42 -07:00
merge
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export async function createPolicyEngineConfig(
|
||||
mcp: settings.mcp,
|
||||
tools: settings.tools,
|
||||
mcpServers: settings.mcpServers,
|
||||
policyPaths: settings.policyPaths,
|
||||
};
|
||||
|
||||
return createCorePolicyEngineConfig(policySettings, approvalMode);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 || [])
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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: '' },
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`] = `
|
||||
"[40m[30m▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀[39m[49m
|
||||
[40m [97m> [7m[[27mPasted Text: 10 lines][39m [49m
|
||||
[40m[30m▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄[39m[49m"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = `
|
||||
"[40m[30m▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀[39m[49m
|
||||
[40m [97m> [39m[7ml[27mine1 [49m
|
||||
[40m line2 [49m
|
||||
[40m line3 [49m
|
||||
[40m line4 [49m
|
||||
[40m line5 [49m
|
||||
[40m line6 [49m
|
||||
[40m line7 [49m
|
||||
[40m line8 [49m
|
||||
[40m line9 [49m
|
||||
[40m line10 [49m
|
||||
[40m[30m▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄[39m[49m"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 7`] = `
|
||||
"[40m[30m▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀[39m[49m
|
||||
[40m [97m> [7m[[27mPasted Text: 10 lines][39m [49m
|
||||
[40m[30m▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄[39m[49m"
|
||||
`;
|
||||
|
||||
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
|
||||
"
|
||||
`;
|
||||
|
||||
+5
-120
@@ -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());
|
||||
})();
|
||||
|
||||
|
||||
@@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {},
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user