feature(commands) - Refactor Slash Command + Vision For the Future (#3175)
This commit is contained in:
parent
6eccb474c7
commit
aa10ccba71
|
@ -0,0 +1,110 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { CommandService } from './CommandService.js';
|
||||||
|
import { type SlashCommand } from '../ui/commands/types.js';
|
||||||
|
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||||
|
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||||
|
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||||
|
|
||||||
|
// Mock the command modules to isolate the service from the command implementations.
|
||||||
|
vi.mock('../ui/commands/memoryCommand.js', () => ({
|
||||||
|
memoryCommand: { name: 'memory', description: 'Mock Memory' },
|
||||||
|
}));
|
||||||
|
vi.mock('../ui/commands/helpCommand.js', () => ({
|
||||||
|
helpCommand: { name: 'help', description: 'Mock Help' },
|
||||||
|
}));
|
||||||
|
vi.mock('../ui/commands/clearCommand.js', () => ({
|
||||||
|
clearCommand: { name: 'clear', description: 'Mock Clear' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('CommandService', () => {
|
||||||
|
describe('when using default production loader', () => {
|
||||||
|
let commandService: CommandService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
commandService = new CommandService();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with an empty command tree', () => {
|
||||||
|
const tree = commandService.getCommands();
|
||||||
|
expect(tree).toBeInstanceOf(Array);
|
||||||
|
expect(tree.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadCommands', () => {
|
||||||
|
it('should load the built-in commands into the command tree', async () => {
|
||||||
|
// Pre-condition check
|
||||||
|
expect(commandService.getCommands().length).toBe(0);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
await commandService.loadCommands();
|
||||||
|
const tree = commandService.getCommands();
|
||||||
|
|
||||||
|
// Post-condition assertions
|
||||||
|
expect(tree.length).toBe(3);
|
||||||
|
|
||||||
|
const commandNames = tree.map((cmd) => cmd.name);
|
||||||
|
expect(commandNames).toContain('memory');
|
||||||
|
expect(commandNames).toContain('help');
|
||||||
|
expect(commandNames).toContain('clear');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should overwrite any existing commands when called again', async () => {
|
||||||
|
// Load once
|
||||||
|
await commandService.loadCommands();
|
||||||
|
expect(commandService.getCommands().length).toBe(3);
|
||||||
|
|
||||||
|
// Load again
|
||||||
|
await commandService.loadCommands();
|
||||||
|
const tree = commandService.getCommands();
|
||||||
|
|
||||||
|
// Should not append, but overwrite
|
||||||
|
expect(tree.length).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCommandTree', () => {
|
||||||
|
it('should return the current command tree', async () => {
|
||||||
|
const initialTree = commandService.getCommands();
|
||||||
|
expect(initialTree).toEqual([]);
|
||||||
|
|
||||||
|
await commandService.loadCommands();
|
||||||
|
|
||||||
|
const loadedTree = commandService.getCommands();
|
||||||
|
expect(loadedTree.length).toBe(3);
|
||||||
|
expect(loadedTree).toEqual([clearCommand, helpCommand, memoryCommand]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when initialized with an injected loader function', () => {
|
||||||
|
it('should use the provided loader instead of the built-in one', async () => {
|
||||||
|
// Arrange: Create a set of mock commands.
|
||||||
|
const mockCommands: SlashCommand[] = [
|
||||||
|
{ name: 'injected-test-1', description: 'injected 1' },
|
||||||
|
{ name: 'injected-test-2', description: 'injected 2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Arrange: Create a mock loader FUNCTION that resolves with our mock commands.
|
||||||
|
const mockLoader = vi.fn().mockResolvedValue(mockCommands);
|
||||||
|
|
||||||
|
// Act: Instantiate the service WITH the injected loader function.
|
||||||
|
const commandService = new CommandService(mockLoader);
|
||||||
|
await commandService.loadCommands();
|
||||||
|
const tree = commandService.getCommands();
|
||||||
|
|
||||||
|
// Assert: The tree should contain ONLY our injected commands.
|
||||||
|
expect(mockLoader).toHaveBeenCalled(); // Verify our mock loader was actually called.
|
||||||
|
expect(tree.length).toBe(2);
|
||||||
|
expect(tree).toEqual(mockCommands);
|
||||||
|
|
||||||
|
const commandNames = tree.map((cmd) => cmd.name);
|
||||||
|
expect(commandNames).not.toContain('memory'); // Verify it didn't load production commands.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,36 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SlashCommand } from '../ui/commands/types.js';
|
||||||
|
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||||
|
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||||
|
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||||
|
|
||||||
|
const loadBuiltInCommands = async (): Promise<SlashCommand[]> => [
|
||||||
|
clearCommand,
|
||||||
|
helpCommand,
|
||||||
|
memoryCommand,
|
||||||
|
];
|
||||||
|
|
||||||
|
export class CommandService {
|
||||||
|
private commands: SlashCommand[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private commandLoader: () => Promise<SlashCommand[]> = loadBuiltInCommands,
|
||||||
|
) {
|
||||||
|
// The constructor can be used for dependency injection in the future.
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadCommands(): Promise<void> {
|
||||||
|
// For now, we only load the built-in commands.
|
||||||
|
// File-based and remote commands will be added later.
|
||||||
|
this.commands = await this.commandLoader();
|
||||||
|
}
|
||||||
|
|
||||||
|
getCommands(): SlashCommand[] {
|
||||||
|
return this.commands;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,62 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { vi, describe, it, expect } from 'vitest';
|
||||||
|
import { createMockCommandContext } from './mockCommandContext.js';
|
||||||
|
|
||||||
|
describe('createMockCommandContext', () => {
|
||||||
|
it('should return a valid CommandContext object with default mocks', () => {
|
||||||
|
const context = createMockCommandContext();
|
||||||
|
|
||||||
|
// Just a few spot checks to ensure the structure is correct
|
||||||
|
// and functions are mocks.
|
||||||
|
expect(context).toBeDefined();
|
||||||
|
expect(context.ui.addItem).toBeInstanceOf(Function);
|
||||||
|
expect(vi.isMockFunction(context.ui.addItem)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply top-level overrides correctly', () => {
|
||||||
|
const mockClear = vi.fn();
|
||||||
|
const overrides = {
|
||||||
|
ui: {
|
||||||
|
clear: mockClear,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const context = createMockCommandContext(overrides);
|
||||||
|
|
||||||
|
// Call the function to see if the override was used
|
||||||
|
context.ui.clear();
|
||||||
|
|
||||||
|
// Assert that our specific mock was called, not the default
|
||||||
|
expect(mockClear).toHaveBeenCalled();
|
||||||
|
// And that other defaults are still in place
|
||||||
|
expect(vi.isMockFunction(context.ui.addItem)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply deeply nested overrides correctly', () => {
|
||||||
|
// This is the most important test for factory's logic.
|
||||||
|
const mockConfig = {
|
||||||
|
getProjectRoot: () => '/test/project',
|
||||||
|
getModel: () => 'gemini-pro',
|
||||||
|
};
|
||||||
|
|
||||||
|
const overrides = {
|
||||||
|
services: {
|
||||||
|
config: mockConfig,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const context = createMockCommandContext(overrides);
|
||||||
|
|
||||||
|
expect(context.services.config).toBeDefined();
|
||||||
|
expect(context.services.config?.getModel()).toBe('gemini-pro');
|
||||||
|
expect(context.services.config?.getProjectRoot()).toBe('/test/project');
|
||||||
|
|
||||||
|
// Verify a default property on the same nested object is still there
|
||||||
|
expect(context.services.logger).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,94 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { CommandContext } from '../ui/commands/types.js';
|
||||||
|
import { LoadedSettings } from '../config/settings.js';
|
||||||
|
import { GitService } from '@google/gemini-cli-core';
|
||||||
|
import { SessionStatsState } from '../ui/contexts/SessionContext.js';
|
||||||
|
|
||||||
|
// A utility type to make all properties of an object, and its nested objects, partial.
|
||||||
|
type DeepPartial<T> = T extends object
|
||||||
|
? {
|
||||||
|
[P in keyof T]?: DeepPartial<T[P]>;
|
||||||
|
}
|
||||||
|
: T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a deep, fully-typed mock of the CommandContext for use in tests.
|
||||||
|
* All functions are pre-mocked with `vi.fn()`.
|
||||||
|
*
|
||||||
|
* @param overrides - A deep partial object to override any default mock values.
|
||||||
|
* @returns A complete, mocked CommandContext object.
|
||||||
|
*/
|
||||||
|
export const createMockCommandContext = (
|
||||||
|
overrides: DeepPartial<CommandContext> = {},
|
||||||
|
): CommandContext => {
|
||||||
|
const defaultMocks: CommandContext = {
|
||||||
|
services: {
|
||||||
|
config: null,
|
||||||
|
settings: { merged: {} } as LoadedSettings,
|
||||||
|
git: undefined as GitService | undefined,
|
||||||
|
logger: {
|
||||||
|
log: vi.fn(),
|
||||||
|
logMessage: vi.fn(),
|
||||||
|
saveCheckpoint: vi.fn(),
|
||||||
|
loadCheckpoint: vi.fn().mockResolvedValue([]),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any, // Cast because Logger is a class.
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
addItem: vi.fn(),
|
||||||
|
clear: vi.fn(),
|
||||||
|
setDebugMessage: vi.fn(),
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
stats: {
|
||||||
|
sessionStartTime: new Date(),
|
||||||
|
lastPromptTokenCount: 0,
|
||||||
|
metrics: {
|
||||||
|
models: {},
|
||||||
|
tools: {
|
||||||
|
totalCalls: 0,
|
||||||
|
totalSuccess: 0,
|
||||||
|
totalFail: 0,
|
||||||
|
totalDurationMs: 0,
|
||||||
|
totalDecisions: { accept: 0, reject: 0, modify: 0 },
|
||||||
|
byName: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as SessionStatsState,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const merge = (target: any, source: any): any => {
|
||||||
|
const output = { ...target };
|
||||||
|
|
||||||
|
for (const key in source) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||||
|
const sourceValue = source[key];
|
||||||
|
const targetValue = output[key];
|
||||||
|
|
||||||
|
if (
|
||||||
|
sourceValue &&
|
||||||
|
typeof sourceValue === 'object' &&
|
||||||
|
!Array.isArray(sourceValue) &&
|
||||||
|
targetValue &&
|
||||||
|
typeof targetValue === 'object' &&
|
||||||
|
!Array.isArray(targetValue)
|
||||||
|
) {
|
||||||
|
output[key] = merge(targetValue, sourceValue);
|
||||||
|
} else {
|
||||||
|
output[key] = sourceValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
|
||||||
|
return merge(defaultMocks, overrides);
|
||||||
|
};
|
|
@ -128,6 +128,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
getCheckpointingEnabled: vi.fn(() => opts.checkpointing ?? true),
|
getCheckpointingEnabled: vi.fn(() => opts.checkpointing ?? true),
|
||||||
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
|
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
|
||||||
setFlashFallbackHandler: vi.fn(),
|
setFlashFallbackHandler: vi.fn(),
|
||||||
|
getSessionId: vi.fn(() => 'test-session-id'),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -265,6 +265,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
handleSlashCommand,
|
handleSlashCommand,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
pendingHistoryItems: pendingSlashCommandHistoryItems,
|
pendingHistoryItems: pendingSlashCommandHistoryItems,
|
||||||
|
commandContext,
|
||||||
} = useSlashCommandProcessor(
|
} = useSlashCommandProcessor(
|
||||||
config,
|
config,
|
||||||
settings,
|
settings,
|
||||||
|
@ -278,7 +279,6 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
openThemeDialog,
|
openThemeDialog,
|
||||||
openAuthDialog,
|
openAuthDialog,
|
||||||
openEditorDialog,
|
openEditorDialog,
|
||||||
performMemoryRefresh,
|
|
||||||
toggleCorgiMode,
|
toggleCorgiMode,
|
||||||
showToolDescriptions,
|
showToolDescriptions,
|
||||||
setQuittingMessages,
|
setQuittingMessages,
|
||||||
|
@ -326,9 +326,10 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
const quitCommand = slashCommands.find(
|
const quitCommand = slashCommands.find(
|
||||||
(cmd) => cmd.name === 'quit' || cmd.altName === 'exit',
|
(cmd) => cmd.name === 'quit' || cmd.altName === 'exit',
|
||||||
);
|
);
|
||||||
if (quitCommand) {
|
if (quitCommand && quitCommand.action) {
|
||||||
quitCommand.action('quit', '', '');
|
quitCommand.action(commandContext, '');
|
||||||
} else {
|
} else {
|
||||||
|
// This is unlikely to be needed but added for an additional fallback.
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -339,7 +340,8 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
}, CTRL_EXIT_PROMPT_DURATION_MS);
|
}, CTRL_EXIT_PROMPT_DURATION_MS);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[slashCommands],
|
// Add commandContext to the dependency array here!
|
||||||
|
[slashCommands, commandContext],
|
||||||
);
|
);
|
||||||
|
|
||||||
useInput((input: string, key: InkKeyType) => {
|
useInput((input: string, key: InkKeyType) => {
|
||||||
|
@ -775,6 +777,7 @@ const App = ({ config, settings, startupWarnings = [] }: AppProps) => {
|
||||||
onClearScreen={handleClearScreen}
|
onClearScreen={handleClearScreen}
|
||||||
config={config}
|
config={config}
|
||||||
slashCommands={slashCommands}
|
slashCommands={slashCommands}
|
||||||
|
commandContext={commandContext}
|
||||||
shellModeActive={shellModeActive}
|
shellModeActive={shellModeActive}
|
||||||
setShellModeActive={setShellModeActive}
|
setShellModeActive={setShellModeActive}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -0,0 +1,78 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
|
||||||
|
import { clearCommand } from './clearCommand.js';
|
||||||
|
import { type CommandContext } from './types.js';
|
||||||
|
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||||
|
import { GeminiClient } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
describe('clearCommand', () => {
|
||||||
|
let mockContext: CommandContext;
|
||||||
|
let mockResetChat: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockResetChat = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
mockContext = createMockCommandContext({
|
||||||
|
services: {
|
||||||
|
config: {
|
||||||
|
getGeminiClient: () =>
|
||||||
|
({
|
||||||
|
resetChat: mockResetChat,
|
||||||
|
}) as unknown as GeminiClient,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set debug message, reset chat, and clear UI when config is available', async () => {
|
||||||
|
if (!clearCommand.action) {
|
||||||
|
throw new Error('clearCommand must have an action.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await clearCommand.action(mockContext, '');
|
||||||
|
|
||||||
|
expect(mockContext.ui.setDebugMessage).toHaveBeenCalledWith(
|
||||||
|
'Clearing terminal and resetting chat.',
|
||||||
|
);
|
||||||
|
expect(mockContext.ui.setDebugMessage).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(mockResetChat).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Check the order of operations.
|
||||||
|
const setDebugMessageOrder = (mockContext.ui.setDebugMessage as Mock).mock
|
||||||
|
.invocationCallOrder[0];
|
||||||
|
const resetChatOrder = mockResetChat.mock.invocationCallOrder[0];
|
||||||
|
const clearOrder = (mockContext.ui.clear as Mock).mock
|
||||||
|
.invocationCallOrder[0];
|
||||||
|
|
||||||
|
expect(setDebugMessageOrder).toBeLessThan(resetChatOrder);
|
||||||
|
expect(resetChatOrder).toBeLessThan(clearOrder);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not attempt to reset chat if config service is not available', async () => {
|
||||||
|
if (!clearCommand.action) {
|
||||||
|
throw new Error('clearCommand must have an action.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nullConfigContext = createMockCommandContext({
|
||||||
|
services: {
|
||||||
|
config: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await clearCommand.action(nullConfigContext, '');
|
||||||
|
|
||||||
|
expect(nullConfigContext.ui.setDebugMessage).toHaveBeenCalledWith(
|
||||||
|
'Clearing terminal and resetting chat.',
|
||||||
|
);
|
||||||
|
expect(mockResetChat).not.toHaveBeenCalled();
|
||||||
|
expect(nullConfigContext.ui.clear).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,17 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SlashCommand } from './types.js';
|
||||||
|
|
||||||
|
export const clearCommand: SlashCommand = {
|
||||||
|
name: 'clear',
|
||||||
|
description: 'clear the screen and conversation history',
|
||||||
|
action: async (context, _args) => {
|
||||||
|
context.ui.setDebugMessage('Clearing terminal and resetting chat.');
|
||||||
|
await context.services.config?.getGeminiClient()?.resetChat();
|
||||||
|
context.ui.clear();
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,40 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { helpCommand } from './helpCommand.js';
|
||||||
|
import { type CommandContext } from './types.js';
|
||||||
|
|
||||||
|
describe('helpCommand', () => {
|
||||||
|
let mockContext: CommandContext;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockContext = {} as unknown as CommandContext;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return a dialog action and log a debug message for '/help'", () => {
|
||||||
|
const consoleDebugSpy = vi
|
||||||
|
.spyOn(console, 'debug')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
if (!helpCommand.action) {
|
||||||
|
throw new Error('Help command has no action');
|
||||||
|
}
|
||||||
|
const result = helpCommand.action(mockContext, '');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'dialog',
|
||||||
|
dialog: 'help',
|
||||||
|
});
|
||||||
|
expect(consoleDebugSpy).toHaveBeenCalledWith('Opening help UI ...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should also be triggered by its alternative name '?'", () => {
|
||||||
|
// This test is more conceptual. The routing of altName to the command
|
||||||
|
// is handled by the slash command processor, but we can assert the
|
||||||
|
// altName is correctly defined on the command object itself.
|
||||||
|
expect(helpCommand.altName).toBe('?');
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,20 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||||
|
|
||||||
|
export const helpCommand: SlashCommand = {
|
||||||
|
name: 'help',
|
||||||
|
altName: '?',
|
||||||
|
description: 'for help on gemini-cli',
|
||||||
|
action: (_context, _args): OpenDialogActionReturn => {
|
||||||
|
console.debug('Opening help UI ...');
|
||||||
|
return {
|
||||||
|
type: 'dialog',
|
||||||
|
dialog: 'help',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,249 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
|
||||||
|
import { memoryCommand } from './memoryCommand.js';
|
||||||
|
import { type CommandContext, SlashCommand } from './types.js';
|
||||||
|
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||||
|
import { MessageType } from '../types.js';
|
||||||
|
import { getErrorMessage } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
|
const original =
|
||||||
|
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||||
|
return {
|
||||||
|
...original,
|
||||||
|
getErrorMessage: vi.fn((error: unknown) => {
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
return String(error);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('memoryCommand', () => {
|
||||||
|
let mockContext: CommandContext;
|
||||||
|
|
||||||
|
const getSubCommand = (name: 'show' | 'add' | 'refresh'): SlashCommand => {
|
||||||
|
const subCommand = memoryCommand.subCommands?.find(
|
||||||
|
(cmd) => cmd.name === name,
|
||||||
|
);
|
||||||
|
if (!subCommand) {
|
||||||
|
throw new Error(`/memory ${name} command not found.`);
|
||||||
|
}
|
||||||
|
return subCommand;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('/memory show', () => {
|
||||||
|
let showCommand: SlashCommand;
|
||||||
|
let mockGetUserMemory: Mock;
|
||||||
|
let mockGetGeminiMdFileCount: Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
showCommand = getSubCommand('show');
|
||||||
|
|
||||||
|
mockGetUserMemory = vi.fn();
|
||||||
|
mockGetGeminiMdFileCount = vi.fn();
|
||||||
|
|
||||||
|
mockContext = createMockCommandContext({
|
||||||
|
services: {
|
||||||
|
config: {
|
||||||
|
getUserMemory: mockGetUserMemory,
|
||||||
|
getGeminiMdFileCount: mockGetGeminiMdFileCount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display a message if memory is empty', async () => {
|
||||||
|
if (!showCommand.action) throw new Error('Command has no action');
|
||||||
|
|
||||||
|
mockGetUserMemory.mockReturnValue('');
|
||||||
|
mockGetGeminiMdFileCount.mockReturnValue(0);
|
||||||
|
|
||||||
|
await showCommand.action(mockContext, '');
|
||||||
|
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: 'Memory is currently empty.',
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display the memory content and file count if it exists', async () => {
|
||||||
|
if (!showCommand.action) throw new Error('Command has no action');
|
||||||
|
|
||||||
|
const memoryContent = 'This is a test memory.';
|
||||||
|
|
||||||
|
mockGetUserMemory.mockReturnValue(memoryContent);
|
||||||
|
mockGetGeminiMdFileCount.mockReturnValue(1);
|
||||||
|
|
||||||
|
await showCommand.action(mockContext, '');
|
||||||
|
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Current memory content from 1 file(s):\n\n---\n${memoryContent}\n---`,
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/memory add', () => {
|
||||||
|
let addCommand: SlashCommand;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
addCommand = getSubCommand('add');
|
||||||
|
mockContext = createMockCommandContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an error message if no arguments are provided', () => {
|
||||||
|
if (!addCommand.action) throw new Error('Command has no action');
|
||||||
|
|
||||||
|
const result = addCommand.action(mockContext, ' ');
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'Usage: /memory add <text to remember>',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockContext.ui.addItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a tool action and add an info message when arguments are provided', () => {
|
||||||
|
if (!addCommand.action) throw new Error('Command has no action');
|
||||||
|
|
||||||
|
const fact = 'remember this';
|
||||||
|
const result = addCommand.action(mockContext, ` ${fact} `);
|
||||||
|
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Attempting to save to memory: "${fact}"`,
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'tool',
|
||||||
|
toolName: 'save_memory',
|
||||||
|
toolArgs: { fact },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('/memory refresh', () => {
|
||||||
|
let refreshCommand: SlashCommand;
|
||||||
|
let mockRefreshMemory: Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
refreshCommand = getSubCommand('refresh');
|
||||||
|
mockRefreshMemory = vi.fn();
|
||||||
|
mockContext = createMockCommandContext({
|
||||||
|
services: {
|
||||||
|
config: {
|
||||||
|
refreshMemory: mockRefreshMemory,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display success message when memory is refreshed with content', async () => {
|
||||||
|
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||||
|
|
||||||
|
const refreshResult = {
|
||||||
|
memoryContent: 'new memory content',
|
||||||
|
fileCount: 2,
|
||||||
|
};
|
||||||
|
mockRefreshMemory.mockResolvedValue(refreshResult);
|
||||||
|
|
||||||
|
await refreshCommand.action(mockContext, '');
|
||||||
|
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: 'Refreshing memory from source files...',
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockRefreshMemory).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: 'Memory refreshed successfully. Loaded 18 characters from 2 file(s).',
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display success message when memory is refreshed with no content', async () => {
|
||||||
|
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||||
|
|
||||||
|
const refreshResult = { memoryContent: '', fileCount: 0 };
|
||||||
|
mockRefreshMemory.mockResolvedValue(refreshResult);
|
||||||
|
|
||||||
|
await refreshCommand.action(mockContext, '');
|
||||||
|
|
||||||
|
expect(mockRefreshMemory).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: 'Memory refreshed successfully. No memory content found.',
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display an error message if refreshing fails', async () => {
|
||||||
|
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||||
|
|
||||||
|
const error = new Error('Failed to read memory files.');
|
||||||
|
mockRefreshMemory.mockRejectedValue(error);
|
||||||
|
|
||||||
|
await refreshCommand.action(mockContext, '');
|
||||||
|
|
||||||
|
expect(mockRefreshMemory).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: `Error refreshing memory: ${error.message}`,
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getErrorMessage).toHaveBeenCalledWith(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not throw if config service is unavailable', async () => {
|
||||||
|
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||||
|
|
||||||
|
const nullConfigContext = createMockCommandContext({
|
||||||
|
services: { config: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
refreshCommand.action(nullConfigContext, ''),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(nullConfigContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: 'Refreshing memory from source files...',
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockRefreshMemory).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,106 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getErrorMessage } from '@google/gemini-cli-core';
|
||||||
|
import { MessageType } from '../types.js';
|
||||||
|
import { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||||
|
|
||||||
|
export const memoryCommand: SlashCommand = {
|
||||||
|
name: 'memory',
|
||||||
|
description: 'Commands for interacting with memory.',
|
||||||
|
subCommands: [
|
||||||
|
{
|
||||||
|
name: 'show',
|
||||||
|
description: 'Show the current memory contents.',
|
||||||
|
action: async (context) => {
|
||||||
|
const memoryContent = context.services.config?.getUserMemory() || '';
|
||||||
|
const fileCount = context.services.config?.getGeminiMdFileCount() || 0;
|
||||||
|
|
||||||
|
const messageContent =
|
||||||
|
memoryContent.length > 0
|
||||||
|
? `Current memory content from ${fileCount} file(s):\n\n---\n${memoryContent}\n---`
|
||||||
|
: 'Memory is currently empty.';
|
||||||
|
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: messageContent,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'add',
|
||||||
|
description: 'Add content to the memory.',
|
||||||
|
action: (context, args): SlashCommandActionReturn | void => {
|
||||||
|
if (!args || args.trim() === '') {
|
||||||
|
return {
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'error',
|
||||||
|
content: 'Usage: /memory add <text to remember>',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Attempting to save to memory: "${args.trim()}"`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'tool',
|
||||||
|
toolName: 'save_memory',
|
||||||
|
toolArgs: { fact: args.trim() },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'refresh',
|
||||||
|
description: 'Refresh the memory from the source.',
|
||||||
|
action: async (context) => {
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: 'Refreshing memory from source files...',
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await context.services.config?.refreshMemory();
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
const { memoryContent, fileCount } = result;
|
||||||
|
const successMessage =
|
||||||
|
memoryContent.length > 0
|
||||||
|
? `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`
|
||||||
|
: 'Memory refreshed successfully. No memory content found.';
|
||||||
|
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: successMessage,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = getErrorMessage(error);
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: `Error refreshing memory: ${errorMessage}`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
|
@ -0,0 +1,99 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Config, GitService, Logger } from '@google/gemini-cli-core';
|
||||||
|
import { LoadedSettings } from '../../config/settings.js';
|
||||||
|
import { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||||
|
import { SessionStatsState } from '../contexts/SessionContext.js';
|
||||||
|
|
||||||
|
// Grouped dependencies for clarity and easier mocking
|
||||||
|
export interface CommandContext {
|
||||||
|
// Core services and configuration
|
||||||
|
services: {
|
||||||
|
// TODO(abhipatel12): Ensure that config is never null.
|
||||||
|
config: Config | null;
|
||||||
|
settings: LoadedSettings;
|
||||||
|
git: GitService | undefined;
|
||||||
|
logger: Logger;
|
||||||
|
};
|
||||||
|
// UI state and history management
|
||||||
|
ui: {
|
||||||
|
// TODO - As more commands are add some additions may be needed or reworked using this new context.
|
||||||
|
// Ex.
|
||||||
|
// history: HistoryItem[];
|
||||||
|
// pendingHistoryItems: HistoryItemWithoutId[];
|
||||||
|
|
||||||
|
/** Adds a new item to the history display. */
|
||||||
|
addItem: UseHistoryManagerReturn['addItem'];
|
||||||
|
/** Clears all history items and the console screen. */
|
||||||
|
clear: () => void;
|
||||||
|
/**
|
||||||
|
* Sets the transient debug message displayed in the application footer in debug mode.
|
||||||
|
*/
|
||||||
|
setDebugMessage: (message: string) => void;
|
||||||
|
};
|
||||||
|
// Session-specific data
|
||||||
|
session: {
|
||||||
|
stats: SessionStatsState;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The return type for a command action that results in scheduling a tool call.
|
||||||
|
*/
|
||||||
|
export interface ToolActionReturn {
|
||||||
|
type: 'tool';
|
||||||
|
toolName: string;
|
||||||
|
toolArgs: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The return type for a command action that results in a simple message
|
||||||
|
* being displayed to the user.
|
||||||
|
*/
|
||||||
|
export interface MessageActionReturn {
|
||||||
|
type: 'message';
|
||||||
|
messageType: 'info' | 'error';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The return type for a command action that needs to open a dialog.
|
||||||
|
*/
|
||||||
|
export interface OpenDialogActionReturn {
|
||||||
|
type: 'dialog';
|
||||||
|
// TODO: Add 'theme' | 'auth' | 'editor' | 'privacy' as migration happens.
|
||||||
|
dialog: 'help';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SlashCommandActionReturn =
|
||||||
|
| ToolActionReturn
|
||||||
|
| MessageActionReturn
|
||||||
|
| OpenDialogActionReturn;
|
||||||
|
|
||||||
|
// The standardized contract for any command in the system.
|
||||||
|
export interface SlashCommand {
|
||||||
|
name: string;
|
||||||
|
altName?: string;
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
// The action to run. Optional for parent commands that only group sub-commands.
|
||||||
|
action?: (
|
||||||
|
context: CommandContext,
|
||||||
|
args: string,
|
||||||
|
) =>
|
||||||
|
| void
|
||||||
|
| SlashCommandActionReturn
|
||||||
|
| Promise<void | SlashCommandActionReturn>;
|
||||||
|
|
||||||
|
// Provides argument completion (e.g., completing a tag for `/chat resume <tag>`).
|
||||||
|
completion?: (
|
||||||
|
context: CommandContext,
|
||||||
|
partialArg: string,
|
||||||
|
) => Promise<string[]>;
|
||||||
|
|
||||||
|
subCommands?: SlashCommand[];
|
||||||
|
}
|
|
@ -7,7 +7,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { Colors } from '../colors.js';
|
import { Colors } from '../colors.js';
|
||||||
import { SlashCommand } from '../hooks/slashCommandProcessor.js';
|
import { SlashCommand } from '../commands/types.js';
|
||||||
|
|
||||||
interface Help {
|
interface Help {
|
||||||
commands: SlashCommand[];
|
commands: SlashCommand[];
|
||||||
|
@ -67,13 +67,25 @@ export const Help: React.FC<Help> = ({ commands }) => (
|
||||||
{commands
|
{commands
|
||||||
.filter((command) => command.description)
|
.filter((command) => command.description)
|
||||||
.map((command: SlashCommand) => (
|
.map((command: SlashCommand) => (
|
||||||
<Text key={command.name} color={Colors.Foreground}>
|
<Box key={command.name} flexDirection="column">
|
||||||
|
<Text color={Colors.Foreground}>
|
||||||
<Text bold color={Colors.AccentPurple}>
|
<Text bold color={Colors.AccentPurple}>
|
||||||
{' '}
|
{' '}
|
||||||
/{command.name}
|
/{command.name}
|
||||||
</Text>
|
</Text>
|
||||||
{command.description && ' - ' + command.description}
|
{command.description && ' - ' + command.description}
|
||||||
</Text>
|
</Text>
|
||||||
|
{command.subCommands &&
|
||||||
|
command.subCommands.map((subCommand) => (
|
||||||
|
<Text key={subCommand.name} color={Colors.Foreground}>
|
||||||
|
<Text> </Text>
|
||||||
|
<Text bold color={Colors.AccentPurple}>
|
||||||
|
{subCommand.name}
|
||||||
|
</Text>
|
||||||
|
{subCommand.description && ' - ' + subCommand.description}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
))}
|
))}
|
||||||
<Text color={Colors.Foreground}>
|
<Text color={Colors.Foreground}>
|
||||||
<Text bold color={Colors.AccentPurple}>
|
<Text bold color={Colors.AccentPurple}>
|
||||||
|
|
|
@ -8,10 +8,12 @@ import { render } from 'ink-testing-library';
|
||||||
import { InputPrompt, InputPromptProps } from './InputPrompt.js';
|
import { InputPrompt, InputPromptProps } from './InputPrompt.js';
|
||||||
import type { TextBuffer } from './shared/text-buffer.js';
|
import type { TextBuffer } from './shared/text-buffer.js';
|
||||||
import { Config } from '@google/gemini-cli-core';
|
import { Config } from '@google/gemini-cli-core';
|
||||||
|
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import { useShellHistory } from '../hooks/useShellHistory.js';
|
import { useShellHistory } from '../hooks/useShellHistory.js';
|
||||||
import { useCompletion } from '../hooks/useCompletion.js';
|
import { useCompletion } from '../hooks/useCompletion.js';
|
||||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||||
|
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||||
|
|
||||||
vi.mock('../hooks/useShellHistory.js');
|
vi.mock('../hooks/useShellHistory.js');
|
||||||
vi.mock('../hooks/useCompletion.js');
|
vi.mock('../hooks/useCompletion.js');
|
||||||
|
@ -21,12 +23,38 @@ type MockedUseShellHistory = ReturnType<typeof useShellHistory>;
|
||||||
type MockedUseCompletion = ReturnType<typeof useCompletion>;
|
type MockedUseCompletion = ReturnType<typeof useCompletion>;
|
||||||
type MockedUseInputHistory = ReturnType<typeof useInputHistory>;
|
type MockedUseInputHistory = ReturnType<typeof useInputHistory>;
|
||||||
|
|
||||||
|
const mockSlashCommands: SlashCommand[] = [
|
||||||
|
{ name: 'clear', description: 'Clear screen', action: vi.fn() },
|
||||||
|
{
|
||||||
|
name: 'memory',
|
||||||
|
description: 'Manage memory',
|
||||||
|
subCommands: [
|
||||||
|
{ name: 'show', description: 'Show memory', action: vi.fn() },
|
||||||
|
{ name: 'add', description: 'Add to memory', action: vi.fn() },
|
||||||
|
{ name: 'refresh', description: 'Refresh memory', action: vi.fn() },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'chat',
|
||||||
|
description: 'Manage chats',
|
||||||
|
subCommands: [
|
||||||
|
{
|
||||||
|
name: 'resume',
|
||||||
|
description: 'Resume a chat',
|
||||||
|
action: vi.fn(),
|
||||||
|
completion: async () => ['fix-foo', 'fix-bar'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
describe('InputPrompt', () => {
|
describe('InputPrompt', () => {
|
||||||
let props: InputPromptProps;
|
let props: InputPromptProps;
|
||||||
let mockShellHistory: MockedUseShellHistory;
|
let mockShellHistory: MockedUseShellHistory;
|
||||||
let mockCompletion: MockedUseCompletion;
|
let mockCompletion: MockedUseCompletion;
|
||||||
let mockInputHistory: MockedUseInputHistory;
|
let mockInputHistory: MockedUseInputHistory;
|
||||||
let mockBuffer: TextBuffer;
|
let mockBuffer: TextBuffer;
|
||||||
|
let mockCommandContext: CommandContext;
|
||||||
|
|
||||||
const mockedUseShellHistory = vi.mocked(useShellHistory);
|
const mockedUseShellHistory = vi.mocked(useShellHistory);
|
||||||
const mockedUseCompletion = vi.mocked(useCompletion);
|
const mockedUseCompletion = vi.mocked(useCompletion);
|
||||||
|
@ -35,6 +63,8 @@ describe('InputPrompt', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
|
||||||
|
mockCommandContext = createMockCommandContext();
|
||||||
|
|
||||||
mockBuffer = {
|
mockBuffer = {
|
||||||
text: '',
|
text: '',
|
||||||
cursor: [0, 0],
|
cursor: [0, 0],
|
||||||
|
@ -99,12 +129,15 @@ describe('InputPrompt', () => {
|
||||||
getTargetDir: () => '/test/project/src',
|
getTargetDir: () => '/test/project/src',
|
||||||
} as unknown as Config,
|
} as unknown as Config,
|
||||||
slashCommands: [],
|
slashCommands: [],
|
||||||
|
commandContext: mockCommandContext,
|
||||||
shellModeActive: false,
|
shellModeActive: false,
|
||||||
setShellModeActive: vi.fn(),
|
setShellModeActive: vi.fn(),
|
||||||
inputWidth: 80,
|
inputWidth: 80,
|
||||||
suggestionsWidth: 80,
|
suggestionsWidth: 80,
|
||||||
focus: true,
|
focus: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
props.slashCommands = mockSlashCommands;
|
||||||
});
|
});
|
||||||
|
|
||||||
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
|
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
@ -184,4 +217,194 @@ describe('InputPrompt', () => {
|
||||||
expect(props.onSubmit).toHaveBeenCalledWith('some text');
|
expect(props.onSubmit).toHaveBeenCalledWith('some text');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should complete a partial parent command and add a space', async () => {
|
||||||
|
// SCENARIO: /mem -> Tab
|
||||||
|
mockedUseCompletion.mockReturnValue({
|
||||||
|
...mockCompletion,
|
||||||
|
showSuggestions: true,
|
||||||
|
suggestions: [{ label: 'memory', value: 'memory', description: '...' }],
|
||||||
|
activeSuggestionIndex: 0,
|
||||||
|
});
|
||||||
|
props.buffer.setText('/mem');
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
stdin.write('\t'); // Press Tab
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(props.buffer.setText).toHaveBeenCalledWith('/memory ');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should append a sub-command when the parent command is already complete with a space', async () => {
|
||||||
|
// SCENARIO: /memory -> Tab (to accept 'add')
|
||||||
|
mockedUseCompletion.mockReturnValue({
|
||||||
|
...mockCompletion,
|
||||||
|
showSuggestions: true,
|
||||||
|
suggestions: [
|
||||||
|
{ label: 'show', value: 'show' },
|
||||||
|
{ label: 'add', value: 'add' },
|
||||||
|
],
|
||||||
|
activeSuggestionIndex: 1, // 'add' is highlighted
|
||||||
|
});
|
||||||
|
props.buffer.setText('/memory ');
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
stdin.write('\t'); // Press Tab
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(props.buffer.setText).toHaveBeenCalledWith('/memory add ');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle the "backspace" edge case correctly', async () => {
|
||||||
|
// SCENARIO: /memory -> Backspace -> /memory -> Tab (to accept 'show')
|
||||||
|
// This is the critical bug we fixed.
|
||||||
|
mockedUseCompletion.mockReturnValue({
|
||||||
|
...mockCompletion,
|
||||||
|
showSuggestions: true,
|
||||||
|
suggestions: [
|
||||||
|
{ label: 'show', value: 'show' },
|
||||||
|
{ label: 'add', value: 'add' },
|
||||||
|
],
|
||||||
|
activeSuggestionIndex: 0, // 'show' is highlighted
|
||||||
|
});
|
||||||
|
// The user has backspaced, so the query is now just '/memory'
|
||||||
|
props.buffer.setText('/memory');
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
stdin.write('\t'); // Press Tab
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// It should NOT become '/show '. It should correctly become '/memory show '.
|
||||||
|
expect(props.buffer.setText).toHaveBeenCalledWith('/memory show ');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should complete a partial argument for a command', async () => {
|
||||||
|
// SCENARIO: /chat resume fi- -> Tab
|
||||||
|
mockedUseCompletion.mockReturnValue({
|
||||||
|
...mockCompletion,
|
||||||
|
showSuggestions: true,
|
||||||
|
suggestions: [{ label: 'fix-foo', value: 'fix-foo' }],
|
||||||
|
activeSuggestionIndex: 0,
|
||||||
|
});
|
||||||
|
props.buffer.setText('/chat resume fi-');
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
stdin.write('\t'); // Press Tab
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(props.buffer.setText).toHaveBeenCalledWith('/chat resume fix-foo ');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should autocomplete on Enter when suggestions are active, without submitting', async () => {
|
||||||
|
mockedUseCompletion.mockReturnValue({
|
||||||
|
...mockCompletion,
|
||||||
|
showSuggestions: true,
|
||||||
|
suggestions: [{ label: 'memory', value: 'memory' }],
|
||||||
|
activeSuggestionIndex: 0,
|
||||||
|
});
|
||||||
|
props.buffer.setText('/mem');
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
stdin.write('\r');
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// The app should autocomplete the text, NOT submit.
|
||||||
|
expect(props.buffer.setText).toHaveBeenCalledWith('/memory ');
|
||||||
|
|
||||||
|
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should complete a command based on its altName', async () => {
|
||||||
|
// Add a command with an altName to our mock for this test
|
||||||
|
props.slashCommands.push({
|
||||||
|
name: 'help',
|
||||||
|
altName: '?',
|
||||||
|
description: '...',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockedUseCompletion.mockReturnValue({
|
||||||
|
...mockCompletion,
|
||||||
|
showSuggestions: true,
|
||||||
|
suggestions: [{ label: 'help', value: 'help' }],
|
||||||
|
activeSuggestionIndex: 0,
|
||||||
|
});
|
||||||
|
props.buffer.setText('/?');
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
stdin.write('\t'); // Press Tab
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(props.buffer.setText).toHaveBeenCalledWith('/help ');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ADD this test for defensive coverage
|
||||||
|
|
||||||
|
it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
|
||||||
|
props.buffer.setText(' '); // Set buffer to whitespace
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
stdin.write('\r'); // Press Enter
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should submit directly on Enter when a complete leaf command is typed', async () => {
|
||||||
|
mockedUseCompletion.mockReturnValue({
|
||||||
|
...mockCompletion,
|
||||||
|
showSuggestions: false,
|
||||||
|
});
|
||||||
|
props.buffer.setText('/clear');
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
stdin.write('\r');
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(props.onSubmit).toHaveBeenCalledWith('/clear');
|
||||||
|
expect(props.buffer.setText).not.toHaveBeenCalledWith('/clear ');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should autocomplete an @-path on Enter without submitting', async () => {
|
||||||
|
mockedUseCompletion.mockReturnValue({
|
||||||
|
...mockCompletion,
|
||||||
|
showSuggestions: true,
|
||||||
|
suggestions: [{ label: 'index.ts', value: 'index.ts' }],
|
||||||
|
activeSuggestionIndex: 0,
|
||||||
|
});
|
||||||
|
props.buffer.setText('@src/components/');
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
stdin.write('\r');
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(props.buffer.replaceRangeByOffset).toHaveBeenCalled();
|
||||||
|
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -13,12 +13,11 @@ import { TextBuffer } from './shared/text-buffer.js';
|
||||||
import { cpSlice, cpLen } from '../utils/textUtils.js';
|
import { cpSlice, cpLen } from '../utils/textUtils.js';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import stringWidth from 'string-width';
|
import stringWidth from 'string-width';
|
||||||
import process from 'node:process';
|
|
||||||
import { useShellHistory } from '../hooks/useShellHistory.js';
|
import { useShellHistory } from '../hooks/useShellHistory.js';
|
||||||
import { useCompletion } from '../hooks/useCompletion.js';
|
import { useCompletion } from '../hooks/useCompletion.js';
|
||||||
import { useKeypress, Key } from '../hooks/useKeypress.js';
|
import { useKeypress, Key } from '../hooks/useKeypress.js';
|
||||||
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
|
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
|
||||||
import { SlashCommand } from '../hooks/slashCommandProcessor.js';
|
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||||
import { Config } from '@google/gemini-cli-core';
|
import { Config } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
export interface InputPromptProps {
|
export interface InputPromptProps {
|
||||||
|
@ -26,8 +25,9 @@ export interface InputPromptProps {
|
||||||
onSubmit: (value: string) => void;
|
onSubmit: (value: string) => void;
|
||||||
userMessages: readonly string[];
|
userMessages: readonly string[];
|
||||||
onClearScreen: () => void;
|
onClearScreen: () => void;
|
||||||
config: Config; // Added config for useCompletion
|
config: Config;
|
||||||
slashCommands: SlashCommand[]; // Added slashCommands for useCompletion
|
slashCommands: SlashCommand[];
|
||||||
|
commandContext: CommandContext;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
focus?: boolean;
|
focus?: boolean;
|
||||||
inputWidth: number;
|
inputWidth: number;
|
||||||
|
@ -43,6 +43,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
onClearScreen,
|
onClearScreen,
|
||||||
config,
|
config,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
|
commandContext,
|
||||||
placeholder = ' Type your message or @path/to/file',
|
placeholder = ' Type your message or @path/to/file',
|
||||||
focus = true,
|
focus = true,
|
||||||
inputWidth,
|
inputWidth,
|
||||||
|
@ -57,6 +58,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
config.getTargetDir(),
|
config.getTargetDir(),
|
||||||
isAtCommand(buffer.text) || isSlashCommand(buffer.text),
|
isAtCommand(buffer.text) || isSlashCommand(buffer.text),
|
||||||
slashCommands,
|
slashCommands,
|
||||||
|
commandContext,
|
||||||
config,
|
config,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -116,28 +118,46 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
const suggestion = completionSuggestions[indexToUse].value;
|
const suggestion = completionSuggestions[indexToUse].value;
|
||||||
|
|
||||||
if (query.trimStart().startsWith('/')) {
|
if (query.trimStart().startsWith('/')) {
|
||||||
const parts = query.trimStart().substring(1).split(' ');
|
const hasTrailingSpace = query.endsWith(' ');
|
||||||
const commandName = parts[0];
|
const parts = query
|
||||||
const slashIndex = query.indexOf('/');
|
.trimStart()
|
||||||
const base = query.substring(0, slashIndex + 1);
|
.substring(1)
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
const command = slashCommands.find((cmd) => cmd.name === commandName);
|
let isParentPath = false;
|
||||||
// Make sure completion isn't the original command when command.completion hasn't happened yet.
|
// If there's no trailing space, we need to check if the current query
|
||||||
if (command && command.completion && suggestion !== commandName) {
|
// is already a complete path to a parent command.
|
||||||
const newValue = `${base}${commandName} ${suggestion}`;
|
if (!hasTrailingSpace) {
|
||||||
if (newValue === query) {
|
let currentLevel: SlashCommand[] | undefined = slashCommands;
|
||||||
handleSubmitAndClear(newValue);
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
const found: SlashCommand | undefined = currentLevel?.find(
|
||||||
|
(cmd) => cmd.name === part || cmd.altName === part,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
if (i === parts.length - 1 && found.subCommands) {
|
||||||
|
isParentPath = true;
|
||||||
|
}
|
||||||
|
currentLevel = found.subCommands;
|
||||||
} else {
|
} else {
|
||||||
|
// Path is invalid, so it can't be a parent path.
|
||||||
|
currentLevel = undefined;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the base path of the command.
|
||||||
|
// - If there's a trailing space, the whole command is the base.
|
||||||
|
// - If it's a known parent path, the whole command is the base.
|
||||||
|
// - Otherwise, the base is everything EXCEPT the last partial part.
|
||||||
|
const basePath =
|
||||||
|
hasTrailingSpace || isParentPath ? parts : parts.slice(0, -1);
|
||||||
|
const newValue = `/${[...basePath, suggestion].join(' ')} `;
|
||||||
|
|
||||||
buffer.setText(newValue);
|
buffer.setText(newValue);
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const newValue = base + suggestion;
|
|
||||||
if (newValue === query) {
|
|
||||||
handleSubmitAndClear(newValue);
|
|
||||||
} else {
|
|
||||||
buffer.setText(newValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const atIndex = query.lastIndexOf('@');
|
const atIndex = query.lastIndexOf('@');
|
||||||
if (atIndex === -1) return;
|
if (atIndex === -1) return;
|
||||||
|
@ -155,13 +175,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
}
|
}
|
||||||
resetCompletionState();
|
resetCompletionState();
|
||||||
},
|
},
|
||||||
[
|
[resetCompletionState, buffer, completionSuggestions, slashCommands],
|
||||||
resetCompletionState,
|
|
||||||
handleSubmitAndClear,
|
|
||||||
buffer,
|
|
||||||
completionSuggestions,
|
|
||||||
slashCommands,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleInput = useCallback(
|
const handleInput = useCallback(
|
||||||
|
@ -169,12 +183,32 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
if (!focus) {
|
if (!focus) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const query = buffer.text;
|
|
||||||
|
|
||||||
if (key.sequence === '!' && query === '' && !completion.showSuggestions) {
|
if (
|
||||||
|
key.sequence === '!' &&
|
||||||
|
buffer.text === '' &&
|
||||||
|
!completion.showSuggestions
|
||||||
|
) {
|
||||||
setShellModeActive(!shellModeActive);
|
setShellModeActive(!shellModeActive);
|
||||||
buffer.setText(''); // Clear the '!' from input
|
buffer.setText(''); // Clear the '!' from input
|
||||||
return true;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.name === 'escape') {
|
||||||
|
if (shellModeActive) {
|
||||||
|
setShellModeActive(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completion.showSuggestions) {
|
||||||
|
completion.resetCompletionState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.ctrl && key.name === 'l') {
|
||||||
|
onClearScreen();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completion.showSuggestions) {
|
if (completion.showSuggestions) {
|
||||||
|
@ -186,11 +220,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
completion.navigateDown();
|
completion.navigateDown();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key.name === 'tab') {
|
|
||||||
|
if (key.name === 'tab' || (key.name === 'return' && !key.ctrl)) {
|
||||||
if (completion.suggestions.length > 0) {
|
if (completion.suggestions.length > 0) {
|
||||||
const targetIndex =
|
const targetIndex =
|
||||||
completion.activeSuggestionIndex === -1
|
completion.activeSuggestionIndex === -1
|
||||||
? 0
|
? 0 // Default to the first if none is active
|
||||||
: completion.activeSuggestionIndex;
|
: completion.activeSuggestionIndex;
|
||||||
if (targetIndex < completion.suggestions.length) {
|
if (targetIndex < completion.suggestions.length) {
|
||||||
handleAutocomplete(targetIndex);
|
handleAutocomplete(targetIndex);
|
||||||
|
@ -198,20 +233,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key.name === 'return') {
|
|
||||||
if (completion.activeSuggestionIndex >= 0) {
|
|
||||||
handleAutocomplete(completion.activeSuggestionIndex);
|
|
||||||
} else if (query.trim()) {
|
|
||||||
handleSubmitAndClear(query);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Keybindings when suggestions are not shown
|
if (!shellModeActive) {
|
||||||
if (key.ctrl && key.name === 'l') {
|
|
||||||
onClearScreen();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key.ctrl && key.name === 'p') {
|
if (key.ctrl && key.name === 'p') {
|
||||||
inputHistory.navigateUp();
|
inputHistory.navigateUp();
|
||||||
return;
|
return;
|
||||||
|
@ -220,45 +243,62 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
inputHistory.navigateDown();
|
inputHistory.navigateDown();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key.name === 'escape') {
|
// Handle arrow-up/down for history on single-line or at edges
|
||||||
if (shellModeActive) {
|
if (
|
||||||
setShellModeActive(false);
|
key.name === 'up' &&
|
||||||
return;
|
(buffer.allVisualLines.length === 1 ||
|
||||||
}
|
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
|
||||||
completion.resetCompletionState();
|
) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+A (Home)
|
|
||||||
if (key.ctrl && key.name === 'a') {
|
|
||||||
buffer.move('home');
|
|
||||||
buffer.moveToOffset(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Ctrl+E (End)
|
|
||||||
if (key.ctrl && key.name === 'e') {
|
|
||||||
buffer.move('end');
|
|
||||||
buffer.moveToOffset(cpLen(buffer.text));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Ctrl+L (Clear Screen)
|
|
||||||
if (key.ctrl && key.name === 'l') {
|
|
||||||
onClearScreen();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Ctrl+P (History Up)
|
|
||||||
if (key.ctrl && key.name === 'p' && !completion.showSuggestions) {
|
|
||||||
inputHistory.navigateUp();
|
inputHistory.navigateUp();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Ctrl+N (History Down)
|
if (
|
||||||
if (key.ctrl && key.name === 'n' && !completion.showSuggestions) {
|
key.name === 'down' &&
|
||||||
|
(buffer.allVisualLines.length === 1 ||
|
||||||
|
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
|
||||||
|
) {
|
||||||
inputHistory.navigateDown();
|
inputHistory.navigateDown();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Shell History Navigation
|
||||||
|
if (key.name === 'up') {
|
||||||
|
const prevCommand = shellHistory.getPreviousCommand();
|
||||||
|
if (prevCommand !== null) buffer.setText(prevCommand);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.name === 'down') {
|
||||||
|
const nextCommand = shellHistory.getNextCommand();
|
||||||
|
if (nextCommand !== null) buffer.setText(nextCommand);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Core text editing from MultilineTextEditor's useInput
|
if (key.name === 'return' && !key.ctrl && !key.meta && !key.paste) {
|
||||||
|
if (buffer.text.trim()) {
|
||||||
|
handleSubmitAndClear(buffer.text);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newline insertion
|
||||||
|
if (key.name === 'return' && (key.ctrl || key.meta || key.paste)) {
|
||||||
|
buffer.newline();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+A (Home) / Ctrl+E (End)
|
||||||
|
if (key.ctrl && key.name === 'a') {
|
||||||
|
buffer.move('home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.ctrl && key.name === 'e') {
|
||||||
|
buffer.move('end');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill line commands
|
||||||
if (key.ctrl && key.name === 'k') {
|
if (key.ctrl && key.name === 'k') {
|
||||||
buffer.killLineRight();
|
buffer.killLineRight();
|
||||||
return;
|
return;
|
||||||
|
@ -267,97 +307,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
buffer.killLineLeft();
|
buffer.killLineLeft();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const isCtrlX =
|
|
||||||
(key.ctrl && (key.name === 'x' || key.sequence === '\x18')) ||
|
|
||||||
key.sequence === '\x18';
|
|
||||||
const isCtrlEFromEditor =
|
|
||||||
(key.ctrl && (key.name === 'e' || key.sequence === '\x05')) ||
|
|
||||||
key.sequence === '\x05' ||
|
|
||||||
(!key.ctrl &&
|
|
||||||
key.name === 'e' &&
|
|
||||||
key.sequence.length === 1 &&
|
|
||||||
key.sequence.charCodeAt(0) === 5);
|
|
||||||
|
|
||||||
if (isCtrlX || isCtrlEFromEditor) {
|
// External editor
|
||||||
if (isCtrlEFromEditor && !(key.ctrl && key.name === 'e')) {
|
const isCtrlX = key.ctrl && (key.name === 'x' || key.sequence === '\x18');
|
||||||
// Avoid double handling Ctrl+E
|
|
||||||
buffer.openInExternalEditor();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isCtrlX) {
|
if (isCtrlX) {
|
||||||
buffer.openInExternalEditor();
|
buffer.openInExternalEditor();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
// Fallback to the text buffer's default input handling for all other keys
|
||||||
process.env['TEXTBUFFER_DEBUG'] === '1' ||
|
|
||||||
process.env['TEXTBUFFER_DEBUG'] === 'true'
|
|
||||||
) {
|
|
||||||
console.log('[InputPromptCombined] event', { key });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+Enter for newline, Enter for submit
|
|
||||||
if (key.name === 'return') {
|
|
||||||
const [row, col] = buffer.cursor;
|
|
||||||
const line = buffer.lines[row];
|
|
||||||
const charBefore = col > 0 ? cpSlice(line, col - 1, col) : '';
|
|
||||||
if (key.ctrl || key.meta || charBefore === '\\' || key.paste) {
|
|
||||||
// Ctrl+Enter or escaped newline
|
|
||||||
if (charBefore === '\\') {
|
|
||||||
buffer.backspace();
|
|
||||||
}
|
|
||||||
buffer.newline();
|
|
||||||
} else {
|
|
||||||
// Enter for submit
|
|
||||||
if (query.trim()) {
|
|
||||||
handleSubmitAndClear(query);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standard arrow navigation within the buffer
|
|
||||||
if (key.name === 'up' && !completion.showSuggestions) {
|
|
||||||
if (shellModeActive) {
|
|
||||||
const prevCommand = shellHistory.getPreviousCommand();
|
|
||||||
if (prevCommand !== null) {
|
|
||||||
buffer.setText(prevCommand);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(buffer.allVisualLines.length === 1 || // Always navigate for single line
|
|
||||||
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0)) &&
|
|
||||||
inputHistory.navigateUp
|
|
||||||
) {
|
|
||||||
inputHistory.navigateUp();
|
|
||||||
} else {
|
|
||||||
buffer.move('up');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key.name === 'down' && !completion.showSuggestions) {
|
|
||||||
if (shellModeActive) {
|
|
||||||
const nextCommand = shellHistory.getNextCommand();
|
|
||||||
if (nextCommand !== null) {
|
|
||||||
buffer.setText(nextCommand);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(buffer.allVisualLines.length === 1 || // Always navigate for single line
|
|
||||||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1) &&
|
|
||||||
inputHistory.navigateDown
|
|
||||||
) {
|
|
||||||
inputHistory.navigateDown();
|
|
||||||
} else {
|
|
||||||
buffer.move('down');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to buffer's default input handling
|
|
||||||
buffer.handleInput(key);
|
buffer.handleInput(key);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
|
|
@ -22,7 +22,7 @@ import {
|
||||||
|
|
||||||
export type { SessionMetrics, ModelMetrics };
|
export type { SessionMetrics, ModelMetrics };
|
||||||
|
|
||||||
interface SessionStatsState {
|
export interface SessionStatsState {
|
||||||
sessionStartTime: Date;
|
sessionStartTime: Date;
|
||||||
metrics: SessionMetrics;
|
metrics: SessionMetrics;
|
||||||
lastPromptTokenCount: number;
|
lastPromptTokenCount: number;
|
||||||
|
|
|
@ -56,11 +56,8 @@ vi.mock('../../utils/version.js', () => ({
|
||||||
import { act, renderHook } from '@testing-library/react';
|
import { act, renderHook } from '@testing-library/react';
|
||||||
import { vi, describe, it, expect, beforeEach, afterEach, Mock } from 'vitest';
|
import { vi, describe, it, expect, beforeEach, afterEach, Mock } from 'vitest';
|
||||||
import open from 'open';
|
import open from 'open';
|
||||||
import {
|
import { useSlashCommandProcessor } from './slashCommandProcessor.js';
|
||||||
useSlashCommandProcessor,
|
import { MessageType, SlashCommandProcessorResult } from '../types.js';
|
||||||
type SlashCommandActionReturn,
|
|
||||||
} from './slashCommandProcessor.js';
|
|
||||||
import { MessageType } from '../types.js';
|
|
||||||
import {
|
import {
|
||||||
Config,
|
Config,
|
||||||
MCPDiscoveryState,
|
MCPDiscoveryState,
|
||||||
|
@ -73,11 +70,15 @@ import { useSessionStats } from '../contexts/SessionContext.js';
|
||||||
import { LoadedSettings } from '../../config/settings.js';
|
import { LoadedSettings } from '../../config/settings.js';
|
||||||
import * as ShowMemoryCommandModule from './useShowMemoryCommand.js';
|
import * as ShowMemoryCommandModule from './useShowMemoryCommand.js';
|
||||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||||
|
import { CommandService } from '../../services/CommandService.js';
|
||||||
|
import { SlashCommand } from '../commands/types.js';
|
||||||
|
|
||||||
vi.mock('../contexts/SessionContext.js', () => ({
|
vi.mock('../contexts/SessionContext.js', () => ({
|
||||||
useSessionStats: vi.fn(),
|
useSessionStats: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../services/CommandService.js');
|
||||||
|
|
||||||
vi.mock('./useShowMemoryCommand.js', () => ({
|
vi.mock('./useShowMemoryCommand.js', () => ({
|
||||||
SHOW_MEMORY_COMMAND_NAME: '/memory show',
|
SHOW_MEMORY_COMMAND_NAME: '/memory show',
|
||||||
createShowMemoryAction: vi.fn(() => vi.fn()),
|
createShowMemoryAction: vi.fn(() => vi.fn()),
|
||||||
|
@ -87,6 +88,16 @@ vi.mock('open', () => ({
|
||||||
default: vi.fn(),
|
default: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getMCPServerStatus: vi.fn(),
|
||||||
|
getMCPDiscoveryState: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe('useSlashCommandProcessor', () => {
|
describe('useSlashCommandProcessor', () => {
|
||||||
let mockAddItem: ReturnType<typeof vi.fn>;
|
let mockAddItem: ReturnType<typeof vi.fn>;
|
||||||
let mockClearItems: ReturnType<typeof vi.fn>;
|
let mockClearItems: ReturnType<typeof vi.fn>;
|
||||||
|
@ -97,7 +108,6 @@ describe('useSlashCommandProcessor', () => {
|
||||||
let mockOpenThemeDialog: ReturnType<typeof vi.fn>;
|
let mockOpenThemeDialog: ReturnType<typeof vi.fn>;
|
||||||
let mockOpenAuthDialog: ReturnType<typeof vi.fn>;
|
let mockOpenAuthDialog: ReturnType<typeof vi.fn>;
|
||||||
let mockOpenEditorDialog: ReturnType<typeof vi.fn>;
|
let mockOpenEditorDialog: ReturnType<typeof vi.fn>;
|
||||||
let mockPerformMemoryRefresh: ReturnType<typeof vi.fn>;
|
|
||||||
let mockSetQuittingMessages: ReturnType<typeof vi.fn>;
|
let mockSetQuittingMessages: ReturnType<typeof vi.fn>;
|
||||||
let mockTryCompressChat: ReturnType<typeof vi.fn>;
|
let mockTryCompressChat: ReturnType<typeof vi.fn>;
|
||||||
let mockGeminiClient: GeminiClient;
|
let mockGeminiClient: GeminiClient;
|
||||||
|
@ -106,6 +116,20 @@ describe('useSlashCommandProcessor', () => {
|
||||||
const mockUseSessionStats = useSessionStats as Mock;
|
const mockUseSessionStats = useSessionStats as Mock;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
// Reset all mocks to clear any previous state or calls.
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Default mock setup for CommandService for all the OLD tests.
|
||||||
|
// This makes them pass again by simulating the original behavior where
|
||||||
|
// the service is constructed but doesn't do much yet.
|
||||||
|
vi.mocked(CommandService).mockImplementation(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
loadCommands: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getCommands: vi.fn().mockReturnValue([]), // Return an empty array by default
|
||||||
|
}) as unknown as CommandService,
|
||||||
|
);
|
||||||
|
|
||||||
mockAddItem = vi.fn();
|
mockAddItem = vi.fn();
|
||||||
mockClearItems = vi.fn();
|
mockClearItems = vi.fn();
|
||||||
mockLoadHistory = vi.fn();
|
mockLoadHistory = vi.fn();
|
||||||
|
@ -115,7 +139,6 @@ describe('useSlashCommandProcessor', () => {
|
||||||
mockOpenThemeDialog = vi.fn();
|
mockOpenThemeDialog = vi.fn();
|
||||||
mockOpenAuthDialog = vi.fn();
|
mockOpenAuthDialog = vi.fn();
|
||||||
mockOpenEditorDialog = vi.fn();
|
mockOpenEditorDialog = vi.fn();
|
||||||
mockPerformMemoryRefresh = vi.fn().mockResolvedValue(undefined);
|
|
||||||
mockSetQuittingMessages = vi.fn();
|
mockSetQuittingMessages = vi.fn();
|
||||||
mockTryCompressChat = vi.fn();
|
mockTryCompressChat = vi.fn();
|
||||||
mockGeminiClient = {
|
mockGeminiClient = {
|
||||||
|
@ -129,6 +152,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
getProjectRoot: vi.fn(() => '/test/dir'),
|
getProjectRoot: vi.fn(() => '/test/dir'),
|
||||||
getCheckpointingEnabled: vi.fn(() => true),
|
getCheckpointingEnabled: vi.fn(() => true),
|
||||||
getBugCommand: vi.fn(() => undefined),
|
getBugCommand: vi.fn(() => undefined),
|
||||||
|
getSessionId: vi.fn(() => 'test-session-id'),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
mockCorgiMode = vi.fn();
|
mockCorgiMode = vi.fn();
|
||||||
mockUseSessionStats.mockReturnValue({
|
mockUseSessionStats.mockReturnValue({
|
||||||
|
@ -149,7 +173,6 @@ describe('useSlashCommandProcessor', () => {
|
||||||
(open as Mock).mockClear();
|
(open as Mock).mockClear();
|
||||||
mockProcessExit.mockClear();
|
mockProcessExit.mockClear();
|
||||||
(ShowMemoryCommandModule.createShowMemoryAction as Mock).mockClear();
|
(ShowMemoryCommandModule.createShowMemoryAction as Mock).mockClear();
|
||||||
mockPerformMemoryRefresh.mockClear();
|
|
||||||
process.env = { ...globalThis.process.env };
|
process.env = { ...globalThis.process.env };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -158,7 +181,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
merged: {
|
merged: {
|
||||||
contextFileName: 'GEMINI.md',
|
contextFileName: 'GEMINI.md',
|
||||||
},
|
},
|
||||||
} as LoadedSettings;
|
} as unknown as LoadedSettings;
|
||||||
return renderHook(() =>
|
return renderHook(() =>
|
||||||
useSlashCommandProcessor(
|
useSlashCommandProcessor(
|
||||||
mockConfig,
|
mockConfig,
|
||||||
|
@ -173,10 +196,10 @@ describe('useSlashCommandProcessor', () => {
|
||||||
mockOpenThemeDialog,
|
mockOpenThemeDialog,
|
||||||
mockOpenAuthDialog,
|
mockOpenAuthDialog,
|
||||||
mockOpenEditorDialog,
|
mockOpenEditorDialog,
|
||||||
mockPerformMemoryRefresh,
|
|
||||||
mockCorgiMode,
|
mockCorgiMode,
|
||||||
showToolDescriptions,
|
showToolDescriptions,
|
||||||
mockSetQuittingMessages,
|
mockSetQuittingMessages,
|
||||||
|
vi.fn(), // mockOpenPrivacyNotice
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -184,115 +207,6 @@ describe('useSlashCommandProcessor', () => {
|
||||||
const getProcessor = (showToolDescriptions: boolean = false) =>
|
const getProcessor = (showToolDescriptions: boolean = false) =>
|
||||||
getProcessorHook(showToolDescriptions).result.current;
|
getProcessorHook(showToolDescriptions).result.current;
|
||||||
|
|
||||||
describe('/memory add', () => {
|
|
||||||
it('should return tool scheduling info on valid input', async () => {
|
|
||||||
const { handleSlashCommand } = getProcessor();
|
|
||||||
const fact = 'Remember this fact';
|
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
|
||||||
await act(async () => {
|
|
||||||
commandResult = await handleSlashCommand(`/memory add ${fact}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockAddItem).toHaveBeenNthCalledWith(
|
|
||||||
1, // User message
|
|
||||||
expect.objectContaining({
|
|
||||||
type: MessageType.USER,
|
|
||||||
text: `/memory add ${fact}`,
|
|
||||||
}),
|
|
||||||
expect.any(Number),
|
|
||||||
);
|
|
||||||
expect(mockAddItem).toHaveBeenNthCalledWith(
|
|
||||||
2, // Info message about attempting to save
|
|
||||||
expect.objectContaining({
|
|
||||||
type: MessageType.INFO,
|
|
||||||
text: `Attempting to save to memory: "${fact}"`,
|
|
||||||
}),
|
|
||||||
expect.any(Number),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(commandResult).toEqual({
|
|
||||||
shouldScheduleTool: true,
|
|
||||||
toolName: 'save_memory',
|
|
||||||
toolArgs: { fact },
|
|
||||||
});
|
|
||||||
|
|
||||||
// performMemoryRefresh is no longer called directly here
|
|
||||||
expect(mockPerformMemoryRefresh).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show usage error and return true if no text is provided', async () => {
|
|
||||||
const { handleSlashCommand } = getProcessor();
|
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
|
||||||
await act(async () => {
|
|
||||||
commandResult = await handleSlashCommand('/memory add ');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockAddItem).toHaveBeenNthCalledWith(
|
|
||||||
2, // After user message
|
|
||||||
expect.objectContaining({
|
|
||||||
type: MessageType.ERROR,
|
|
||||||
text: 'Usage: /memory add <text to remember>',
|
|
||||||
}),
|
|
||||||
expect.any(Number),
|
|
||||||
);
|
|
||||||
expect(commandResult).toBe(true); // Command was handled (by showing an error)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('/memory show', () => {
|
|
||||||
it('should call the showMemoryAction and return true', async () => {
|
|
||||||
const mockReturnedShowAction = vi.fn();
|
|
||||||
vi.mocked(ShowMemoryCommandModule.createShowMemoryAction).mockReturnValue(
|
|
||||||
mockReturnedShowAction,
|
|
||||||
);
|
|
||||||
const { handleSlashCommand } = getProcessor();
|
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
|
||||||
await act(async () => {
|
|
||||||
commandResult = await handleSlashCommand('/memory show');
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
ShowMemoryCommandModule.createShowMemoryAction,
|
|
||||||
).toHaveBeenCalledWith(
|
|
||||||
mockConfig,
|
|
||||||
expect.any(Object),
|
|
||||||
expect.any(Function),
|
|
||||||
);
|
|
||||||
expect(mockReturnedShowAction).toHaveBeenCalled();
|
|
||||||
expect(commandResult).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('/memory refresh', () => {
|
|
||||||
it('should call performMemoryRefresh and return true', async () => {
|
|
||||||
const { handleSlashCommand } = getProcessor();
|
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
|
||||||
await act(async () => {
|
|
||||||
commandResult = await handleSlashCommand('/memory refresh');
|
|
||||||
});
|
|
||||||
expect(mockPerformMemoryRefresh).toHaveBeenCalled();
|
|
||||||
expect(commandResult).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Unknown /memory subcommand', () => {
|
|
||||||
it('should show an error for unknown /memory subcommand and return true', async () => {
|
|
||||||
const { handleSlashCommand } = getProcessor();
|
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
|
||||||
await act(async () => {
|
|
||||||
commandResult = await handleSlashCommand('/memory foobar');
|
|
||||||
});
|
|
||||||
expect(mockAddItem).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
expect.objectContaining({
|
|
||||||
type: MessageType.ERROR,
|
|
||||||
text: 'Unknown /memory command: foobar. Available: show, refresh, add',
|
|
||||||
}),
|
|
||||||
expect.any(Number),
|
|
||||||
);
|
|
||||||
expect(commandResult).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('/stats command', () => {
|
describe('/stats command', () => {
|
||||||
it('should show detailed session statistics', async () => {
|
it('should show detailed session statistics', async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
@ -376,7 +290,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
selectedAuthType: 'test-auth-type',
|
selectedAuthType: 'test-auth-type',
|
||||||
contextFileName: 'GEMINI.md',
|
contextFileName: 'GEMINI.md',
|
||||||
},
|
},
|
||||||
} as LoadedSettings;
|
} as unknown as LoadedSettings;
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useSlashCommandProcessor(
|
useSlashCommandProcessor(
|
||||||
|
@ -392,10 +306,10 @@ describe('useSlashCommandProcessor', () => {
|
||||||
mockOpenThemeDialog,
|
mockOpenThemeDialog,
|
||||||
mockOpenAuthDialog,
|
mockOpenAuthDialog,
|
||||||
mockOpenEditorDialog,
|
mockOpenEditorDialog,
|
||||||
mockPerformMemoryRefresh,
|
|
||||||
mockCorgiMode,
|
mockCorgiMode,
|
||||||
false,
|
false,
|
||||||
mockSetQuittingMessages,
|
mockSetQuittingMessages,
|
||||||
|
vi.fn(), // mockOpenPrivacyNotice
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -447,45 +361,187 @@ describe('useSlashCommandProcessor', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Other commands', () => {
|
describe('Other commands', () => {
|
||||||
it('/help should open help and return true', async () => {
|
it('/editor should open editor dialog and return handled', async () => {
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandProcessorResult | false = false;
|
||||||
await act(async () => {
|
|
||||||
commandResult = await handleSlashCommand('/help');
|
|
||||||
});
|
|
||||||
expect(mockSetShowHelp).toHaveBeenCalledWith(true);
|
|
||||||
expect(commandResult).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('/clear should clear items, reset chat, and refresh static', async () => {
|
|
||||||
const mockResetChat = vi.fn();
|
|
||||||
mockConfig = {
|
|
||||||
...mockConfig,
|
|
||||||
getGeminiClient: () => ({
|
|
||||||
resetChat: mockResetChat,
|
|
||||||
}),
|
|
||||||
} as unknown as Config;
|
|
||||||
|
|
||||||
const { handleSlashCommand } = getProcessor();
|
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
|
||||||
await act(async () => {
|
|
||||||
commandResult = await handleSlashCommand('/clear');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockClearItems).toHaveBeenCalled();
|
|
||||||
expect(mockResetChat).toHaveBeenCalled();
|
|
||||||
expect(mockRefreshStatic).toHaveBeenCalled();
|
|
||||||
expect(commandResult).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('/editor should open editor dialog and return true', async () => {
|
|
||||||
const { handleSlashCommand } = getProcessor();
|
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = await handleSlashCommand('/editor');
|
commandResult = await handleSlashCommand('/editor');
|
||||||
});
|
});
|
||||||
expect(mockOpenEditorDialog).toHaveBeenCalled();
|
expect(mockOpenEditorDialog).toHaveBeenCalled();
|
||||||
expect(commandResult).toBe(true);
|
expect(commandResult).toEqual({ type: 'handled' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('New command registry', () => {
|
||||||
|
let ActualCommandService: typeof CommandService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const actual = (await vi.importActual(
|
||||||
|
'../../services/CommandService.js',
|
||||||
|
)) as { CommandService: typeof CommandService };
|
||||||
|
ActualCommandService = actual.CommandService;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should execute a command from the new registry', async () => {
|
||||||
|
const mockAction = vi.fn();
|
||||||
|
const newCommand: SlashCommand = { name: 'test', action: mockAction };
|
||||||
|
const mockLoader = async () => [newCommand];
|
||||||
|
|
||||||
|
// We create the instance outside the mock implementation.
|
||||||
|
const commandServiceInstance = new ActualCommandService(mockLoader);
|
||||||
|
|
||||||
|
// This mock ensures the hook uses our pre-configured instance.
|
||||||
|
vi.mocked(CommandService).mockImplementation(
|
||||||
|
() => commandServiceInstance,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = getProcessorHook();
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
// We check that the `slashCommands` array, which is the public API
|
||||||
|
// of our hook, eventually contains the command we injected.
|
||||||
|
expect(
|
||||||
|
result.current.slashCommands.some((c) => c.name === 'test'),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
let commandResult: SlashCommandProcessorResult | false = false;
|
||||||
|
await act(async () => {
|
||||||
|
commandResult = await result.current.handleSlashCommand('/test');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockAction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(commandResult).toEqual({ type: 'handled' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "schedule_tool" when a new command returns a tool action', async () => {
|
||||||
|
const mockAction = vi.fn().mockResolvedValue({
|
||||||
|
type: 'tool',
|
||||||
|
toolName: 'my_tool',
|
||||||
|
toolArgs: { arg1: 'value1' },
|
||||||
|
});
|
||||||
|
const newCommand: SlashCommand = { name: 'test', action: mockAction };
|
||||||
|
const mockLoader = async () => [newCommand];
|
||||||
|
const commandServiceInstance = new ActualCommandService(mockLoader);
|
||||||
|
vi.mocked(CommandService).mockImplementation(
|
||||||
|
() => commandServiceInstance,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = getProcessorHook();
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(
|
||||||
|
result.current.slashCommands.some((c) => c.name === 'test'),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const commandResult = await result.current.handleSlashCommand('/test');
|
||||||
|
|
||||||
|
expect(mockAction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(commandResult).toEqual({
|
||||||
|
type: 'schedule_tool',
|
||||||
|
toolName: 'my_tool',
|
||||||
|
toolArgs: { arg1: 'value1' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "handled" when a new command returns a message action', async () => {
|
||||||
|
const mockAction = vi.fn().mockResolvedValue({
|
||||||
|
type: 'message',
|
||||||
|
messageType: 'info',
|
||||||
|
content: 'This is a message',
|
||||||
|
});
|
||||||
|
const newCommand: SlashCommand = { name: 'test', action: mockAction };
|
||||||
|
const mockLoader = async () => [newCommand];
|
||||||
|
const commandServiceInstance = new ActualCommandService(mockLoader);
|
||||||
|
vi.mocked(CommandService).mockImplementation(
|
||||||
|
() => commandServiceInstance,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = getProcessorHook();
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(
|
||||||
|
result.current.slashCommands.some((c) => c.name === 'test'),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const commandResult = await result.current.handleSlashCommand('/test');
|
||||||
|
|
||||||
|
expect(mockAction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockAddItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'info',
|
||||||
|
text: 'This is a message',
|
||||||
|
}),
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
expect(commandResult).toEqual({ type: 'handled' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "handled" when a new command returns a dialog action', async () => {
|
||||||
|
const mockAction = vi.fn().mockResolvedValue({
|
||||||
|
type: 'dialog',
|
||||||
|
dialog: 'help',
|
||||||
|
});
|
||||||
|
const newCommand: SlashCommand = { name: 'test', action: mockAction };
|
||||||
|
const mockLoader = async () => [newCommand];
|
||||||
|
const commandServiceInstance = new ActualCommandService(mockLoader);
|
||||||
|
vi.mocked(CommandService).mockImplementation(
|
||||||
|
() => commandServiceInstance,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = getProcessorHook();
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(
|
||||||
|
result.current.slashCommands.some((c) => c.name === 'test'),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const commandResult = await result.current.handleSlashCommand('/test');
|
||||||
|
|
||||||
|
expect(mockAction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockSetShowHelp).toHaveBeenCalledWith(true);
|
||||||
|
expect(commandResult).toEqual({ type: 'handled' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show help for a parent command with no action', async () => {
|
||||||
|
const parentCommand: SlashCommand = {
|
||||||
|
name: 'parent',
|
||||||
|
subCommands: [
|
||||||
|
{ name: 'child', description: 'A child.', action: vi.fn() },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockLoader = async () => [parentCommand];
|
||||||
|
const commandServiceInstance = new ActualCommandService(mockLoader);
|
||||||
|
vi.mocked(CommandService).mockImplementation(
|
||||||
|
() => commandServiceInstance,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = getProcessorHook();
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(
|
||||||
|
result.current.slashCommands.some((c) => c.name === 'parent'),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleSlashCommand('/parent');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockAddItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'info',
|
||||||
|
text: expect.stringContaining(
|
||||||
|
"Command '/parent' requires a subcommand.",
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -498,6 +554,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
process.env = originalEnv;
|
process.env = originalEnv;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -547,14 +604,14 @@ describe('useSlashCommandProcessor', () => {
|
||||||
process.env.SEATBELT_PROFILE,
|
process.env.SEATBELT_PROFILE,
|
||||||
'test-version',
|
'test-version',
|
||||||
);
|
);
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandProcessorResult | false = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = await handleSlashCommand(`/bug ${bugDescription}`);
|
commandResult = await handleSlashCommand(`/bug ${bugDescription}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockAddItem).toHaveBeenCalledTimes(2);
|
expect(mockAddItem).toHaveBeenCalledTimes(2);
|
||||||
expect(open).toHaveBeenCalledWith(expectedUrl);
|
expect(open).toHaveBeenCalledWith(expectedUrl);
|
||||||
expect(commandResult).toBe(true);
|
expect(commandResult).toEqual({ type: 'handled' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use the custom bug command URL from config if available', async () => {
|
it('should use the custom bug command URL from config if available', async () => {
|
||||||
|
@ -585,14 +642,14 @@ describe('useSlashCommandProcessor', () => {
|
||||||
.replace('{title}', encodeURIComponent(bugDescription))
|
.replace('{title}', encodeURIComponent(bugDescription))
|
||||||
.replace('{info}', encodeURIComponent(info));
|
.replace('{info}', encodeURIComponent(info));
|
||||||
|
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandProcessorResult | false = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = await handleSlashCommand(`/bug ${bugDescription}`);
|
commandResult = await handleSlashCommand(`/bug ${bugDescription}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockAddItem).toHaveBeenCalledTimes(2);
|
expect(mockAddItem).toHaveBeenCalledTimes(2);
|
||||||
expect(open).toHaveBeenCalledWith(expectedUrl);
|
expect(open).toHaveBeenCalledWith(expectedUrl);
|
||||||
expect(commandResult).toBe(true);
|
expect(commandResult).toEqual({ type: 'handled' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -640,9 +697,9 @@ describe('useSlashCommandProcessor', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Unknown command', () => {
|
describe('Unknown command', () => {
|
||||||
it('should show an error and return true for a general unknown command', async () => {
|
it('should show an error and return handled for a general unknown command', async () => {
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandProcessorResult | false = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = await handleSlashCommand('/unknowncommand');
|
commandResult = await handleSlashCommand('/unknowncommand');
|
||||||
});
|
});
|
||||||
|
@ -654,7 +711,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
}),
|
}),
|
||||||
expect.any(Number),
|
expect.any(Number),
|
||||||
);
|
);
|
||||||
expect(commandResult).toBe(true);
|
expect(commandResult).toEqual({ type: 'handled' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -665,7 +722,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
getToolRegistry: vi.fn().mockResolvedValue(undefined),
|
getToolRegistry: vi.fn().mockResolvedValue(undefined),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandProcessorResult | false = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = await handleSlashCommand('/tools');
|
commandResult = await handleSlashCommand('/tools');
|
||||||
});
|
});
|
||||||
|
@ -678,7 +735,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
}),
|
}),
|
||||||
expect.any(Number),
|
expect.any(Number),
|
||||||
);
|
);
|
||||||
expect(commandResult).toBe(true);
|
expect(commandResult).toEqual({ type: 'handled' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show an error if getAllTools returns undefined', async () => {
|
it('should show an error if getAllTools returns undefined', async () => {
|
||||||
|
@ -689,7 +746,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
}),
|
}),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandProcessorResult | false = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = await handleSlashCommand('/tools');
|
commandResult = await handleSlashCommand('/tools');
|
||||||
});
|
});
|
||||||
|
@ -702,7 +759,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
}),
|
}),
|
||||||
expect.any(Number),
|
expect.any(Number),
|
||||||
);
|
);
|
||||||
expect(commandResult).toBe(true);
|
expect(commandResult).toEqual({ type: 'handled' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display only Gemini CLI tools (filtering out MCP tools)', async () => {
|
it('should display only Gemini CLI tools (filtering out MCP tools)', async () => {
|
||||||
|
@ -722,7 +779,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandProcessorResult | false = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = await handleSlashCommand('/tools');
|
commandResult = await handleSlashCommand('/tools');
|
||||||
});
|
});
|
||||||
|
@ -731,7 +788,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
const message = mockAddItem.mock.calls[1][0].text;
|
const message = mockAddItem.mock.calls[1][0].text;
|
||||||
expect(message).toContain('Tool1');
|
expect(message).toContain('Tool1');
|
||||||
expect(message).toContain('Tool2');
|
expect(message).toContain('Tool2');
|
||||||
expect(commandResult).toBe(true);
|
expect(commandResult).toEqual({ type: 'handled' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display a message when no Gemini CLI tools are available', async () => {
|
it('should display a message when no Gemini CLI tools are available', async () => {
|
||||||
|
@ -749,14 +806,14 @@ describe('useSlashCommandProcessor', () => {
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandProcessorResult | false = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = await handleSlashCommand('/tools');
|
commandResult = await handleSlashCommand('/tools');
|
||||||
});
|
});
|
||||||
|
|
||||||
const message = mockAddItem.mock.calls[1][0].text;
|
const message = mockAddItem.mock.calls[1][0].text;
|
||||||
expect(message).toContain('No tools available');
|
expect(message).toContain('No tools available');
|
||||||
expect(commandResult).toBe(true);
|
expect(commandResult).toEqual({ type: 'handled' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display tool descriptions when /tools desc is used', async () => {
|
it('should display tool descriptions when /tools desc is used', async () => {
|
||||||
|
@ -781,7 +838,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandProcessorResult | false = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = await handleSlashCommand('/tools desc');
|
commandResult = await handleSlashCommand('/tools desc');
|
||||||
});
|
});
|
||||||
|
@ -791,40 +848,18 @@ describe('useSlashCommandProcessor', () => {
|
||||||
expect(message).toContain('Description for Tool1');
|
expect(message).toContain('Description for Tool1');
|
||||||
expect(message).toContain('Tool2');
|
expect(message).toContain('Tool2');
|
||||||
expect(message).toContain('Description for Tool2');
|
expect(message).toContain('Description for Tool2');
|
||||||
expect(commandResult).toBe(true);
|
expect(commandResult).toEqual({ type: 'handled' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('/mcp command', () => {
|
describe('/mcp command', () => {
|
||||||
beforeEach(() => {
|
|
||||||
// Mock the core module with getMCPServerStatus and getMCPDiscoveryState
|
|
||||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
|
||||||
const actual = await importOriginal();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
MCPServerStatus: {
|
|
||||||
CONNECTED: 'connected',
|
|
||||||
CONNECTING: 'connecting',
|
|
||||||
DISCONNECTED: 'disconnected',
|
|
||||||
},
|
|
||||||
MCPDiscoveryState: {
|
|
||||||
NOT_STARTED: 'not_started',
|
|
||||||
IN_PROGRESS: 'in_progress',
|
|
||||||
COMPLETED: 'completed',
|
|
||||||
},
|
|
||||||
getMCPServerStatus: vi.fn(),
|
|
||||||
getMCPDiscoveryState: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show an error if tool registry is not available', async () => {
|
it('should show an error if tool registry is not available', async () => {
|
||||||
mockConfig = {
|
mockConfig = {
|
||||||
...mockConfig,
|
...mockConfig,
|
||||||
getToolRegistry: vi.fn().mockResolvedValue(undefined),
|
getToolRegistry: vi.fn().mockResolvedValue(undefined),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandProcessorResult | false = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = await handleSlashCommand('/mcp');
|
commandResult = await handleSlashCommand('/mcp');
|
||||||
});
|
});
|
||||||
|
@ -837,7 +872,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
}),
|
}),
|
||||||
expect.any(Number),
|
expect.any(Number),
|
||||||
);
|
);
|
||||||
expect(commandResult).toBe(true);
|
expect(commandResult).toEqual({ type: 'handled' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display a message with a URL when no MCP servers are configured in a sandbox', async () => {
|
it('should display a message with a URL when no MCP servers are configured in a sandbox', async () => {
|
||||||
|
@ -851,7 +886,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandProcessorResult | false = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = await handleSlashCommand('/mcp');
|
commandResult = await handleSlashCommand('/mcp');
|
||||||
});
|
});
|
||||||
|
@ -864,7 +899,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
}),
|
}),
|
||||||
expect.any(Number),
|
expect.any(Number),
|
||||||
);
|
);
|
||||||
expect(commandResult).toBe(true);
|
expect(commandResult).toEqual({ type: 'handled' });
|
||||||
delete process.env.SANDBOX;
|
delete process.env.SANDBOX;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -878,7 +913,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandProcessorResult | false = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = await handleSlashCommand('/mcp');
|
commandResult = await handleSlashCommand('/mcp');
|
||||||
});
|
});
|
||||||
|
@ -892,7 +927,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
expect.any(Number),
|
expect.any(Number),
|
||||||
);
|
);
|
||||||
expect(open).toHaveBeenCalledWith('https://goo.gle/gemini-cli-docs-mcp');
|
expect(open).toHaveBeenCalledWith('https://goo.gle/gemini-cli-docs-mcp');
|
||||||
expect(commandResult).toBe(true);
|
expect(commandResult).toEqual({ type: 'handled' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display configured MCP servers with status indicators and their tools', async () => {
|
it('should display configured MCP servers with status indicators and their tools', async () => {
|
||||||
|
@ -941,7 +976,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandProcessorResult | false = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = await handleSlashCommand('/mcp');
|
commandResult = await handleSlashCommand('/mcp');
|
||||||
});
|
});
|
||||||
|
@ -976,7 +1011,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
);
|
);
|
||||||
expect(message).toContain('\u001b[36mserver3_tool1\u001b[0m');
|
expect(message).toContain('\u001b[36mserver3_tool1\u001b[0m');
|
||||||
|
|
||||||
expect(commandResult).toBe(true);
|
expect(commandResult).toEqual({ type: 'handled' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display tool descriptions when showToolDescriptions is true', async () => {
|
it('should display tool descriptions when showToolDescriptions is true', async () => {
|
||||||
|
@ -1014,7 +1049,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
const { handleSlashCommand } = getProcessor(true);
|
const { handleSlashCommand } = getProcessor(true);
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandProcessorResult | false = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = await handleSlashCommand('/mcp');
|
commandResult = await handleSlashCommand('/mcp');
|
||||||
});
|
});
|
||||||
|
@ -1046,7 +1081,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
'\u001b[32mThis is tool 2 description\u001b[0m',
|
'\u001b[32mThis is tool 2 description\u001b[0m',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(commandResult).toBe(true);
|
expect(commandResult).toEqual({ type: 'handled' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should indicate when a server has no tools', async () => {
|
it('should indicate when a server has no tools', async () => {
|
||||||
|
@ -1071,7 +1106,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
// Mock tools from each server - server2 has no tools
|
// Mock tools from each server - server2 has no tools
|
||||||
const mockServer1Tools = [{ name: 'server1_tool1' }];
|
const mockServer1Tools = [{ name: 'server1_tool1' }];
|
||||||
|
|
||||||
const mockServer2Tools = [];
|
const mockServer2Tools: Array<{ name: string }> = [];
|
||||||
|
|
||||||
const mockGetToolsByServer = vi.fn().mockImplementation((serverName) => {
|
const mockGetToolsByServer = vi.fn().mockImplementation((serverName) => {
|
||||||
if (serverName === 'server1') return mockServer1Tools;
|
if (serverName === 'server1') return mockServer1Tools;
|
||||||
|
@ -1088,7 +1123,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandProcessorResult | false = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = await handleSlashCommand('/mcp');
|
commandResult = await handleSlashCommand('/mcp');
|
||||||
});
|
});
|
||||||
|
@ -1113,7 +1148,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
);
|
);
|
||||||
expect(message).toContain('No tools available');
|
expect(message).toContain('No tools available');
|
||||||
|
|
||||||
expect(commandResult).toBe(true);
|
expect(commandResult).toEqual({ type: 'handled' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show startup indicator when servers are connecting', async () => {
|
it('should show startup indicator when servers are connecting', async () => {
|
||||||
|
@ -1154,7 +1189,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
const { handleSlashCommand } = getProcessor();
|
const { handleSlashCommand } = getProcessor();
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandProcessorResult | false = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = await handleSlashCommand('/mcp');
|
commandResult = await handleSlashCommand('/mcp');
|
||||||
});
|
});
|
||||||
|
@ -1177,7 +1212,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
'🔄 \u001b[1mserver2\u001b[0m - Starting... (first startup may take longer) (tools will appear when ready)',
|
'🔄 \u001b[1mserver2\u001b[0m - Starting... (first startup may take longer) (tools will appear when ready)',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(commandResult).toBe(true);
|
expect(commandResult).toEqual({ type: 'handled' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1229,7 +1264,7 @@ describe('useSlashCommandProcessor', () => {
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
const { handleSlashCommand } = getProcessor(true);
|
const { handleSlashCommand } = getProcessor(true);
|
||||||
let commandResult: SlashCommandActionReturn | boolean = false;
|
let commandResult: SlashCommandProcessorResult | false = false;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
commandResult = await handleSlashCommand('/mcp schema');
|
commandResult = await handleSlashCommand('/mcp schema');
|
||||||
});
|
});
|
||||||
|
@ -1257,30 +1292,16 @@ describe('useSlashCommandProcessor', () => {
|
||||||
expect(message).toContain('param2');
|
expect(message).toContain('param2');
|
||||||
expect(message).toContain('number');
|
expect(message).toContain('number');
|
||||||
|
|
||||||
expect(commandResult).toBe(true);
|
expect(commandResult).toEqual({ type: 'handled' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('/compress command', () => {
|
describe('/compress command', () => {
|
||||||
it('should call tryCompressChat(true)', async () => {
|
it('should call tryCompressChat(true)', async () => {
|
||||||
const hook = getProcessorHook();
|
const hook = getProcessorHook();
|
||||||
mockTryCompressChat.mockImplementationOnce(async (force?: boolean) => {
|
mockTryCompressChat.mockResolvedValue({
|
||||||
expect(force).toBe(true);
|
|
||||||
await act(async () => {
|
|
||||||
hook.rerender();
|
|
||||||
});
|
|
||||||
expect(hook.result.current.pendingHistoryItems).toContainEqual({
|
|
||||||
type: MessageType.COMPRESSION,
|
|
||||||
compression: {
|
|
||||||
isPending: true,
|
|
||||||
originalTokenCount: null,
|
|
||||||
newTokenCount: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
originalTokenCount: 100,
|
originalTokenCount: 100,
|
||||||
newTokenCount: 50,
|
newTokenCount: 50,
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo, useEffect, useState } from 'react';
|
||||||
import { type PartListUnion } from '@google/genai';
|
import { type PartListUnion } from '@google/genai';
|
||||||
import open from 'open';
|
import open from 'open';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
@ -25,23 +25,24 @@ import {
|
||||||
MessageType,
|
MessageType,
|
||||||
HistoryItemWithoutId,
|
HistoryItemWithoutId,
|
||||||
HistoryItem,
|
HistoryItem,
|
||||||
|
SlashCommandProcessorResult,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { createShowMemoryAction } from './useShowMemoryCommand.js';
|
|
||||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||||
import { formatDuration, formatMemoryUsage } from '../utils/formatters.js';
|
import { formatDuration, formatMemoryUsage } from '../utils/formatters.js';
|
||||||
import { getCliVersion } from '../../utils/version.js';
|
import { getCliVersion } from '../../utils/version.js';
|
||||||
import { LoadedSettings } from '../../config/settings.js';
|
import { LoadedSettings } from '../../config/settings.js';
|
||||||
|
import {
|
||||||
|
type CommandContext,
|
||||||
|
type SlashCommandActionReturn,
|
||||||
|
type SlashCommand,
|
||||||
|
} from '../commands/types.js';
|
||||||
|
import { CommandService } from '../../services/CommandService.js';
|
||||||
|
|
||||||
export interface SlashCommandActionReturn {
|
// This interface is for the old, inline command definitions.
|
||||||
shouldScheduleTool?: boolean;
|
// It will be removed once all commands are migrated to the new system.
|
||||||
toolName?: string;
|
export interface LegacySlashCommand {
|
||||||
toolArgs?: Record<string, unknown>;
|
|
||||||
message?: string; // For simple messages or errors
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SlashCommand {
|
|
||||||
name: string;
|
name: string;
|
||||||
altName?: string;
|
altName?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
@ -53,7 +54,7 @@ export interface SlashCommand {
|
||||||
) =>
|
) =>
|
||||||
| void
|
| void
|
||||||
| SlashCommandActionReturn
|
| SlashCommandActionReturn
|
||||||
| Promise<void | SlashCommandActionReturn>; // Action can now return this object
|
| Promise<void | SlashCommandActionReturn>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -72,13 +73,13 @@ export const useSlashCommandProcessor = (
|
||||||
openThemeDialog: () => void,
|
openThemeDialog: () => void,
|
||||||
openAuthDialog: () => void,
|
openAuthDialog: () => void,
|
||||||
openEditorDialog: () => void,
|
openEditorDialog: () => void,
|
||||||
performMemoryRefresh: () => Promise<void>,
|
|
||||||
toggleCorgiMode: () => void,
|
toggleCorgiMode: () => void,
|
||||||
showToolDescriptions: boolean = false,
|
showToolDescriptions: boolean = false,
|
||||||
setQuittingMessages: (message: HistoryItem[]) => void,
|
setQuittingMessages: (message: HistoryItem[]) => void,
|
||||||
openPrivacyNotice: () => void,
|
openPrivacyNotice: () => void,
|
||||||
) => {
|
) => {
|
||||||
const session = useSessionStats();
|
const session = useSessionStats();
|
||||||
|
const [commands, setCommands] = useState<SlashCommand[]>([]);
|
||||||
const gitService = useMemo(() => {
|
const gitService = useMemo(() => {
|
||||||
if (!config?.getProjectRoot()) {
|
if (!config?.getProjectRoot()) {
|
||||||
return;
|
return;
|
||||||
|
@ -86,12 +87,23 @@ export const useSlashCommandProcessor = (
|
||||||
return new GitService(config.getProjectRoot());
|
return new GitService(config.getProjectRoot());
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
const pendingHistoryItems: HistoryItemWithoutId[] = [];
|
const logger = useMemo(() => {
|
||||||
|
const l = new Logger(config?.getSessionId() || '');
|
||||||
|
// The logger's initialize is async, but we can create the instance
|
||||||
|
// synchronously. Commands that use it will await its initialization.
|
||||||
|
return l;
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
const [pendingCompressionItemRef, setPendingCompressionItem] =
|
const [pendingCompressionItemRef, setPendingCompressionItem] =
|
||||||
useStateAndRef<HistoryItemWithoutId | null>(null);
|
useStateAndRef<HistoryItemWithoutId | null>(null);
|
||||||
|
|
||||||
|
const pendingHistoryItems = useMemo(() => {
|
||||||
|
const items: HistoryItemWithoutId[] = [];
|
||||||
if (pendingCompressionItemRef.current != null) {
|
if (pendingCompressionItemRef.current != null) {
|
||||||
pendingHistoryItems.push(pendingCompressionItemRef.current);
|
items.push(pendingCompressionItemRef.current);
|
||||||
}
|
}
|
||||||
|
return items;
|
||||||
|
}, [pendingCompressionItemRef]);
|
||||||
|
|
||||||
const addMessage = useCallback(
|
const addMessage = useCallback(
|
||||||
(message: Message) => {
|
(message: Message) => {
|
||||||
|
@ -141,41 +153,51 @@ export const useSlashCommandProcessor = (
|
||||||
[addItem],
|
[addItem],
|
||||||
);
|
);
|
||||||
|
|
||||||
const showMemoryAction = useCallback(async () => {
|
const commandContext = useMemo(
|
||||||
const actionFn = createShowMemoryAction(config, settings, addMessage);
|
(): CommandContext => ({
|
||||||
await actionFn();
|
services: {
|
||||||
}, [config, settings, addMessage]);
|
config,
|
||||||
|
settings,
|
||||||
const addMemoryAction = useCallback(
|
git: gitService,
|
||||||
(
|
logger,
|
||||||
_mainCommand: string,
|
|
||||||
_subCommand?: string,
|
|
||||||
args?: string,
|
|
||||||
): SlashCommandActionReturn | void => {
|
|
||||||
if (!args || args.trim() === '') {
|
|
||||||
addMessage({
|
|
||||||
type: MessageType.ERROR,
|
|
||||||
content: 'Usage: /memory add <text to remember>',
|
|
||||||
timestamp: new Date(),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// UI feedback for attempting to schedule
|
|
||||||
addMessage({
|
|
||||||
type: MessageType.INFO,
|
|
||||||
content: `Attempting to save to memory: "${args.trim()}"`,
|
|
||||||
timestamp: new Date(),
|
|
||||||
});
|
|
||||||
// Return info for scheduling the tool call
|
|
||||||
return {
|
|
||||||
shouldScheduleTool: true,
|
|
||||||
toolName: 'save_memory',
|
|
||||||
toolArgs: { fact: args.trim() },
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
[addMessage],
|
ui: {
|
||||||
|
addItem,
|
||||||
|
clear: () => {
|
||||||
|
clearItems();
|
||||||
|
console.clear();
|
||||||
|
refreshStatic();
|
||||||
|
},
|
||||||
|
setDebugMessage: onDebugMessage,
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
stats: session.stats,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
config,
|
||||||
|
settings,
|
||||||
|
gitService,
|
||||||
|
logger,
|
||||||
|
addItem,
|
||||||
|
clearItems,
|
||||||
|
refreshStatic,
|
||||||
|
session.stats,
|
||||||
|
onDebugMessage,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const commandService = useMemo(() => new CommandService(), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
await commandService.loadCommands();
|
||||||
|
setCommands(commandService.getCommands());
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
}, [commandService]);
|
||||||
|
|
||||||
const savedChatTags = useCallback(async () => {
|
const savedChatTags = useCallback(async () => {
|
||||||
const geminiDir = config?.getProjectTempDir();
|
const geminiDir = config?.getProjectTempDir();
|
||||||
if (!geminiDir) {
|
if (!geminiDir) {
|
||||||
|
@ -193,17 +215,12 @@ export const useSlashCommandProcessor = (
|
||||||
}
|
}
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
const slashCommands: SlashCommand[] = useMemo(() => {
|
// Define legacy commands
|
||||||
const commands: SlashCommand[] = [
|
// This list contains all commands that have NOT YET been migrated to the
|
||||||
{
|
// new system. As commands are migrated, they are removed from this list.
|
||||||
name: 'help',
|
const legacyCommands: LegacySlashCommand[] = useMemo(() => {
|
||||||
altName: '?',
|
const commands: LegacySlashCommand[] = [
|
||||||
description: 'for help on gemini-cli',
|
// `/help` and `/clear` have been migrated and REMOVED from this list.
|
||||||
action: (_mainCommand, _subCommand, _args) => {
|
|
||||||
onDebugMessage('Opening help.');
|
|
||||||
setShowHelp(true);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'docs',
|
name: 'docs',
|
||||||
description: 'open full Gemini CLI documentation in your browser',
|
description: 'open full Gemini CLI documentation in your browser',
|
||||||
|
@ -225,17 +242,6 @@ export const useSlashCommandProcessor = (
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'clear',
|
|
||||||
description: 'clear the screen and conversation history',
|
|
||||||
action: async (_mainCommand, _subCommand, _args) => {
|
|
||||||
onDebugMessage('Clearing terminal and resetting chat.');
|
|
||||||
clearItems();
|
|
||||||
await config?.getGeminiClient()?.resetChat();
|
|
||||||
console.clear();
|
|
||||||
refreshStatic();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'theme',
|
name: 'theme',
|
||||||
description: 'change the theme',
|
description: 'change the theme',
|
||||||
|
@ -246,23 +252,17 @@ export const useSlashCommandProcessor = (
|
||||||
{
|
{
|
||||||
name: 'auth',
|
name: 'auth',
|
||||||
description: 'change the auth method',
|
description: 'change the auth method',
|
||||||
action: (_mainCommand, _subCommand, _args) => {
|
action: (_mainCommand, _subCommand, _args) => openAuthDialog(),
|
||||||
openAuthDialog();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'editor',
|
name: 'editor',
|
||||||
description: 'set external editor preference',
|
description: 'set external editor preference',
|
||||||
action: (_mainCommand, _subCommand, _args) => {
|
action: (_mainCommand, _subCommand, _args) => openEditorDialog(),
|
||||||
openEditorDialog();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'privacy',
|
name: 'privacy',
|
||||||
description: 'display the privacy notice',
|
description: 'display the privacy notice',
|
||||||
action: (_mainCommand, _subCommand, _args) => {
|
action: (_mainCommand, _subCommand, _args) => openPrivacyNotice(),
|
||||||
openPrivacyNotice();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'stats',
|
name: 'stats',
|
||||||
|
@ -493,38 +493,6 @@ export const useSlashCommandProcessor = (
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'memory',
|
|
||||||
description:
|
|
||||||
'manage memory. Usage: /memory <show|refresh|add> [text for add]',
|
|
||||||
action: (mainCommand, subCommand, args) => {
|
|
||||||
switch (subCommand) {
|
|
||||||
case 'show':
|
|
||||||
showMemoryAction();
|
|
||||||
return;
|
|
||||||
case 'refresh':
|
|
||||||
performMemoryRefresh();
|
|
||||||
return;
|
|
||||||
case 'add':
|
|
||||||
return addMemoryAction(mainCommand, subCommand, args); // Return the object
|
|
||||||
case undefined:
|
|
||||||
addMessage({
|
|
||||||
type: MessageType.ERROR,
|
|
||||||
content:
|
|
||||||
'Missing command\nUsage: /memory <show|refresh|add> [text for add]',
|
|
||||||
timestamp: new Date(),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
default:
|
|
||||||
addMessage({
|
|
||||||
type: MessageType.ERROR,
|
|
||||||
content: `Unknown /memory command: ${subCommand}. Available: show, refresh, add`,
|
|
||||||
timestamp: new Date(),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'tools',
|
name: 'tools',
|
||||||
description: 'list available Gemini CLI tools',
|
description: 'list available Gemini CLI tools',
|
||||||
|
@ -1020,7 +988,7 @@ export const useSlashCommandProcessor = (
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
shouldScheduleTool: true,
|
type: 'tool',
|
||||||
toolName: toolCallData.toolCall.name,
|
toolName: toolCallData.toolCall.name,
|
||||||
toolArgs: toolCallData.toolCall.args,
|
toolArgs: toolCallData.toolCall.args,
|
||||||
};
|
};
|
||||||
|
@ -1036,17 +1004,11 @@ export const useSlashCommandProcessor = (
|
||||||
}
|
}
|
||||||
return commands;
|
return commands;
|
||||||
}, [
|
}, [
|
||||||
onDebugMessage,
|
addMessage,
|
||||||
setShowHelp,
|
|
||||||
refreshStatic,
|
|
||||||
openThemeDialog,
|
openThemeDialog,
|
||||||
openAuthDialog,
|
openAuthDialog,
|
||||||
openEditorDialog,
|
openEditorDialog,
|
||||||
clearItems,
|
openPrivacyNotice,
|
||||||
performMemoryRefresh,
|
|
||||||
showMemoryAction,
|
|
||||||
addMemoryAction,
|
|
||||||
addMessage,
|
|
||||||
toggleCorgiMode,
|
toggleCorgiMode,
|
||||||
savedChatTags,
|
savedChatTags,
|
||||||
config,
|
config,
|
||||||
|
@ -1059,20 +1021,23 @@ export const useSlashCommandProcessor = (
|
||||||
setQuittingMessages,
|
setQuittingMessages,
|
||||||
pendingCompressionItemRef,
|
pendingCompressionItemRef,
|
||||||
setPendingCompressionItem,
|
setPendingCompressionItem,
|
||||||
openPrivacyNotice,
|
clearItems,
|
||||||
|
refreshStatic,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleSlashCommand = useCallback(
|
const handleSlashCommand = useCallback(
|
||||||
async (
|
async (
|
||||||
rawQuery: PartListUnion,
|
rawQuery: PartListUnion,
|
||||||
): Promise<SlashCommandActionReturn | boolean> => {
|
): Promise<SlashCommandProcessorResult | false> => {
|
||||||
if (typeof rawQuery !== 'string') {
|
if (typeof rawQuery !== 'string') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmed = rawQuery.trim();
|
const trimmed = rawQuery.trim();
|
||||||
if (!trimmed.startsWith('/') && !trimmed.startsWith('?')) {
|
if (!trimmed.startsWith('/') && !trimmed.startsWith('?')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userMessageTimestamp = Date.now();
|
const userMessageTimestamp = Date.now();
|
||||||
if (trimmed !== '/quit' && trimmed !== '/exit') {
|
if (trimmed !== '/quit' && trimmed !== '/exit') {
|
||||||
addItem(
|
addItem(
|
||||||
|
@ -1081,35 +1046,128 @@ export const useSlashCommandProcessor = (
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let subCommand: string | undefined;
|
|
||||||
let args: string | undefined;
|
|
||||||
|
|
||||||
const commandToMatch = (() => {
|
|
||||||
if (trimmed.startsWith('?')) {
|
|
||||||
return 'help';
|
|
||||||
}
|
|
||||||
const parts = trimmed.substring(1).trim().split(/\s+/);
|
const parts = trimmed.substring(1).trim().split(/\s+/);
|
||||||
if (parts.length > 1) {
|
const commandPath = parts.filter((p) => p); // The parts of the command, e.g., ['memory', 'add']
|
||||||
subCommand = parts[1];
|
|
||||||
}
|
|
||||||
if (parts.length > 2) {
|
|
||||||
args = parts.slice(2).join(' ');
|
|
||||||
}
|
|
||||||
return parts[0];
|
|
||||||
})();
|
|
||||||
|
|
||||||
const mainCommand = commandToMatch;
|
// --- Start of New Tree Traversal Logic ---
|
||||||
|
|
||||||
for (const cmd of slashCommands) {
|
let currentCommands = commands;
|
||||||
|
let commandToExecute: SlashCommand | undefined;
|
||||||
|
let pathIndex = 0;
|
||||||
|
|
||||||
|
for (const part of commandPath) {
|
||||||
|
const foundCommand = currentCommands.find(
|
||||||
|
(cmd) => cmd.name === part || cmd.altName === part,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (foundCommand) {
|
||||||
|
commandToExecute = foundCommand;
|
||||||
|
pathIndex++;
|
||||||
|
if (foundCommand.subCommands) {
|
||||||
|
currentCommands = foundCommand.subCommands;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commandToExecute) {
|
||||||
|
const args = parts.slice(pathIndex).join(' ');
|
||||||
|
|
||||||
|
if (commandToExecute.action) {
|
||||||
|
const result = await commandToExecute.action(commandContext, args);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
switch (result.type) {
|
||||||
|
case 'tool':
|
||||||
|
return {
|
||||||
|
type: 'schedule_tool',
|
||||||
|
toolName: result.toolName,
|
||||||
|
toolArgs: result.toolArgs,
|
||||||
|
};
|
||||||
|
case 'message':
|
||||||
|
addItem(
|
||||||
|
{
|
||||||
|
type:
|
||||||
|
result.messageType === 'error'
|
||||||
|
? MessageType.ERROR
|
||||||
|
: MessageType.INFO,
|
||||||
|
text: result.content,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
return { type: 'handled' };
|
||||||
|
case 'dialog':
|
||||||
|
switch (result.dialog) {
|
||||||
|
case 'help':
|
||||||
|
setShowHelp(true);
|
||||||
|
return { type: 'handled' };
|
||||||
|
default: {
|
||||||
|
const unhandled: never = result.dialog;
|
||||||
|
throw new Error(
|
||||||
|
`Unhandled slash command result: ${unhandled}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const unhandled: never = result;
|
||||||
|
throw new Error(`Unhandled slash command result: ${unhandled}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: 'handled' };
|
||||||
|
} else if (commandToExecute.subCommands) {
|
||||||
|
const helpText = `Command '/${commandToExecute.name}' requires a subcommand. Available:\n${commandToExecute.subCommands
|
||||||
|
.map((sc) => ` - ${sc.name}: ${sc.description || ''}`)
|
||||||
|
.join('\n')}`;
|
||||||
|
addMessage({
|
||||||
|
type: MessageType.INFO,
|
||||||
|
content: helpText,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
return { type: 'handled' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- End of New Tree Traversal Logic ---
|
||||||
|
|
||||||
|
// --- Legacy Fallback Logic (for commands not yet migrated) ---
|
||||||
|
|
||||||
|
const mainCommand = parts[0];
|
||||||
|
const subCommand = parts[1];
|
||||||
|
const legacyArgs = parts.slice(2).join(' ');
|
||||||
|
|
||||||
|
for (const cmd of legacyCommands) {
|
||||||
if (mainCommand === cmd.name || mainCommand === cmd.altName) {
|
if (mainCommand === cmd.name || mainCommand === cmd.altName) {
|
||||||
const actionResult = await cmd.action(mainCommand, subCommand, args);
|
const actionResult = await cmd.action(
|
||||||
if (
|
mainCommand,
|
||||||
typeof actionResult === 'object' &&
|
subCommand,
|
||||||
actionResult?.shouldScheduleTool
|
legacyArgs,
|
||||||
) {
|
);
|
||||||
return actionResult; // Return the object for useGeminiStream
|
|
||||||
|
if (actionResult?.type === 'tool') {
|
||||||
|
return {
|
||||||
|
type: 'schedule_tool',
|
||||||
|
toolName: actionResult.toolName,
|
||||||
|
toolArgs: actionResult.toolArgs,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return true; // Command was handled, but no tool to schedule
|
if (actionResult?.type === 'message') {
|
||||||
|
addItem(
|
||||||
|
{
|
||||||
|
type:
|
||||||
|
actionResult.messageType === 'error'
|
||||||
|
? MessageType.ERROR
|
||||||
|
: MessageType.INFO,
|
||||||
|
text: actionResult.content,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { type: 'handled' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1118,10 +1176,51 @@ export const useSlashCommandProcessor = (
|
||||||
content: `Unknown command: ${trimmed}`,
|
content: `Unknown command: ${trimmed}`,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
});
|
});
|
||||||
return true; // Indicate command was processed (even if unknown)
|
return { type: 'handled' };
|
||||||
},
|
},
|
||||||
[addItem, slashCommands, addMessage],
|
[
|
||||||
|
addItem,
|
||||||
|
setShowHelp,
|
||||||
|
commands,
|
||||||
|
legacyCommands,
|
||||||
|
commandContext,
|
||||||
|
addMessage,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { handleSlashCommand, slashCommands, pendingHistoryItems };
|
const allCommands = useMemo(() => {
|
||||||
|
// Adapt legacy commands to the new SlashCommand interface
|
||||||
|
const adaptedLegacyCommands: SlashCommand[] = legacyCommands.map(
|
||||||
|
(legacyCmd) => ({
|
||||||
|
name: legacyCmd.name,
|
||||||
|
altName: legacyCmd.altName,
|
||||||
|
description: legacyCmd.description,
|
||||||
|
action: async (_context: CommandContext, args: string) => {
|
||||||
|
const parts = args.split(/\s+/);
|
||||||
|
const subCommand = parts[0] || undefined;
|
||||||
|
const restOfArgs = parts.slice(1).join(' ') || undefined;
|
||||||
|
|
||||||
|
return legacyCmd.action(legacyCmd.name, subCommand, restOfArgs);
|
||||||
|
},
|
||||||
|
completion: legacyCmd.completion
|
||||||
|
? async (_context: CommandContext, _partialArg: string) =>
|
||||||
|
legacyCmd.completion!()
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const newCommandNames = new Set(commands.map((c) => c.name));
|
||||||
|
const filteredAdaptedLegacy = adaptedLegacyCommands.filter(
|
||||||
|
(c) => !newCommandNames.has(c.name),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [...commands, ...filteredAdaptedLegacy];
|
||||||
|
}, [commands, legacyCommands]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleSlashCommand,
|
||||||
|
slashCommands: allCommands,
|
||||||
|
pendingHistoryItems,
|
||||||
|
commandContext,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -9,8 +9,15 @@ import type { Mocked } from 'vitest';
|
||||||
import { renderHook, act } from '@testing-library/react';
|
import { renderHook, act } from '@testing-library/react';
|
||||||
import { useCompletion } from './useCompletion.js';
|
import { useCompletion } from './useCompletion.js';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import { FileDiscoveryService } from '@google/gemini-cli-core';
|
|
||||||
import { glob } from 'glob';
|
import { glob } from 'glob';
|
||||||
|
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||||
|
import { Config, FileDiscoveryService } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
interface MockConfig {
|
||||||
|
getFileFilteringRespectGitIgnore: () => boolean;
|
||||||
|
getEnableRecursiveFileSearch: () => boolean;
|
||||||
|
getFileService: () => FileDiscoveryService | null;
|
||||||
|
}
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock('fs/promises');
|
vi.mock('fs/promises');
|
||||||
|
@ -29,23 +36,83 @@ vi.mock('glob');
|
||||||
|
|
||||||
describe('useCompletion git-aware filtering integration', () => {
|
describe('useCompletion git-aware filtering integration', () => {
|
||||||
let mockFileDiscoveryService: Mocked<FileDiscoveryService>;
|
let mockFileDiscoveryService: Mocked<FileDiscoveryService>;
|
||||||
let mockConfig: {
|
let mockConfig: MockConfig;
|
||||||
fileFiltering?: { enabled?: boolean; respectGitignore?: boolean };
|
|
||||||
};
|
|
||||||
const testCwd = '/test/project';
|
const testCwd = '/test/project';
|
||||||
const slashCommands = [
|
const slashCommands = [
|
||||||
{ name: 'help', description: 'Show help', action: vi.fn() },
|
{ name: 'help', description: 'Show help', action: vi.fn() },
|
||||||
{ name: 'clear', description: 'Clear screen', action: vi.fn() },
|
{ name: 'clear', description: 'Clear screen', action: vi.fn() },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// A minimal mock is sufficient for these tests.
|
||||||
|
const mockCommandContext = {} as CommandContext;
|
||||||
|
|
||||||
|
const mockSlashCommands: SlashCommand[] = [
|
||||||
|
{
|
||||||
|
name: 'help',
|
||||||
|
altName: '?',
|
||||||
|
description: 'Show help',
|
||||||
|
action: vi.fn(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'clear',
|
||||||
|
description: 'Clear the screen',
|
||||||
|
action: vi.fn(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'memory',
|
||||||
|
description: 'Manage memory',
|
||||||
|
// This command is a parent, no action.
|
||||||
|
subCommands: [
|
||||||
|
{
|
||||||
|
name: 'show',
|
||||||
|
description: 'Show memory',
|
||||||
|
action: vi.fn(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'add',
|
||||||
|
description: 'Add to memory',
|
||||||
|
action: vi.fn(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'chat',
|
||||||
|
description: 'Manage chat history',
|
||||||
|
subCommands: [
|
||||||
|
{
|
||||||
|
name: 'save',
|
||||||
|
description: 'Save chat',
|
||||||
|
action: vi.fn(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'resume',
|
||||||
|
description: 'Resume a saved chat',
|
||||||
|
action: vi.fn(),
|
||||||
|
// This command provides its own argument completions
|
||||||
|
completion: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
'my-chat-tag-1',
|
||||||
|
'my-chat-tag-2',
|
||||||
|
'my-channel',
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockFileDiscoveryService = {
|
mockFileDiscoveryService = {
|
||||||
shouldGitIgnoreFile: vi.fn(),
|
shouldGitIgnoreFile: vi.fn(),
|
||||||
shouldGeminiIgnoreFile: vi.fn(),
|
shouldGeminiIgnoreFile: vi.fn(),
|
||||||
shouldIgnoreFile: vi.fn(),
|
shouldIgnoreFile: vi.fn(),
|
||||||
filterFiles: vi.fn(),
|
filterFiles: vi.fn(),
|
||||||
getGeminiIgnorePatterns: vi.fn(() => []),
|
getGeminiIgnorePatterns: vi.fn(),
|
||||||
};
|
projectRoot: '',
|
||||||
|
gitIgnoreFilter: null,
|
||||||
|
geminiIgnoreFilter: null,
|
||||||
|
} as unknown as Mocked<FileDiscoveryService>;
|
||||||
|
|
||||||
mockConfig = {
|
mockConfig = {
|
||||||
getFileFilteringRespectGitIgnore: vi.fn(() => true),
|
getFileFilteringRespectGitIgnore: vi.fn(() => true),
|
||||||
|
@ -81,7 +148,14 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useCompletion('@d', testCwd, true, slashCommands, mockConfig),
|
useCompletion(
|
||||||
|
'@d',
|
||||||
|
testCwd,
|
||||||
|
true,
|
||||||
|
slashCommands,
|
||||||
|
mockCommandContext,
|
||||||
|
mockConfig as Config,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wait for async operations to complete
|
// Wait for async operations to complete
|
||||||
|
@ -104,7 +178,7 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
{ name: 'dist', isDirectory: () => true },
|
{ name: 'dist', isDirectory: () => true },
|
||||||
{ name: 'README.md', isDirectory: () => false },
|
{ name: 'README.md', isDirectory: () => false },
|
||||||
{ name: '.env', isDirectory: () => false },
|
{ name: '.env', isDirectory: () => false },
|
||||||
] as Array<{ name: string; isDirectory: () => boolean }>);
|
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||||
|
|
||||||
// Mock git ignore service to ignore certain files
|
// Mock git ignore service to ignore certain files
|
||||||
mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation(
|
mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation(
|
||||||
|
@ -123,7 +197,14 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useCompletion('@', testCwd, true, slashCommands, mockConfig),
|
useCompletion(
|
||||||
|
'@',
|
||||||
|
testCwd,
|
||||||
|
true,
|
||||||
|
slashCommands,
|
||||||
|
mockCommandContext,
|
||||||
|
mockConfig as Config,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wait for async operations to complete
|
// Wait for async operations to complete
|
||||||
|
@ -182,7 +263,14 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useCompletion('@t', testCwd, true, slashCommands, mockConfig),
|
useCompletion(
|
||||||
|
'@t',
|
||||||
|
testCwd,
|
||||||
|
true,
|
||||||
|
slashCommands,
|
||||||
|
mockCommandContext,
|
||||||
|
mockConfig as Config,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wait for async operations to complete
|
// Wait for async operations to complete
|
||||||
|
@ -206,15 +294,22 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
const mockConfigNoRecursive = {
|
const mockConfigNoRecursive = {
|
||||||
...mockConfig,
|
...mockConfig,
|
||||||
getEnableRecursiveFileSearch: vi.fn(() => false),
|
getEnableRecursiveFileSearch: vi.fn(() => false),
|
||||||
};
|
} as unknown as Config;
|
||||||
|
|
||||||
vi.mocked(fs.readdir).mockResolvedValue([
|
vi.mocked(fs.readdir).mockResolvedValue([
|
||||||
{ name: 'data', isDirectory: () => true },
|
{ name: 'data', isDirectory: () => true },
|
||||||
{ name: 'dist', isDirectory: () => true },
|
{ name: 'dist', isDirectory: () => true },
|
||||||
] as Array<{ name: string; isDirectory: () => boolean }>);
|
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||||
|
|
||||||
renderHook(() =>
|
renderHook(() =>
|
||||||
useCompletion('@d', testCwd, true, slashCommands, mockConfigNoRecursive),
|
useCompletion(
|
||||||
|
'@d',
|
||||||
|
testCwd,
|
||||||
|
true,
|
||||||
|
slashCommands,
|
||||||
|
mockCommandContext,
|
||||||
|
mockConfigNoRecursive,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
@ -232,10 +327,17 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
{ name: 'src', isDirectory: () => true },
|
{ name: 'src', isDirectory: () => true },
|
||||||
{ name: 'node_modules', isDirectory: () => true },
|
{ name: 'node_modules', isDirectory: () => true },
|
||||||
{ name: 'README.md', isDirectory: () => false },
|
{ name: 'README.md', isDirectory: () => false },
|
||||||
] as Array<{ name: string; isDirectory: () => boolean }>);
|
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useCompletion('@', testCwd, true, slashCommands, undefined),
|
useCompletion(
|
||||||
|
'@',
|
||||||
|
testCwd,
|
||||||
|
true,
|
||||||
|
slashCommands,
|
||||||
|
mockCommandContext,
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
@ -257,12 +359,19 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
vi.mocked(fs.readdir).mockResolvedValue([
|
vi.mocked(fs.readdir).mockResolvedValue([
|
||||||
{ name: 'src', isDirectory: () => true },
|
{ name: 'src', isDirectory: () => true },
|
||||||
{ name: 'README.md', isDirectory: () => false },
|
{ name: 'README.md', isDirectory: () => false },
|
||||||
] as Array<{ name: string; isDirectory: () => boolean }>);
|
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||||
|
|
||||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useCompletion('@', testCwd, true, slashCommands, mockConfig),
|
useCompletion(
|
||||||
|
'@',
|
||||||
|
testCwd,
|
||||||
|
true,
|
||||||
|
slashCommands,
|
||||||
|
mockCommandContext,
|
||||||
|
mockConfig as Config,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
@ -283,7 +392,7 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
{ name: 'component.tsx', isDirectory: () => false },
|
{ name: 'component.tsx', isDirectory: () => false },
|
||||||
{ name: 'temp.log', isDirectory: () => false },
|
{ name: 'temp.log', isDirectory: () => false },
|
||||||
{ name: 'index.ts', isDirectory: () => false },
|
{ name: 'index.ts', isDirectory: () => false },
|
||||||
] as Array<{ name: string; isDirectory: () => boolean }>);
|
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||||
|
|
||||||
mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation(
|
mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation(
|
||||||
(path: string) => path.includes('.log'),
|
(path: string) => path.includes('.log'),
|
||||||
|
@ -298,7 +407,14 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useCompletion('@src/comp', testCwd, true, slashCommands, mockConfig),
|
useCompletion(
|
||||||
|
'@src/comp',
|
||||||
|
testCwd,
|
||||||
|
true,
|
||||||
|
slashCommands,
|
||||||
|
mockCommandContext,
|
||||||
|
mockConfig as Config,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
@ -316,7 +432,14 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
vi.mocked(glob).mockResolvedValue(globResults);
|
vi.mocked(glob).mockResolvedValue(globResults);
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useCompletion('@s', testCwd, true, slashCommands, mockConfig),
|
useCompletion(
|
||||||
|
'@s',
|
||||||
|
testCwd,
|
||||||
|
true,
|
||||||
|
slashCommands,
|
||||||
|
mockCommandContext,
|
||||||
|
mockConfig as Config,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
@ -344,7 +467,14 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
vi.mocked(glob).mockResolvedValue(globResults);
|
vi.mocked(glob).mockResolvedValue(globResults);
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useCompletion('@.', testCwd, true, slashCommands, mockConfig),
|
useCompletion(
|
||||||
|
'@.',
|
||||||
|
testCwd,
|
||||||
|
true,
|
||||||
|
slashCommands,
|
||||||
|
mockCommandContext,
|
||||||
|
mockConfig as Config,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
@ -363,4 +493,263 @@ describe('useCompletion git-aware filtering integration', () => {
|
||||||
{ label: 'src/index.ts', value: 'src/index.ts' },
|
{ label: 'src/index.ts', value: 'src/index.ts' },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should suggest top-level command names based on partial input', async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useCompletion(
|
||||||
|
'/mem',
|
||||||
|
'/test/cwd',
|
||||||
|
true,
|
||||||
|
mockSlashCommands,
|
||||||
|
mockCommandContext,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.suggestions).toEqual([
|
||||||
|
{ label: 'memory', value: 'memory', description: 'Manage memory' },
|
||||||
|
]);
|
||||||
|
expect(result.current.showSuggestions).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should suggest commands based on altName', async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useCompletion(
|
||||||
|
'/?',
|
||||||
|
'/test/cwd',
|
||||||
|
true,
|
||||||
|
mockSlashCommands,
|
||||||
|
mockCommandContext,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.suggestions).toEqual([
|
||||||
|
{ label: 'help', value: 'help', description: 'Show help' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should suggest sub-command names for a parent command', async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useCompletion(
|
||||||
|
'/memory a',
|
||||||
|
'/test/cwd',
|
||||||
|
true,
|
||||||
|
mockSlashCommands,
|
||||||
|
mockCommandContext,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.suggestions).toEqual([
|
||||||
|
{ label: 'add', value: 'add', description: 'Add to memory' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should suggest all sub-commands when the query ends with the parent command and a space', async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useCompletion(
|
||||||
|
'/memory ',
|
||||||
|
'/test/cwd',
|
||||||
|
true,
|
||||||
|
mockSlashCommands,
|
||||||
|
mockCommandContext,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.suggestions).toHaveLength(2);
|
||||||
|
expect(result.current.suggestions).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ label: 'show', value: 'show', description: 'Show memory' },
|
||||||
|
{ label: 'add', value: 'add', description: 'Add to memory' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call the command.completion function for argument suggestions', async () => {
|
||||||
|
const availableTags = ['my-chat-tag-1', 'my-chat-tag-2', 'another-channel'];
|
||||||
|
const mockCompletionFn = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(async (context: CommandContext, partialArg: string) =>
|
||||||
|
availableTags.filter((tag) => tag.startsWith(partialArg)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockCommandsWithFiltering = JSON.parse(
|
||||||
|
JSON.stringify(mockSlashCommands),
|
||||||
|
) as SlashCommand[];
|
||||||
|
|
||||||
|
const chatCmd = mockCommandsWithFiltering.find(
|
||||||
|
(cmd) => cmd.name === 'chat',
|
||||||
|
);
|
||||||
|
if (!chatCmd || !chatCmd.subCommands) {
|
||||||
|
throw new Error(
|
||||||
|
"Test setup error: Could not find the 'chat' command with subCommands in the mock data.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resumeCmd = chatCmd.subCommands.find((sc) => sc.name === 'resume');
|
||||||
|
if (!resumeCmd) {
|
||||||
|
throw new Error(
|
||||||
|
"Test setup error: Could not find the 'resume' sub-command in the mock data.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
resumeCmd.completion = mockCompletionFn;
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useCompletion(
|
||||||
|
'/chat resume my-ch',
|
||||||
|
'/test/cwd',
|
||||||
|
true,
|
||||||
|
mockCommandsWithFiltering,
|
||||||
|
mockCommandContext,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCompletionFn).toHaveBeenCalledWith(mockCommandContext, 'my-ch');
|
||||||
|
|
||||||
|
expect(result.current.suggestions).toEqual([
|
||||||
|
{ label: 'my-chat-tag-1', value: 'my-chat-tag-1' },
|
||||||
|
{ label: 'my-chat-tag-2', value: 'my-chat-tag-2' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useCompletion(
|
||||||
|
'/clear ',
|
||||||
|
'/test/cwd',
|
||||||
|
true,
|
||||||
|
mockSlashCommands,
|
||||||
|
mockCommandContext,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.suggestions).toHaveLength(0);
|
||||||
|
expect(result.current.showSuggestions).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not provide suggestions for an unknown command', async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useCompletion(
|
||||||
|
'/unknown-command',
|
||||||
|
'/test/cwd',
|
||||||
|
true,
|
||||||
|
mockSlashCommands,
|
||||||
|
mockCommandContext,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.suggestions).toHaveLength(0);
|
||||||
|
expect(result.current.showSuggestions).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should suggest sub-commands for a fully typed parent command without a trailing space', async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useCompletion(
|
||||||
|
'/memory', // Note: no trailing space
|
||||||
|
'/test/cwd',
|
||||||
|
true,
|
||||||
|
mockSlashCommands,
|
||||||
|
mockCommandContext,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert that suggestions for sub-commands are shown immediately
|
||||||
|
expect(result.current.suggestions).toHaveLength(2);
|
||||||
|
expect(result.current.suggestions).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ label: 'show', value: 'show', description: 'Show memory' },
|
||||||
|
{ label: 'add', value: 'add', description: 'Add to memory' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(result.current.showSuggestions).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useCompletion(
|
||||||
|
'/clear', // No trailing space
|
||||||
|
'/test/cwd',
|
||||||
|
true,
|
||||||
|
mockSlashCommands,
|
||||||
|
mockCommandContext,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.suggestions).toHaveLength(0);
|
||||||
|
expect(result.current.showSuggestions).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call command.completion with an empty string when args start with a space', async () => {
|
||||||
|
const mockCompletionFn = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(['my-chat-tag-1', 'my-chat-tag-2', 'my-channel']);
|
||||||
|
|
||||||
|
const isolatedMockCommands = JSON.parse(
|
||||||
|
JSON.stringify(mockSlashCommands),
|
||||||
|
) as SlashCommand[];
|
||||||
|
|
||||||
|
const resumeCommand = isolatedMockCommands
|
||||||
|
.find((cmd) => cmd.name === 'chat')
|
||||||
|
?.subCommands?.find((cmd) => cmd.name === 'resume');
|
||||||
|
|
||||||
|
if (!resumeCommand) {
|
||||||
|
throw new Error(
|
||||||
|
'Test setup failed: could not find resume command in mock',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resumeCommand.completion = mockCompletionFn;
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useCompletion(
|
||||||
|
'/chat resume ', // Trailing space, no partial argument
|
||||||
|
'/test/cwd',
|
||||||
|
true,
|
||||||
|
isolatedMockCommands,
|
||||||
|
mockCommandContext,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCompletionFn).toHaveBeenCalledWith(mockCommandContext, '');
|
||||||
|
expect(result.current.suggestions).toHaveLength(3);
|
||||||
|
expect(result.current.showSuggestions).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should suggest all top-level commands for the root slash', async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useCompletion(
|
||||||
|
'/',
|
||||||
|
'/test/cwd',
|
||||||
|
true,
|
||||||
|
mockSlashCommands,
|
||||||
|
mockCommandContext,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.suggestions.length).toBe(mockSlashCommands.length);
|
||||||
|
expect(result.current.suggestions.map((s) => s.label)).toEqual(
|
||||||
|
expect.arrayContaining(['help', 'clear', 'memory', 'chat']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide no suggestions for an invalid sub-command', async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useCompletion(
|
||||||
|
'/memory dothisnow',
|
||||||
|
'/test/cwd',
|
||||||
|
true,
|
||||||
|
mockSlashCommands,
|
||||||
|
mockCommandContext,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.suggestions).toHaveLength(0);
|
||||||
|
expect(result.current.showSuggestions).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -20,7 +20,7 @@ import {
|
||||||
MAX_SUGGESTIONS_TO_SHOW,
|
MAX_SUGGESTIONS_TO_SHOW,
|
||||||
Suggestion,
|
Suggestion,
|
||||||
} from '../components/SuggestionsDisplay.js';
|
} from '../components/SuggestionsDisplay.js';
|
||||||
import { SlashCommand } from './slashCommandProcessor.js';
|
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||||
|
|
||||||
export interface UseCompletionReturn {
|
export interface UseCompletionReturn {
|
||||||
suggestions: Suggestion[];
|
suggestions: Suggestion[];
|
||||||
|
@ -40,6 +40,7 @@ export function useCompletion(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
isActive: boolean,
|
isActive: boolean,
|
||||||
slashCommands: SlashCommand[],
|
slashCommands: SlashCommand[],
|
||||||
|
commandContext: CommandContext,
|
||||||
config?: Config,
|
config?: Config,
|
||||||
): UseCompletionReturn {
|
): UseCompletionReturn {
|
||||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||||
|
@ -123,75 +124,129 @@ export function useCompletion(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmedQuery = query.trimStart(); // Trim leading whitespace
|
const trimmedQuery = query.trimStart();
|
||||||
|
|
||||||
// --- Handle Slash Command Completion ---
|
|
||||||
if (trimmedQuery.startsWith('/')) {
|
if (trimmedQuery.startsWith('/')) {
|
||||||
const parts = trimmedQuery.substring(1).split(' ');
|
const fullPath = trimmedQuery.substring(1);
|
||||||
const commandName = parts[0];
|
const hasTrailingSpace = trimmedQuery.endsWith(' ');
|
||||||
const subCommand = parts.slice(1).join(' ');
|
|
||||||
|
|
||||||
const command = slashCommands.find(
|
// Get all non-empty parts of the command.
|
||||||
(cmd) => cmd.name === commandName || cmd.altName === commandName,
|
const rawParts = fullPath.split(/\s+/).filter((p) => p);
|
||||||
|
|
||||||
|
let commandPathParts = rawParts;
|
||||||
|
let partial = '';
|
||||||
|
|
||||||
|
// If there's no trailing space, the last part is potentially a partial segment.
|
||||||
|
// We tentatively separate it.
|
||||||
|
if (!hasTrailingSpace && rawParts.length > 0) {
|
||||||
|
partial = rawParts[rawParts.length - 1];
|
||||||
|
commandPathParts = rawParts.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traverse the Command Tree using the tentative completed path
|
||||||
|
let currentLevel: SlashCommand[] | undefined = slashCommands;
|
||||||
|
let leafCommand: SlashCommand | null = null;
|
||||||
|
|
||||||
|
for (const part of commandPathParts) {
|
||||||
|
if (!currentLevel) {
|
||||||
|
leafCommand = null;
|
||||||
|
currentLevel = [];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const found: SlashCommand | undefined = currentLevel.find(
|
||||||
|
(cmd) => cmd.name === part || cmd.altName === part,
|
||||||
|
);
|
||||||
|
if (found) {
|
||||||
|
leafCommand = found;
|
||||||
|
currentLevel = found.subCommands;
|
||||||
|
} else {
|
||||||
|
leafCommand = null;
|
||||||
|
currentLevel = [];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the Ambiguous Case
|
||||||
|
if (!hasTrailingSpace && currentLevel) {
|
||||||
|
const exactMatchAsParent = currentLevel.find(
|
||||||
|
(cmd) =>
|
||||||
|
(cmd.name === partial || cmd.altName === partial) &&
|
||||||
|
cmd.subCommands,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Continue to show command help until user types past command name.
|
if (exactMatchAsParent) {
|
||||||
if (command && command.completion && parts.length > 1) {
|
// It's a perfect match for a parent command. Override our initial guess.
|
||||||
|
// Treat it as a completed command path.
|
||||||
|
leafCommand = exactMatchAsParent;
|
||||||
|
currentLevel = exactMatchAsParent.subCommands;
|
||||||
|
partial = ''; // We now want to suggest ALL of its sub-commands.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const depth = commandPathParts.length;
|
||||||
|
|
||||||
|
// Provide Suggestions based on the now-corrected context
|
||||||
|
|
||||||
|
// Argument Completion
|
||||||
|
if (
|
||||||
|
leafCommand?.completion &&
|
||||||
|
(hasTrailingSpace ||
|
||||||
|
(rawParts.length > depth && depth > 0 && partial !== ''))
|
||||||
|
) {
|
||||||
const fetchAndSetSuggestions = async () => {
|
const fetchAndSetSuggestions = async () => {
|
||||||
setIsLoadingSuggestions(true);
|
setIsLoadingSuggestions(true);
|
||||||
if (command.completion) {
|
const argString = rawParts.slice(depth).join(' ');
|
||||||
const results = await command.completion();
|
const results =
|
||||||
const filtered = results.filter((r) => r.startsWith(subCommand));
|
(await leafCommand!.completion!(commandContext, argString)) || [];
|
||||||
const newSuggestions = filtered.map((s) => ({
|
const finalSuggestions = results.map((s) => ({ label: s, value: s }));
|
||||||
label: s,
|
setSuggestions(finalSuggestions);
|
||||||
value: s,
|
setShowSuggestions(finalSuggestions.length > 0);
|
||||||
}));
|
setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1);
|
||||||
setSuggestions(newSuggestions);
|
|
||||||
setShowSuggestions(newSuggestions.length > 0);
|
|
||||||
setActiveSuggestionIndex(newSuggestions.length > 0 ? 0 : -1);
|
|
||||||
}
|
|
||||||
setIsLoadingSuggestions(false);
|
setIsLoadingSuggestions(false);
|
||||||
};
|
};
|
||||||
fetchAndSetSuggestions();
|
fetchAndSetSuggestions();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const partialCommand = trimmedQuery.substring(1);
|
// Command/Sub-command Completion
|
||||||
const filteredSuggestions = slashCommands
|
const commandsToSearch = currentLevel || [];
|
||||||
.filter(
|
if (commandsToSearch.length > 0) {
|
||||||
|
let potentialSuggestions = commandsToSearch.filter(
|
||||||
(cmd) =>
|
(cmd) =>
|
||||||
cmd.name.startsWith(partialCommand) ||
|
cmd.description &&
|
||||||
cmd.altName?.startsWith(partialCommand),
|
(cmd.name.startsWith(partial) || cmd.altName?.startsWith(partial)),
|
||||||
)
|
|
||||||
// Filter out ? and any other single character commands unless it's the only char
|
|
||||||
.filter((cmd) => {
|
|
||||||
const nameMatch = cmd.name.startsWith(partialCommand);
|
|
||||||
const altNameMatch = cmd.altName?.startsWith(partialCommand);
|
|
||||||
if (partialCommand.length === 1) {
|
|
||||||
return nameMatch || altNameMatch; // Allow single char match if query is single char
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
(nameMatch && cmd.name.length > 1) ||
|
|
||||||
(altNameMatch && cmd.altName && cmd.altName.length > 1)
|
|
||||||
);
|
);
|
||||||
})
|
|
||||||
.filter((cmd) => cmd.description)
|
|
||||||
.map((cmd) => ({
|
|
||||||
label: cmd.name, // Always show the main name as label
|
|
||||||
value: cmd.name, // Value should be the main command name for execution
|
|
||||||
description: cmd.description,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => a.label.localeCompare(b.label));
|
|
||||||
|
|
||||||
setSuggestions(filteredSuggestions);
|
// If a user's input is an exact match and it is a leaf command,
|
||||||
setShowSuggestions(filteredSuggestions.length > 0);
|
// enter should submit immediately.
|
||||||
setActiveSuggestionIndex(filteredSuggestions.length > 0 ? 0 : -1);
|
if (potentialSuggestions.length > 0 && !hasTrailingSpace) {
|
||||||
setVisibleStartIndex(0);
|
const perfectMatch = potentialSuggestions.find(
|
||||||
|
(s) => s.name === partial,
|
||||||
|
);
|
||||||
|
if (perfectMatch && !perfectMatch.subCommands) {
|
||||||
|
potentialSuggestions = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalSuggestions = potentialSuggestions.map((cmd) => ({
|
||||||
|
label: cmd.name,
|
||||||
|
value: cmd.name,
|
||||||
|
description: cmd.description,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setSuggestions(finalSuggestions);
|
||||||
|
setShowSuggestions(finalSuggestions.length > 0);
|
||||||
|
setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1);
|
||||||
setIsLoadingSuggestions(false);
|
setIsLoadingSuggestions(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Handle At Command Completion ---
|
// If we fall through, no suggestions are available.
|
||||||
|
resetCompletionState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle At Command Completion
|
||||||
const atIndex = query.lastIndexOf('@');
|
const atIndex = query.lastIndexOf('@');
|
||||||
if (atIndex === -1) {
|
if (atIndex === -1) {
|
||||||
resetCompletionState();
|
resetCompletionState();
|
||||||
|
@ -451,7 +506,15 @@ export function useCompletion(
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
clearTimeout(debounceTimeout);
|
clearTimeout(debounceTimeout);
|
||||||
};
|
};
|
||||||
}, [query, cwd, isActive, resetCompletionState, slashCommands, config]);
|
}, [
|
||||||
|
query,
|
||||||
|
cwd,
|
||||||
|
isActive,
|
||||||
|
resetCompletionState,
|
||||||
|
slashCommands,
|
||||||
|
commandContext,
|
||||||
|
config,
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
suggestions,
|
suggestions,
|
||||||
|
|
|
@ -19,7 +19,12 @@ import {
|
||||||
import { Config, EditorType, AuthType } from '@google/gemini-cli-core';
|
import { Config, EditorType, AuthType } from '@google/gemini-cli-core';
|
||||||
import { Part, PartListUnion } from '@google/genai';
|
import { Part, PartListUnion } from '@google/genai';
|
||||||
import { UseHistoryManagerReturn } from './useHistoryManager.js';
|
import { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||||
import { HistoryItem, MessageType, StreamingState } from '../types.js';
|
import {
|
||||||
|
HistoryItem,
|
||||||
|
MessageType,
|
||||||
|
SlashCommandProcessorResult,
|
||||||
|
StreamingState,
|
||||||
|
} from '../types.js';
|
||||||
import { Dispatch, SetStateAction } from 'react';
|
import { Dispatch, SetStateAction } from 'react';
|
||||||
import { LoadedSettings } from '../../config/settings.js';
|
import { LoadedSettings } from '../../config/settings.js';
|
||||||
|
|
||||||
|
@ -360,10 +365,7 @@ describe('useGeminiStream', () => {
|
||||||
onDebugMessage: (message: string) => void;
|
onDebugMessage: (message: string) => void;
|
||||||
handleSlashCommand: (
|
handleSlashCommand: (
|
||||||
cmd: PartListUnion,
|
cmd: PartListUnion,
|
||||||
) => Promise<
|
) => Promise<SlashCommandProcessorResult | false>;
|
||||||
| import('./slashCommandProcessor.js').SlashCommandActionReturn
|
|
||||||
| boolean
|
|
||||||
>;
|
|
||||||
shellModeActive: boolean;
|
shellModeActive: boolean;
|
||||||
loadedSettings: LoadedSettings;
|
loadedSettings: LoadedSettings;
|
||||||
toolCalls?: TrackedToolCall[]; // Allow passing updated toolCalls
|
toolCalls?: TrackedToolCall[]; // Allow passing updated toolCalls
|
||||||
|
@ -396,10 +398,7 @@ describe('useGeminiStream', () => {
|
||||||
onDebugMessage: mockOnDebugMessage,
|
onDebugMessage: mockOnDebugMessage,
|
||||||
handleSlashCommand: mockHandleSlashCommand as unknown as (
|
handleSlashCommand: mockHandleSlashCommand as unknown as (
|
||||||
cmd: PartListUnion,
|
cmd: PartListUnion,
|
||||||
) => Promise<
|
) => Promise<SlashCommandProcessorResult | false>,
|
||||||
| import('./slashCommandProcessor.js').SlashCommandActionReturn
|
|
||||||
| boolean
|
|
||||||
>,
|
|
||||||
shellModeActive: false,
|
shellModeActive: false,
|
||||||
loadedSettings: mockLoadedSettings,
|
loadedSettings: mockLoadedSettings,
|
||||||
toolCalls: initialToolCalls,
|
toolCalls: initialToolCalls,
|
||||||
|
@ -966,83 +965,52 @@ describe('useGeminiStream', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Client-Initiated Tool Calls', () => {
|
describe('Slash Command Handling', () => {
|
||||||
it('should execute a client-initiated tool without sending a response to Gemini', async () => {
|
it('should schedule a tool call when the command processor returns a schedule_tool action', async () => {
|
||||||
const clientToolRequest = {
|
const clientToolRequest: SlashCommandProcessorResult = {
|
||||||
shouldScheduleTool: true,
|
type: 'schedule_tool',
|
||||||
toolName: 'save_memory',
|
toolName: 'save_memory',
|
||||||
toolArgs: { fact: 'test fact' },
|
toolArgs: { fact: 'test fact' },
|
||||||
};
|
};
|
||||||
mockHandleSlashCommand.mockResolvedValue(clientToolRequest);
|
mockHandleSlashCommand.mockResolvedValue(clientToolRequest);
|
||||||
|
|
||||||
const completedToolCall: TrackedCompletedToolCall = {
|
const { result } = renderTestHook();
|
||||||
request: {
|
|
||||||
callId: 'client-call-1',
|
|
||||||
name: clientToolRequest.toolName,
|
|
||||||
args: clientToolRequest.toolArgs,
|
|
||||||
isClientInitiated: true,
|
|
||||||
},
|
|
||||||
status: 'success',
|
|
||||||
responseSubmittedToGemini: false,
|
|
||||||
response: {
|
|
||||||
callId: 'client-call-1',
|
|
||||||
responseParts: [{ text: 'Memory saved' }],
|
|
||||||
resultDisplay: 'Success: Memory saved',
|
|
||||||
error: undefined,
|
|
||||||
},
|
|
||||||
tool: {
|
|
||||||
name: clientToolRequest.toolName,
|
|
||||||
description: 'Saves memory',
|
|
||||||
getDescription: vi.fn(),
|
|
||||||
} as any,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Capture the onComplete callback
|
|
||||||
let capturedOnComplete:
|
|
||||||
| ((completedTools: TrackedToolCall[]) => Promise<void>)
|
|
||||||
| null = null;
|
|
||||||
|
|
||||||
mockUseReactToolScheduler.mockImplementation((onComplete) => {
|
|
||||||
capturedOnComplete = onComplete;
|
|
||||||
return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted];
|
|
||||||
});
|
|
||||||
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useGeminiStream(
|
|
||||||
new MockedGeminiClientClass(mockConfig),
|
|
||||||
[],
|
|
||||||
mockAddItem,
|
|
||||||
mockSetShowHelp,
|
|
||||||
mockConfig,
|
|
||||||
mockOnDebugMessage,
|
|
||||||
mockHandleSlashCommand,
|
|
||||||
false,
|
|
||||||
() => 'vscode' as EditorType,
|
|
||||||
() => {},
|
|
||||||
() => Promise.resolve(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- User runs the slash command ---
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.submitQuery('/memory add "test fact"');
|
await result.current.submitQuery('/memory add "test fact"');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trigger the onComplete callback with the completed client-initiated tool
|
await waitFor(() => {
|
||||||
await act(async () => {
|
expect(mockScheduleToolCalls).toHaveBeenCalledWith(
|
||||||
if (capturedOnComplete) {
|
[
|
||||||
await capturedOnComplete([completedToolCall]);
|
expect.objectContaining({
|
||||||
}
|
name: 'save_memory',
|
||||||
|
args: { fact: 'test fact' },
|
||||||
|
isClientInitiated: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
expect.any(AbortSignal),
|
||||||
|
);
|
||||||
|
expect(mockSendMessageStream).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop processing and not call Gemini when a command is handled without a tool call', async () => {
|
||||||
|
const uiOnlyCommandResult: SlashCommandProcessorResult = {
|
||||||
|
type: 'handled',
|
||||||
|
};
|
||||||
|
mockHandleSlashCommand.mockResolvedValue(uiOnlyCommandResult);
|
||||||
|
|
||||||
|
const { result } = renderTestHook();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.submitQuery('/help');
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Assert the outcome ---
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// The tool should be marked as submitted locally
|
expect(mockHandleSlashCommand).toHaveBeenCalledWith('/help');
|
||||||
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith([
|
expect(mockScheduleToolCalls).not.toHaveBeenCalled();
|
||||||
'client-call-1',
|
expect(mockSendMessageStream).not.toHaveBeenCalled(); // No LLM call made
|
||||||
]);
|
|
||||||
// Crucially, no message should be sent to the Gemini API
|
|
||||||
expect(mockSendMessageStream).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -32,6 +32,7 @@ import {
|
||||||
HistoryItemWithoutId,
|
HistoryItemWithoutId,
|
||||||
HistoryItemToolGroup,
|
HistoryItemToolGroup,
|
||||||
MessageType,
|
MessageType,
|
||||||
|
SlashCommandProcessorResult,
|
||||||
ToolCallStatus,
|
ToolCallStatus,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { isAtCommand } from '../utils/commandUtils.js';
|
import { isAtCommand } from '../utils/commandUtils.js';
|
||||||
|
@ -83,9 +84,7 @@ export const useGeminiStream = (
|
||||||
onDebugMessage: (message: string) => void,
|
onDebugMessage: (message: string) => void,
|
||||||
handleSlashCommand: (
|
handleSlashCommand: (
|
||||||
cmd: PartListUnion,
|
cmd: PartListUnion,
|
||||||
) => Promise<
|
) => Promise<SlashCommandProcessorResult | false>,
|
||||||
import('./slashCommandProcessor.js').SlashCommandActionReturn | boolean
|
|
||||||
>,
|
|
||||||
shellModeActive: boolean,
|
shellModeActive: boolean,
|
||||||
getPreferredEditor: () => EditorType | undefined,
|
getPreferredEditor: () => EditorType | undefined,
|
||||||
onAuthError: () => void,
|
onAuthError: () => void,
|
||||||
|
@ -225,16 +224,10 @@ export const useGeminiStream = (
|
||||||
|
|
||||||
// Handle UI-only commands first
|
// Handle UI-only commands first
|
||||||
const slashCommandResult = await handleSlashCommand(trimmedQuery);
|
const slashCommandResult = await handleSlashCommand(trimmedQuery);
|
||||||
if (typeof slashCommandResult === 'boolean' && slashCommandResult) {
|
|
||||||
// Command was handled, and it doesn't require a tool call from here
|
if (slashCommandResult) {
|
||||||
return { queryToSend: null, shouldProceed: false };
|
if (slashCommandResult.type === 'schedule_tool') {
|
||||||
} else if (
|
|
||||||
typeof slashCommandResult === 'object' &&
|
|
||||||
slashCommandResult.shouldScheduleTool
|
|
||||||
) {
|
|
||||||
// Slash command wants to schedule a tool call (e.g., /memory add)
|
|
||||||
const { toolName, toolArgs } = slashCommandResult;
|
const { toolName, toolArgs } = slashCommandResult;
|
||||||
if (toolName && toolArgs) {
|
|
||||||
const toolCallRequest: ToolCallRequestInfo = {
|
const toolCallRequest: ToolCallRequestInfo = {
|
||||||
callId: `${toolName}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
callId: `${toolName}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
name: toolName,
|
name: toolName,
|
||||||
|
@ -243,7 +236,8 @@ export const useGeminiStream = (
|
||||||
};
|
};
|
||||||
scheduleToolCalls([toolCallRequest], abortSignal);
|
scheduleToolCalls([toolCallRequest], abortSignal);
|
||||||
}
|
}
|
||||||
return { queryToSend: null, shouldProceed: false }; // Handled by scheduling the tool
|
|
||||||
|
return { queryToSend: null, shouldProceed: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shellModeActive && handleShellCommand(trimmedQuery, abortSignal)) {
|
if (shellModeActive && handleShellCommand(trimmedQuery, abortSignal)) {
|
||||||
|
|
|
@ -216,3 +216,16 @@ export interface ConsoleMessageItem {
|
||||||
content: string;
|
content: string;
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the result of the slash command processor for its consumer (useGeminiStream).
|
||||||
|
*/
|
||||||
|
export type SlashCommandProcessorResult =
|
||||||
|
| {
|
||||||
|
type: 'schedule_tool';
|
||||||
|
toolName: string;
|
||||||
|
toolArgs: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'handled'; // Indicates the command was processed and no further action is needed.
|
||||||
|
};
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
|
||||||
import { Config, ConfigParameters, SandboxConfig } from './config.js';
|
import { Config, ConfigParameters, SandboxConfig } from './config.js';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { setGeminiMdFilename as mockSetGeminiMdFilename } from '../tools/memoryTool.js';
|
import { setGeminiMdFilename as mockSetGeminiMdFilename } from '../tools/memoryTool.js';
|
||||||
|
@ -13,6 +13,8 @@ import {
|
||||||
DEFAULT_OTLP_ENDPOINT,
|
DEFAULT_OTLP_ENDPOINT,
|
||||||
} from '../telemetry/index.js';
|
} from '../telemetry/index.js';
|
||||||
|
|
||||||
|
import { loadServerHierarchicalMemory } from '../utils/memoryDiscovery.js';
|
||||||
|
|
||||||
// Mock dependencies that might be called during Config construction or createServerConfig
|
// Mock dependencies that might be called during Config construction or createServerConfig
|
||||||
vi.mock('../tools/tool-registry', () => {
|
vi.mock('../tools/tool-registry', () => {
|
||||||
const ToolRegistryMock = vi.fn();
|
const ToolRegistryMock = vi.fn();
|
||||||
|
@ -24,6 +26,10 @@ vi.mock('../tools/tool-registry', () => {
|
||||||
return { ToolRegistry: ToolRegistryMock };
|
return { ToolRegistry: ToolRegistryMock };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock('../utils/memoryDiscovery.js', () => ({
|
||||||
|
loadServerHierarchicalMemory: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock individual tools if their constructors are complex or have side effects
|
// Mock individual tools if their constructors are complex or have side effects
|
||||||
vi.mock('../tools/ls');
|
vi.mock('../tools/ls');
|
||||||
vi.mock('../tools/read-file');
|
vi.mock('../tools/read-file');
|
||||||
|
@ -270,4 +276,38 @@ describe('Server Config (config.ts)', () => {
|
||||||
expect(config.getTelemetryOtlpEndpoint()).toBe(DEFAULT_OTLP_ENDPOINT);
|
expect(config.getTelemetryOtlpEndpoint()).toBe(DEFAULT_OTLP_ENDPOINT);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('refreshMemory', () => {
|
||||||
|
it('should update memory and file count on successful refresh', async () => {
|
||||||
|
const config = new Config(baseParams);
|
||||||
|
const mockMemoryData = {
|
||||||
|
memoryContent: 'new memory content',
|
||||||
|
fileCount: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
(loadServerHierarchicalMemory as Mock).mockResolvedValue(mockMemoryData);
|
||||||
|
|
||||||
|
const result = await config.refreshMemory();
|
||||||
|
|
||||||
|
expect(loadServerHierarchicalMemory).toHaveBeenCalledWith(
|
||||||
|
config.getWorkingDir(),
|
||||||
|
config.getDebugMode(),
|
||||||
|
config.getFileService(),
|
||||||
|
config.getExtensionContextFilePaths(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(config.getUserMemory()).toBe(mockMemoryData.memoryContent);
|
||||||
|
expect(config.getGeminiMdFileCount()).toBe(mockMemoryData.fileCount);
|
||||||
|
expect(result).toEqual(mockMemoryData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should propagate errors from loadServerHierarchicalMemory', async () => {
|
||||||
|
const config = new Config(baseParams);
|
||||||
|
const testError = new Error('Failed to load memory');
|
||||||
|
|
||||||
|
(loadServerHierarchicalMemory as Mock).mockRejectedValue(testError);
|
||||||
|
|
||||||
|
await expect(config.refreshMemory()).rejects.toThrow(testError);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -30,6 +30,7 @@ import { WebSearchTool } from '../tools/web-search.js';
|
||||||
import { GeminiClient } from '../core/client.js';
|
import { GeminiClient } from '../core/client.js';
|
||||||
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||||
import { GitService } from '../services/gitService.js';
|
import { GitService } from '../services/gitService.js';
|
||||||
|
import { loadServerHierarchicalMemory } from '../utils/memoryDiscovery.js';
|
||||||
import { getProjectTempDir } from '../utils/paths.js';
|
import { getProjectTempDir } from '../utils/paths.js';
|
||||||
import {
|
import {
|
||||||
initializeTelemetry,
|
initializeTelemetry,
|
||||||
|
@ -454,6 +455,20 @@ export class Config {
|
||||||
}
|
}
|
||||||
return this.gitService;
|
return this.gitService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async refreshMemory(): Promise<{ memoryContent: string; fileCount: number }> {
|
||||||
|
const { memoryContent, fileCount } = await loadServerHierarchicalMemory(
|
||||||
|
this.getWorkingDir(),
|
||||||
|
this.getDebugMode(),
|
||||||
|
this.getFileService(),
|
||||||
|
this.getExtensionContextFilePaths(),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.setUserMemory(memoryContent);
|
||||||
|
this.setGeminiMdFileCount(fileCount);
|
||||||
|
|
||||||
|
return { memoryContent, fileCount };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createToolRegistry(config: Config): Promise<ToolRegistry> {
|
export function createToolRegistry(config: Config): Promise<ToolRegistry> {
|
||||||
|
|
Loading…
Reference in New Issue