mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
Merge tag 'v0.1.15' into feature/yiheng/sync-gemini-cli-0.1.15
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -136,12 +136,17 @@ export async function handleAtCommand({
|
||||
|
||||
// Get centralized file discovery service
|
||||
const fileDiscovery = config.getFileService();
|
||||
const respectGitIgnore = config.getFileFilteringRespectGitIgnore();
|
||||
|
||||
const respectFileIgnore = config.getFileFilteringOptions();
|
||||
|
||||
const pathSpecsToRead: string[] = [];
|
||||
const atPathToResolvedSpecMap = new Map<string, string>();
|
||||
const contentLabelsForDisplay: string[] = [];
|
||||
const ignoredPaths: string[] = [];
|
||||
const ignoredByReason: Record<string, string[]> = {
|
||||
git: [],
|
||||
gemini: [],
|
||||
both: [],
|
||||
};
|
||||
|
||||
const toolRegistry = await config.getToolRegistry();
|
||||
const readManyFilesTool = toolRegistry.getTool('read_many_files');
|
||||
@@ -182,10 +187,31 @@ export async function handleAtCommand({
|
||||
}
|
||||
|
||||
// Check if path should be ignored based on filtering options
|
||||
if (fileDiscovery.shouldIgnoreFile(pathName, { respectGitIgnore })) {
|
||||
const reason = respectGitIgnore ? 'git-ignored' : 'custom-ignored';
|
||||
onDebugMessage(`Path ${pathName} is ${reason} and will be skipped.`);
|
||||
ignoredPaths.push(pathName);
|
||||
|
||||
const gitIgnored =
|
||||
respectFileIgnore.respectGitIgnore &&
|
||||
fileDiscovery.shouldIgnoreFile(pathName, {
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: false,
|
||||
});
|
||||
const geminiIgnored =
|
||||
respectFileIgnore.respectGeminiIgnore &&
|
||||
fileDiscovery.shouldIgnoreFile(pathName, {
|
||||
respectGitIgnore: false,
|
||||
respectGeminiIgnore: true,
|
||||
});
|
||||
|
||||
if (gitIgnored || geminiIgnored) {
|
||||
const reason =
|
||||
gitIgnored && geminiIgnored ? 'both' : gitIgnored ? 'git' : 'gemini';
|
||||
ignoredByReason[reason].push(pathName);
|
||||
const reasonText =
|
||||
reason === 'both'
|
||||
? 'ignored by both git and gemini'
|
||||
: reason === 'git'
|
||||
? 'git-ignored'
|
||||
: 'gemini-ignored';
|
||||
onDebugMessage(`Path ${pathName} is ${reasonText} and will be skipped.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -196,14 +222,13 @@ export async function handleAtCommand({
|
||||
const absolutePath = path.resolve(config.getTargetDir(), pathName);
|
||||
const stats = await fs.stat(absolutePath);
|
||||
if (stats.isDirectory()) {
|
||||
currentPathSpec = pathName.endsWith('/')
|
||||
? `${pathName}**`
|
||||
: `${pathName}/**`;
|
||||
currentPathSpec =
|
||||
pathName + (pathName.endsWith(path.sep) ? `**` : `/**`);
|
||||
onDebugMessage(
|
||||
`Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`,
|
||||
);
|
||||
} else {
|
||||
onDebugMessage(`Path ${pathName} resolved to file: ${currentPathSpec}`);
|
||||
onDebugMessage(`Path ${pathName} resolved to file: ${absolutePath}`);
|
||||
}
|
||||
resolvedSuccessfully = true;
|
||||
} catch (error) {
|
||||
@@ -214,7 +239,10 @@ export async function handleAtCommand({
|
||||
);
|
||||
try {
|
||||
const globResult = await globTool.execute(
|
||||
{ pattern: `**/*${pathName}*`, path: config.getTargetDir() },
|
||||
{
|
||||
pattern: `**/*${pathName}*`,
|
||||
path: config.getTargetDir(),
|
||||
},
|
||||
signal,
|
||||
);
|
||||
if (
|
||||
@@ -319,11 +347,26 @@ export async function handleAtCommand({
|
||||
initialQueryText = initialQueryText.trim();
|
||||
|
||||
// Inform user about ignored paths
|
||||
if (ignoredPaths.length > 0) {
|
||||
const ignoreType = respectGitIgnore ? 'git-ignored' : 'custom-ignored';
|
||||
onDebugMessage(
|
||||
`Ignored ${ignoredPaths.length} ${ignoreType} files: ${ignoredPaths.join(', ')}`,
|
||||
);
|
||||
const totalIgnored =
|
||||
ignoredByReason.git.length +
|
||||
ignoredByReason.gemini.length +
|
||||
ignoredByReason.both.length;
|
||||
|
||||
if (totalIgnored > 0) {
|
||||
const messages = [];
|
||||
if (ignoredByReason.git.length) {
|
||||
messages.push(`Git-ignored: ${ignoredByReason.git.join(', ')}`);
|
||||
}
|
||||
if (ignoredByReason.gemini.length) {
|
||||
messages.push(`Gemini-ignored: ${ignoredByReason.gemini.join(', ')}`);
|
||||
}
|
||||
if (ignoredByReason.both.length) {
|
||||
messages.push(`Ignored by both: ${ignoredByReason.both.join(', ')}`);
|
||||
}
|
||||
|
||||
const message = `Ignored ${totalIgnored} files:\n${messages.join('\n')}`;
|
||||
console.log(message);
|
||||
onDebugMessage(message);
|
||||
}
|
||||
|
||||
// Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText
|
||||
@@ -347,7 +390,11 @@ export async function handleAtCommand({
|
||||
|
||||
const toolArgs = {
|
||||
paths: pathSpecsToRead,
|
||||
respect_git_ignore: respectGitIgnore, // Use configuration setting
|
||||
file_filtering_options: {
|
||||
respect_git_ignore: respectFileIgnore.respectGitIgnore,
|
||||
respect_gemini_ignore: respectFileIgnore.respectGeminiIgnore,
|
||||
},
|
||||
// Use configuration setting
|
||||
};
|
||||
let toolCallDisplay: IndividualToolCallDisplay;
|
||||
|
||||
|
||||
@@ -5,64 +5,86 @@
|
||||
*/
|
||||
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { vi } from 'vitest';
|
||||
import { useShellCommandProcessor } from './shellCommandProcessor';
|
||||
import { Config, GeminiClient } from '@qwen-code/qwen-code-core';
|
||||
import * as fs from 'fs';
|
||||
import EventEmitter from 'events';
|
||||
import {
|
||||
vi,
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('child_process');
|
||||
const mockIsBinary = vi.hoisted(() => vi.fn());
|
||||
const mockShellExecutionService = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...original,
|
||||
ShellExecutionService: { execute: mockShellExecutionService },
|
||||
isBinary: mockIsBinary,
|
||||
};
|
||||
});
|
||||
vi.mock('fs');
|
||||
vi.mock('os', () => ({
|
||||
default: {
|
||||
platform: () => 'linux',
|
||||
tmpdir: () => '/tmp',
|
||||
},
|
||||
platform: () => 'linux',
|
||||
tmpdir: () => '/tmp',
|
||||
}));
|
||||
vi.mock('@qwen-code/qwen-code-core');
|
||||
vi.mock('../utils/textUtils.js', () => ({
|
||||
isBinary: vi.fn(),
|
||||
}));
|
||||
vi.mock('os');
|
||||
vi.mock('crypto');
|
||||
vi.mock('../utils/textUtils.js');
|
||||
|
||||
import {
|
||||
useShellCommandProcessor,
|
||||
OUTPUT_UPDATE_INTERVAL_MS,
|
||||
} from './shellCommandProcessor.js';
|
||||
import {
|
||||
type Config,
|
||||
type GeminiClient,
|
||||
type ShellExecutionResult,
|
||||
type ShellOutputEvent,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import { ToolCallStatus } from '../types.js';
|
||||
|
||||
describe('useShellCommandProcessor', () => {
|
||||
let spawnEmitter: EventEmitter;
|
||||
let addItemToHistoryMock: vi.Mock;
|
||||
let setPendingHistoryItemMock: vi.Mock;
|
||||
let onExecMock: vi.Mock;
|
||||
let onDebugMessageMock: vi.Mock;
|
||||
let configMock: Config;
|
||||
let geminiClientMock: GeminiClient;
|
||||
let addItemToHistoryMock: Mock;
|
||||
let setPendingHistoryItemMock: Mock;
|
||||
let onExecMock: Mock;
|
||||
let onDebugMessageMock: Mock;
|
||||
let mockConfig: Config;
|
||||
let mockGeminiClient: GeminiClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
const { spawn } = await import('child_process');
|
||||
spawnEmitter = new EventEmitter();
|
||||
spawnEmitter.stdout = new EventEmitter();
|
||||
spawnEmitter.stderr = new EventEmitter();
|
||||
(spawn as vi.Mock).mockReturnValue(spawnEmitter);
|
||||
let mockShellOutputCallback: (event: ShellOutputEvent) => void;
|
||||
let resolveExecutionPromise: (result: ShellExecutionResult) => void;
|
||||
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue('');
|
||||
vi.spyOn(fs, 'unlinkSync').mockReturnValue(undefined);
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
addItemToHistoryMock = vi.fn();
|
||||
setPendingHistoryItemMock = vi.fn();
|
||||
onExecMock = vi.fn();
|
||||
onDebugMessageMock = vi.fn();
|
||||
mockConfig = { getTargetDir: () => '/test/dir' } as Config;
|
||||
mockGeminiClient = { addHistory: vi.fn() } as unknown as GeminiClient;
|
||||
|
||||
configMock = {
|
||||
getTargetDir: () => '/test/dir',
|
||||
} as unknown as Config;
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.mocked(os.tmpdir).mockReturnValue('/tmp');
|
||||
(vi.mocked(crypto.randomBytes) as Mock).mockReturnValue(
|
||||
Buffer.from('abcdef', 'hex'),
|
||||
);
|
||||
mockIsBinary.mockReturnValue(false);
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
geminiClientMock = {
|
||||
addHistory: vi.fn(),
|
||||
} as unknown as GeminiClient;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => {
|
||||
mockShellOutputCallback = callback;
|
||||
return {
|
||||
pid: 12345,
|
||||
result: new Promise((resolve) => {
|
||||
resolveExecutionPromise = resolve;
|
||||
}),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const renderProcessorHook = () =>
|
||||
@@ -72,108 +94,386 @@ describe('useShellCommandProcessor', () => {
|
||||
setPendingHistoryItemMock,
|
||||
onExecMock,
|
||||
onDebugMessageMock,
|
||||
configMock,
|
||||
geminiClientMock,
|
||||
mockConfig,
|
||||
mockGeminiClient,
|
||||
),
|
||||
);
|
||||
|
||||
it('should execute a command and update history on success', async () => {
|
||||
const { result } = renderProcessorHook();
|
||||
const abortController = new AbortController();
|
||||
|
||||
act(() => {
|
||||
result.current.handleShellCommand('ls -l', abortController.signal);
|
||||
});
|
||||
|
||||
expect(onExecMock).toHaveBeenCalledTimes(1);
|
||||
const execPromise = onExecMock.mock.calls[0][0];
|
||||
|
||||
// Simulate stdout
|
||||
act(() => {
|
||||
spawnEmitter.stdout.emit('data', Buffer.from('file1.txt\nfile2.txt'));
|
||||
});
|
||||
|
||||
// Simulate process exit
|
||||
act(() => {
|
||||
spawnEmitter.emit('exit', 0, null);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await execPromise;
|
||||
});
|
||||
|
||||
expect(addItemToHistoryMock).toHaveBeenCalledTimes(2);
|
||||
expect(addItemToHistoryMock.mock.calls[1][0]).toEqual({
|
||||
type: 'info',
|
||||
text: 'file1.txt\nfile2.txt',
|
||||
});
|
||||
expect(geminiClientMock.addHistory).toHaveBeenCalledTimes(1);
|
||||
const createMockServiceResult = (
|
||||
overrides: Partial<ShellExecutionResult> = {},
|
||||
): ShellExecutionResult => ({
|
||||
rawOutput: Buffer.from(overrides.output || ''),
|
||||
output: 'Success',
|
||||
stdout: 'Success',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid: 12345,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('should handle binary output', async () => {
|
||||
it('should initiate command execution and set pending state', async () => {
|
||||
const { result } = renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.handleShellCommand('ls -l', new AbortController().signal);
|
||||
});
|
||||
|
||||
expect(addItemToHistoryMock).toHaveBeenCalledWith(
|
||||
{ type: 'user_shell', text: 'ls -l' },
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(setPendingHistoryItemMock).toHaveBeenCalledWith({
|
||||
type: 'tool_group',
|
||||
tools: [
|
||||
expect.objectContaining({
|
||||
name: 'Shell Command',
|
||||
status: ToolCallStatus.Executing,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const tmpFile = path.join(os.tmpdir(), 'shell_pwd_abcdef.tmp');
|
||||
const wrappedCommand = `{ ls -l; }; __code=$?; pwd > "${tmpFile}"; exit $__code`;
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
wrappedCommand,
|
||||
'/test/dir',
|
||||
expect.any(Function),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(onExecMock).toHaveBeenCalledWith(expect.any(Promise));
|
||||
});
|
||||
|
||||
it('should handle successful execution and update history correctly', async () => {
|
||||
const { result } = renderProcessorHook();
|
||||
const abortController = new AbortController();
|
||||
const { isBinary } = await import('../utils/textUtils.js');
|
||||
(isBinary as vi.Mock).mockReturnValue(true);
|
||||
|
||||
act(() => {
|
||||
result.current.handleShellCommand(
|
||||
'cat myimage.png',
|
||||
abortController.signal,
|
||||
'echo "ok"',
|
||||
new AbortController().signal,
|
||||
);
|
||||
});
|
||||
|
||||
expect(onExecMock).toHaveBeenCalledTimes(1);
|
||||
const execPromise = onExecMock.mock.calls[0][0];
|
||||
|
||||
act(() => {
|
||||
spawnEmitter.stdout.emit('data', Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
resolveExecutionPromise(createMockServiceResult({ output: 'ok' }));
|
||||
});
|
||||
await act(async () => await execPromise);
|
||||
|
||||
expect(setPendingHistoryItemMock).toHaveBeenCalledWith(null);
|
||||
expect(addItemToHistoryMock).toHaveBeenCalledTimes(2); // Initial + final
|
||||
expect(addItemToHistoryMock.mock.calls[1][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
tools: [
|
||||
expect.objectContaining({
|
||||
status: ToolCallStatus.Success,
|
||||
resultDisplay: 'ok',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(mockGeminiClient.addHistory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle command failure and display error status', async () => {
|
||||
const { result } = renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
spawnEmitter.emit('exit', 0, null);
|
||||
result.current.handleShellCommand(
|
||||
'bad-cmd',
|
||||
new AbortController().signal,
|
||||
);
|
||||
});
|
||||
const execPromise = onExecMock.mock.calls[0][0];
|
||||
|
||||
act(() => {
|
||||
resolveExecutionPromise(
|
||||
createMockServiceResult({ exitCode: 127, output: 'not found' }),
|
||||
);
|
||||
});
|
||||
await act(async () => await execPromise);
|
||||
|
||||
const finalHistoryItem = addItemToHistoryMock.mock.calls[1][0];
|
||||
expect(finalHistoryItem.tools[0].status).toBe(ToolCallStatus.Error);
|
||||
expect(finalHistoryItem.tools[0].resultDisplay).toContain(
|
||||
'Command exited with code 127',
|
||||
);
|
||||
expect(finalHistoryItem.tools[0].resultDisplay).toContain('not found');
|
||||
});
|
||||
|
||||
describe('UI Streaming and Throttling', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await execPromise;
|
||||
it('should throttle pending UI updates for text streams', async () => {
|
||||
const { result } = renderProcessorHook();
|
||||
act(() => {
|
||||
result.current.handleShellCommand(
|
||||
'stream',
|
||||
new AbortController().signal,
|
||||
);
|
||||
});
|
||||
|
||||
// Simulate rapid output
|
||||
act(() => {
|
||||
mockShellOutputCallback({
|
||||
type: 'data',
|
||||
stream: 'stdout',
|
||||
chunk: 'hello',
|
||||
});
|
||||
});
|
||||
|
||||
// Should not have updated the UI yet
|
||||
expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(1); // Only the initial call
|
||||
|
||||
// Advance time and send another event to trigger the throttled update
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(OUTPUT_UPDATE_INTERVAL_MS + 1);
|
||||
});
|
||||
act(() => {
|
||||
mockShellOutputCallback({
|
||||
type: 'data',
|
||||
stream: 'stdout',
|
||||
chunk: ' world',
|
||||
});
|
||||
});
|
||||
|
||||
// Should now have been called with the cumulative output
|
||||
expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(2);
|
||||
expect(setPendingHistoryItemMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: [expect.objectContaining({ resultDisplay: 'hello world' })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(addItemToHistoryMock).toHaveBeenCalledTimes(2);
|
||||
expect(addItemToHistoryMock.mock.calls[1][0]).toEqual({
|
||||
type: 'info',
|
||||
text: '[Command produced binary output, which is not shown.]',
|
||||
it('should show binary progress messages correctly', async () => {
|
||||
const { result } = renderProcessorHook();
|
||||
act(() => {
|
||||
result.current.handleShellCommand(
|
||||
'cat img',
|
||||
new AbortController().signal,
|
||||
);
|
||||
});
|
||||
|
||||
// Should immediately show the detection message
|
||||
act(() => {
|
||||
mockShellOutputCallback({ type: 'binary_detected' });
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(OUTPUT_UPDATE_INTERVAL_MS + 1);
|
||||
});
|
||||
// Send another event to trigger the update
|
||||
act(() => {
|
||||
mockShellOutputCallback({ type: 'binary_progress', bytesReceived: 0 });
|
||||
});
|
||||
|
||||
expect(setPendingHistoryItemMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: [
|
||||
expect.objectContaining({
|
||||
resultDisplay: '[Binary output detected. Halting stream...]',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// Now test progress updates
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(OUTPUT_UPDATE_INTERVAL_MS + 1);
|
||||
});
|
||||
act(() => {
|
||||
mockShellOutputCallback({
|
||||
type: 'binary_progress',
|
||||
bytesReceived: 2048,
|
||||
});
|
||||
});
|
||||
|
||||
expect(setPendingHistoryItemMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: [
|
||||
expect.objectContaining({
|
||||
resultDisplay: '[Receiving binary output... 2.0 KB received]',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle command failure', async () => {
|
||||
it('should not wrap the command on Windows', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
const { result } = renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.handleShellCommand('dir', new AbortController().signal);
|
||||
});
|
||||
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
'dir',
|
||||
'/test/dir',
|
||||
expect.any(Function),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle command abort and display cancelled status', async () => {
|
||||
const { result } = renderProcessorHook();
|
||||
const abortController = new AbortController();
|
||||
|
||||
act(() => {
|
||||
result.current.handleShellCommand(
|
||||
'a-bad-command',
|
||||
abortController.signal,
|
||||
);
|
||||
result.current.handleShellCommand('sleep 5', abortController.signal);
|
||||
});
|
||||
|
||||
const execPromise = onExecMock.mock.calls[0][0];
|
||||
|
||||
act(() => {
|
||||
spawnEmitter.stderr.emit('data', Buffer.from('command not found'));
|
||||
abortController.abort();
|
||||
resolveExecutionPromise(
|
||||
createMockServiceResult({ aborted: true, output: 'Canceled' }),
|
||||
);
|
||||
});
|
||||
await act(async () => await execPromise);
|
||||
|
||||
const finalHistoryItem = addItemToHistoryMock.mock.calls[1][0];
|
||||
expect(finalHistoryItem.tools[0].status).toBe(ToolCallStatus.Canceled);
|
||||
expect(finalHistoryItem.tools[0].resultDisplay).toContain(
|
||||
'Command was cancelled.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle binary output result correctly', async () => {
|
||||
const { result } = renderProcessorHook();
|
||||
const binaryBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
mockIsBinary.mockReturnValue(true);
|
||||
|
||||
act(() => {
|
||||
spawnEmitter.emit('exit', 127, null);
|
||||
result.current.handleShellCommand(
|
||||
'cat image.png',
|
||||
new AbortController().signal,
|
||||
);
|
||||
});
|
||||
const execPromise = onExecMock.mock.calls[0][0];
|
||||
|
||||
await act(async () => {
|
||||
await execPromise;
|
||||
act(() => {
|
||||
resolveExecutionPromise(
|
||||
createMockServiceResult({ rawOutput: binaryBuffer }),
|
||||
);
|
||||
});
|
||||
await act(async () => await execPromise);
|
||||
|
||||
const finalHistoryItem = addItemToHistoryMock.mock.calls[1][0];
|
||||
expect(finalHistoryItem.tools[0].status).toBe(ToolCallStatus.Success);
|
||||
expect(finalHistoryItem.tools[0].resultDisplay).toBe(
|
||||
'[Command produced binary output, which is not shown.]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle promise rejection and show an error', async () => {
|
||||
const { result } = renderProcessorHook();
|
||||
const testError = new Error('Unexpected failure');
|
||||
mockShellExecutionService.mockImplementation(() => ({
|
||||
pid: 12345,
|
||||
result: Promise.reject(testError),
|
||||
}));
|
||||
|
||||
act(() => {
|
||||
result.current.handleShellCommand(
|
||||
'a-command',
|
||||
new AbortController().signal,
|
||||
);
|
||||
});
|
||||
const execPromise = onExecMock.mock.calls[0][0];
|
||||
|
||||
await act(async () => await execPromise);
|
||||
|
||||
expect(setPendingHistoryItemMock).toHaveBeenCalledWith(null);
|
||||
expect(addItemToHistoryMock).toHaveBeenCalledTimes(2);
|
||||
expect(addItemToHistoryMock.mock.calls[1][0]).toEqual({
|
||||
type: 'error',
|
||||
text: 'Command exited with code 127.\ncommand not found',
|
||||
text: 'An unexpected error occurred: Unexpected failure',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle synchronous errors during execution and clean up resources', async () => {
|
||||
const testError = new Error('Synchronous spawn error');
|
||||
mockShellExecutionService.mockImplementation(() => {
|
||||
throw testError;
|
||||
});
|
||||
// Mock that the temp file was created before the error was thrown
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
|
||||
const { result } = renderProcessorHook();
|
||||
|
||||
act(() => {
|
||||
result.current.handleShellCommand(
|
||||
'a-command',
|
||||
new AbortController().signal,
|
||||
);
|
||||
});
|
||||
const execPromise = onExecMock.mock.calls[0][0];
|
||||
|
||||
await act(async () => await execPromise);
|
||||
|
||||
expect(setPendingHistoryItemMock).toHaveBeenCalledWith(null);
|
||||
expect(addItemToHistoryMock).toHaveBeenCalledTimes(2);
|
||||
expect(addItemToHistoryMock.mock.calls[1][0]).toEqual({
|
||||
type: 'error',
|
||||
text: 'An unexpected error occurred: Synchronous spawn error',
|
||||
});
|
||||
const tmpFile = path.join(os.tmpdir(), 'shell_pwd_abcdef.tmp');
|
||||
// Verify that the temporary file was cleaned up
|
||||
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
|
||||
});
|
||||
|
||||
describe('Directory Change Warning', () => {
|
||||
it('should show a warning if the working directory changes', async () => {
|
||||
const tmpFile = path.join(os.tmpdir(), 'shell_pwd_abcdef.tmp');
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('/test/dir/new'); // A different directory
|
||||
|
||||
const { result } = renderProcessorHook();
|
||||
act(() => {
|
||||
result.current.handleShellCommand(
|
||||
'cd new',
|
||||
new AbortController().signal,
|
||||
);
|
||||
});
|
||||
const execPromise = onExecMock.mock.calls[0][0];
|
||||
|
||||
act(() => {
|
||||
resolveExecutionPromise(createMockServiceResult());
|
||||
});
|
||||
await act(async () => await execPromise);
|
||||
|
||||
const finalHistoryItem = addItemToHistoryMock.mock.calls[1][0];
|
||||
expect(finalHistoryItem.tools[0].resultDisplay).toContain(
|
||||
"WARNING: shell mode is stateless; the directory change to '/test/dir/new' will not persist.",
|
||||
);
|
||||
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
|
||||
});
|
||||
|
||||
it('should NOT show a warning if the directory does not change', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('/test/dir'); // The same directory
|
||||
|
||||
const { result } = renderProcessorHook();
|
||||
act(() => {
|
||||
result.current.handleShellCommand('ls', new AbortController().signal);
|
||||
});
|
||||
const execPromise = onExecMock.mock.calls[0][0];
|
||||
|
||||
act(() => {
|
||||
resolveExecutionPromise(createMockServiceResult());
|
||||
});
|
||||
await act(async () => await execPromise);
|
||||
|
||||
const finalHistoryItem = addItemToHistoryMock.mock.calls[1][0];
|
||||
expect(finalHistoryItem.tools[0].resultDisplay).not.toContain('WARNING');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,174 +4,31 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { StringDecoder } from 'string_decoder';
|
||||
import type { HistoryItemWithoutId } from '../types.js';
|
||||
import {
|
||||
HistoryItemWithoutId,
|
||||
IndividualToolCallDisplay,
|
||||
ToolCallStatus,
|
||||
} from '../types.js';
|
||||
import { useCallback } from 'react';
|
||||
import { Config, GeminiClient } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
Config,
|
||||
GeminiClient,
|
||||
isBinary,
|
||||
ShellExecutionResult,
|
||||
ShellExecutionService,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { type PartListUnion } from '@google/genai';
|
||||
import { formatMemoryUsage } from '../utils/formatters.js';
|
||||
import { isBinary } from '../utils/textUtils.js';
|
||||
import { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import { SHELL_COMMAND_NAME } from '../constants.js';
|
||||
import { formatMemoryUsage } from '../utils/formatters.js';
|
||||
import crypto from 'crypto';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
|
||||
const OUTPUT_UPDATE_INTERVAL_MS = 1000;
|
||||
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
|
||||
const MAX_OUTPUT_LENGTH = 10000;
|
||||
|
||||
/**
|
||||
* A structured result from a shell command execution.
|
||||
*/
|
||||
interface ShellExecutionResult {
|
||||
rawOutput: Buffer;
|
||||
output: string;
|
||||
exitCode: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
error: Error | null;
|
||||
aborted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a shell command using `spawn`, capturing all output and lifecycle events.
|
||||
* This is the single, unified implementation for shell execution.
|
||||
*
|
||||
* @param commandToExecute The exact command string to run.
|
||||
* @param cwd The working directory to execute the command in.
|
||||
* @param abortSignal An AbortSignal to terminate the process.
|
||||
* @param onOutputChunk A callback for streaming real-time output.
|
||||
* @param onDebugMessage A callback for logging debug information.
|
||||
* @returns A promise that resolves with the complete execution result.
|
||||
*/
|
||||
function executeShellCommand(
|
||||
commandToExecute: string,
|
||||
cwd: string,
|
||||
abortSignal: AbortSignal,
|
||||
onOutputChunk: (chunk: string) => void,
|
||||
onDebugMessage: (message: string) => void,
|
||||
): Promise<ShellExecutionResult> {
|
||||
return new Promise((resolve) => {
|
||||
const isWindows = os.platform() === 'win32';
|
||||
const shell = isWindows ? 'cmd.exe' : 'bash';
|
||||
const shellArgs = isWindows
|
||||
? ['/c', commandToExecute]
|
||||
: ['-c', commandToExecute];
|
||||
|
||||
const child = spawn(shell, shellArgs, {
|
||||
cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: !isWindows, // Use process groups on non-Windows for robust killing
|
||||
});
|
||||
|
||||
// Use decoders to handle multi-byte characters safely (for streaming output).
|
||||
const stdoutDecoder = new StringDecoder('utf8');
|
||||
const stderrDecoder = new StringDecoder('utf8');
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const outputChunks: Buffer[] = [];
|
||||
let error: Error | null = null;
|
||||
let exited = false;
|
||||
|
||||
let streamToUi = true;
|
||||
const MAX_SNIFF_SIZE = 4096;
|
||||
let sniffedBytes = 0;
|
||||
|
||||
const handleOutput = (data: Buffer, stream: 'stdout' | 'stderr') => {
|
||||
outputChunks.push(data);
|
||||
|
||||
if (streamToUi && sniffedBytes < MAX_SNIFF_SIZE) {
|
||||
// Use a limited-size buffer for the check to avoid performance issues.
|
||||
const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20));
|
||||
sniffedBytes = sniffBuffer.length;
|
||||
|
||||
if (isBinary(sniffBuffer)) {
|
||||
streamToUi = false;
|
||||
// Overwrite any garbled text that may have streamed with a clear message.
|
||||
onOutputChunk('[Binary output detected. Halting stream...]');
|
||||
}
|
||||
}
|
||||
|
||||
const decodedChunk =
|
||||
stream === 'stdout'
|
||||
? stdoutDecoder.write(data)
|
||||
: stderrDecoder.write(data);
|
||||
if (stream === 'stdout') {
|
||||
stdout += stripAnsi(decodedChunk);
|
||||
} else {
|
||||
stderr += stripAnsi(decodedChunk);
|
||||
}
|
||||
|
||||
if (!exited && streamToUi) {
|
||||
// Send only the new chunk to avoid re-rendering the whole output.
|
||||
const combinedOutput = stdout + (stderr ? `\n${stderr}` : '');
|
||||
onOutputChunk(combinedOutput);
|
||||
} else if (!exited && !streamToUi) {
|
||||
// Send progress updates for the binary stream
|
||||
const totalBytes = outputChunks.reduce(
|
||||
(sum, chunk) => sum + chunk.length,
|
||||
0,
|
||||
);
|
||||
onOutputChunk(
|
||||
`[Receiving binary output... ${formatMemoryUsage(totalBytes)} received]`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout.on('data', (data) => handleOutput(data, 'stdout'));
|
||||
child.stderr.on('data', (data) => handleOutput(data, 'stderr'));
|
||||
child.on('error', (err) => {
|
||||
error = err;
|
||||
});
|
||||
|
||||
const abortHandler = async () => {
|
||||
if (child.pid && !exited) {
|
||||
onDebugMessage(`Aborting shell command (PID: ${child.pid})`);
|
||||
if (isWindows) {
|
||||
spawn('taskkill', ['/pid', child.pid.toString(), '/f', '/t']);
|
||||
} else {
|
||||
try {
|
||||
// Kill the entire process group (negative PID).
|
||||
// SIGTERM first, then SIGKILL if it doesn't die.
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
await new Promise((res) => setTimeout(res, 200));
|
||||
if (!exited) {
|
||||
process.kill(-child.pid, 'SIGKILL');
|
||||
}
|
||||
} catch (_e) {
|
||||
// Fallback to killing just the main process if group kill fails.
|
||||
if (!exited) child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
abortSignal.addEventListener('abort', abortHandler, { once: true });
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
exited = true;
|
||||
abortSignal.removeEventListener('abort', abortHandler);
|
||||
|
||||
// Handle any final bytes lingering in the decoders
|
||||
stdout += stdoutDecoder.end();
|
||||
stderr += stderrDecoder.end();
|
||||
|
||||
const finalBuffer = Buffer.concat(outputChunks);
|
||||
|
||||
resolve({
|
||||
rawOutput: finalBuffer,
|
||||
output: stdout + (stderr ? `\n${stderr}` : ''),
|
||||
exitCode: code,
|
||||
signal,
|
||||
error,
|
||||
aborted: abortSignal.aborted,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function addShellCommandToGeminiHistory(
|
||||
geminiClient: GeminiClient,
|
||||
rawQuery: string,
|
||||
@@ -221,6 +78,7 @@ export const useShellCommandProcessor = (
|
||||
}
|
||||
|
||||
const userMessageTimestamp = Date.now();
|
||||
const callId = `shell-${userMessageTimestamp}`;
|
||||
addItemToHistory(
|
||||
{ type: 'user_shell', text: rawQuery },
|
||||
userMessageTimestamp,
|
||||
@@ -244,95 +102,203 @@ export const useShellCommandProcessor = (
|
||||
}
|
||||
|
||||
const execPromise = new Promise<void>((resolve) => {
|
||||
let lastUpdateTime = 0;
|
||||
let lastUpdateTime = Date.now();
|
||||
let cumulativeStdout = '';
|
||||
let cumulativeStderr = '';
|
||||
let isBinaryStream = false;
|
||||
let binaryBytesReceived = 0;
|
||||
|
||||
const initialToolDisplay: IndividualToolCallDisplay = {
|
||||
callId,
|
||||
name: SHELL_COMMAND_NAME,
|
||||
description: rawQuery,
|
||||
status: ToolCallStatus.Executing,
|
||||
resultDisplay: '',
|
||||
confirmationDetails: undefined,
|
||||
};
|
||||
|
||||
setPendingHistoryItem({
|
||||
type: 'tool_group',
|
||||
tools: [initialToolDisplay],
|
||||
});
|
||||
|
||||
let executionPid: number | undefined;
|
||||
|
||||
const abortHandler = () => {
|
||||
onDebugMessage(
|
||||
`Aborting shell command (PID: ${executionPid ?? 'unknown'})`,
|
||||
);
|
||||
};
|
||||
abortSignal.addEventListener('abort', abortHandler, { once: true });
|
||||
|
||||
onDebugMessage(`Executing in ${targetDir}: ${commandToExecute}`);
|
||||
executeShellCommand(
|
||||
commandToExecute,
|
||||
targetDir,
|
||||
abortSignal,
|
||||
(streamedOutput) => {
|
||||
// Throttle pending UI updates to avoid excessive re-renders.
|
||||
if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) {
|
||||
setPendingHistoryItem({ type: 'info', text: streamedOutput });
|
||||
lastUpdateTime = Date.now();
|
||||
}
|
||||
},
|
||||
onDebugMessage,
|
||||
)
|
||||
.then((result) => {
|
||||
// TODO(abhipatel12) - Consider updating pending item and using timeout to ensure
|
||||
// there is no jump where intermediate output is skipped.
|
||||
setPendingHistoryItem(null);
|
||||
|
||||
let historyItemType: HistoryItemWithoutId['type'] = 'info';
|
||||
let mainContent: string;
|
||||
|
||||
// The context sent to the model utilizes a text tokenizer which means raw binary data is
|
||||
// cannot be parsed and understood and thus would only pollute the context window and waste
|
||||
// tokens.
|
||||
if (isBinary(result.rawOutput)) {
|
||||
mainContent =
|
||||
'[Command produced binary output, which is not shown.]';
|
||||
} else {
|
||||
mainContent =
|
||||
result.output.trim() || '(Command produced no output)';
|
||||
}
|
||||
|
||||
let finalOutput = mainContent;
|
||||
|
||||
if (result.error) {
|
||||
historyItemType = 'error';
|
||||
finalOutput = `${result.error.message}\n${finalOutput}`;
|
||||
} else if (result.aborted) {
|
||||
finalOutput = `Command was cancelled.\n${finalOutput}`;
|
||||
} else if (result.signal) {
|
||||
historyItemType = 'error';
|
||||
finalOutput = `Command terminated by signal: ${result.signal}.\n${finalOutput}`;
|
||||
} else if (result.exitCode !== 0) {
|
||||
historyItemType = 'error';
|
||||
finalOutput = `Command exited with code ${result.exitCode}.\n${finalOutput}`;
|
||||
}
|
||||
|
||||
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
|
||||
const finalPwd = fs.readFileSync(pwdFilePath, 'utf8').trim();
|
||||
if (finalPwd && finalPwd !== targetDir) {
|
||||
const warning = `WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.`;
|
||||
finalOutput = `${warning}\n\n${finalOutput}`;
|
||||
try {
|
||||
const { pid, result } = ShellExecutionService.execute(
|
||||
commandToExecute,
|
||||
targetDir,
|
||||
(event) => {
|
||||
switch (event.type) {
|
||||
case 'data':
|
||||
// Do not process text data if we've already switched to binary mode.
|
||||
if (isBinaryStream) break;
|
||||
if (event.stream === 'stdout') {
|
||||
cumulativeStdout += event.chunk;
|
||||
} else {
|
||||
cumulativeStderr += event.chunk;
|
||||
}
|
||||
break;
|
||||
case 'binary_detected':
|
||||
isBinaryStream = true;
|
||||
break;
|
||||
case 'binary_progress':
|
||||
isBinaryStream = true;
|
||||
binaryBytesReceived = event.bytesReceived;
|
||||
break;
|
||||
default: {
|
||||
throw new Error('An unhandled ShellOutputEvent was found.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the complete, contextual result to the local UI history.
|
||||
addItemToHistory(
|
||||
{ type: historyItemType, text: finalOutput },
|
||||
userMessageTimestamp,
|
||||
);
|
||||
// Compute the display string based on the *current* state.
|
||||
let currentDisplayOutput: string;
|
||||
if (isBinaryStream) {
|
||||
if (binaryBytesReceived > 0) {
|
||||
currentDisplayOutput = `[Receiving binary output... ${formatMemoryUsage(
|
||||
binaryBytesReceived,
|
||||
)} received]`;
|
||||
} else {
|
||||
currentDisplayOutput =
|
||||
'[Binary output detected. Halting stream...]';
|
||||
}
|
||||
} else {
|
||||
currentDisplayOutput =
|
||||
cumulativeStdout +
|
||||
(cumulativeStderr ? `\n${cumulativeStderr}` : '');
|
||||
}
|
||||
|
||||
// Add the same complete, contextual result to the LLM's history.
|
||||
addShellCommandToGeminiHistory(geminiClient, rawQuery, finalOutput);
|
||||
})
|
||||
.catch((err) => {
|
||||
setPendingHistoryItem(null);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
addItemToHistory(
|
||||
{
|
||||
type: 'error',
|
||||
text: `An unexpected error occurred: ${errorMessage}`,
|
||||
},
|
||||
userMessageTimestamp,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
|
||||
fs.unlinkSync(pwdFilePath);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
// Throttle pending UI updates to avoid excessive re-renders.
|
||||
if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) {
|
||||
setPendingHistoryItem({
|
||||
type: 'tool_group',
|
||||
tools: [
|
||||
{
|
||||
...initialToolDisplay,
|
||||
resultDisplay: currentDisplayOutput,
|
||||
},
|
||||
],
|
||||
});
|
||||
lastUpdateTime = Date.now();
|
||||
}
|
||||
},
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
executionPid = pid;
|
||||
|
||||
result
|
||||
.then((result: ShellExecutionResult) => {
|
||||
setPendingHistoryItem(null);
|
||||
|
||||
let mainContent: string;
|
||||
|
||||
if (isBinary(result.rawOutput)) {
|
||||
mainContent =
|
||||
'[Command produced binary output, which is not shown.]';
|
||||
} else {
|
||||
mainContent =
|
||||
result.output.trim() || '(Command produced no output)';
|
||||
}
|
||||
|
||||
let finalOutput = mainContent;
|
||||
let finalStatus = ToolCallStatus.Success;
|
||||
|
||||
if (result.error) {
|
||||
finalStatus = ToolCallStatus.Error;
|
||||
finalOutput = `${result.error.message}\n${finalOutput}`;
|
||||
} else if (result.aborted) {
|
||||
finalStatus = ToolCallStatus.Canceled;
|
||||
finalOutput = `Command was cancelled.\n${finalOutput}`;
|
||||
} else if (result.signal) {
|
||||
finalStatus = ToolCallStatus.Error;
|
||||
finalOutput = `Command terminated by signal: ${result.signal}.\n${finalOutput}`;
|
||||
} else if (result.exitCode !== 0) {
|
||||
finalStatus = ToolCallStatus.Error;
|
||||
finalOutput = `Command exited with code ${result.exitCode}.\n${finalOutput}`;
|
||||
}
|
||||
|
||||
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
|
||||
const finalPwd = fs.readFileSync(pwdFilePath, 'utf8').trim();
|
||||
if (finalPwd && finalPwd !== targetDir) {
|
||||
const warning = `WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.`;
|
||||
finalOutput = `${warning}\n\n${finalOutput}`;
|
||||
}
|
||||
}
|
||||
|
||||
const finalToolDisplay: IndividualToolCallDisplay = {
|
||||
...initialToolDisplay,
|
||||
status: finalStatus,
|
||||
resultDisplay: finalOutput,
|
||||
};
|
||||
|
||||
// Add the complete, contextual result to the local UI history.
|
||||
addItemToHistory(
|
||||
{
|
||||
type: 'tool_group',
|
||||
tools: [finalToolDisplay],
|
||||
} as HistoryItemWithoutId,
|
||||
userMessageTimestamp,
|
||||
);
|
||||
|
||||
// Add the same complete, contextual result to the LLM's history.
|
||||
addShellCommandToGeminiHistory(
|
||||
geminiClient,
|
||||
rawQuery,
|
||||
finalOutput,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
setPendingHistoryItem(null);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
addItemToHistory(
|
||||
{
|
||||
type: 'error',
|
||||
text: `An unexpected error occurred: ${errorMessage}`,
|
||||
},
|
||||
userMessageTimestamp,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
abortSignal.removeEventListener('abort', abortHandler);
|
||||
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
|
||||
fs.unlinkSync(pwdFilePath);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
} catch (err) {
|
||||
// This block handles synchronous errors from `execute`
|
||||
setPendingHistoryItem(null);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
addItemToHistory(
|
||||
{
|
||||
type: 'error',
|
||||
text: `An unexpected error occurred: ${errorMessage}`,
|
||||
},
|
||||
userMessageTimestamp,
|
||||
);
|
||||
|
||||
// Perform cleanup here as well
|
||||
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
|
||||
fs.unlinkSync(pwdFilePath);
|
||||
}
|
||||
|
||||
resolve(); // Resolve the promise to unblock `onExec`
|
||||
}
|
||||
});
|
||||
|
||||
onExec(execPromise);
|
||||
return true; // Command was initiated
|
||||
return true;
|
||||
},
|
||||
[
|
||||
config,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -55,8 +55,12 @@ export const useAuthCommand = (
|
||||
async (authType: AuthType | undefined, scope: SettingScope) => {
|
||||
if (authType) {
|
||||
await clearCachedCredentialFile();
|
||||
|
||||
settings.setValue(scope, 'selectedAuthType', authType);
|
||||
if (authType === AuthType.LOGIN_WITH_GOOGLE && config.getNoBrowser()) {
|
||||
if (
|
||||
authType === AuthType.LOGIN_WITH_GOOGLE &&
|
||||
config.isBrowserLaunchSuppressed()
|
||||
) {
|
||||
runExitCleanup();
|
||||
console.log(
|
||||
`
|
||||
|
||||
@@ -1,755 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import type { Mocked } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useCompletion } from './useCompletion.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import { glob } from 'glob';
|
||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import { Config, FileDiscoveryService } from '@qwen-code/qwen-code-core';
|
||||
|
||||
interface MockConfig {
|
||||
getFileFilteringRespectGitIgnore: () => boolean;
|
||||
getEnableRecursiveFileSearch: () => boolean;
|
||||
getFileService: () => FileDiscoveryService | null;
|
||||
}
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('fs/promises');
|
||||
vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||
const actual = await vi.importActual('@qwen-code/qwen-code-core');
|
||||
return {
|
||||
...actual,
|
||||
FileDiscoveryService: vi.fn(),
|
||||
isNodeError: vi.fn((error) => error.code === 'ENOENT'),
|
||||
escapePath: vi.fn((path) => path),
|
||||
unescapePath: vi.fn((path) => path),
|
||||
getErrorMessage: vi.fn((error) => error.message),
|
||||
};
|
||||
});
|
||||
vi.mock('glob');
|
||||
|
||||
describe('useCompletion git-aware filtering integration', () => {
|
||||
let mockFileDiscoveryService: Mocked<FileDiscoveryService>;
|
||||
let mockConfig: MockConfig;
|
||||
|
||||
const testCwd = '/test/project';
|
||||
const slashCommands = [
|
||||
{ name: 'help', description: 'Show help', 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(() => {
|
||||
mockFileDiscoveryService = {
|
||||
shouldGitIgnoreFile: vi.fn(),
|
||||
shouldGeminiIgnoreFile: vi.fn(),
|
||||
shouldIgnoreFile: vi.fn(),
|
||||
filterFiles: vi.fn(),
|
||||
getGeminiIgnorePatterns: vi.fn(),
|
||||
projectRoot: '',
|
||||
gitIgnoreFilter: null,
|
||||
geminiIgnoreFilter: null,
|
||||
} as unknown as Mocked<FileDiscoveryService>;
|
||||
|
||||
mockConfig = {
|
||||
getFileFilteringRespectGitIgnore: vi.fn(() => true),
|
||||
getFileService: vi.fn().mockReturnValue(mockFileDiscoveryService),
|
||||
getEnableRecursiveFileSearch: vi.fn(() => true),
|
||||
};
|
||||
|
||||
vi.mocked(FileDiscoveryService).mockImplementation(
|
||||
() => mockFileDiscoveryService,
|
||||
);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should filter git-ignored entries from @ completions', async () => {
|
||||
const globResults = [`${testCwd}/data`, `${testCwd}/dist`];
|
||||
vi.mocked(glob).mockResolvedValue(globResults);
|
||||
|
||||
// Mock git ignore service to ignore certain files
|
||||
mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation(
|
||||
(path: string) => path.includes('dist'),
|
||||
);
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
|
||||
(path: string, options) => {
|
||||
if (options?.respectGitIgnore !== false) {
|
||||
return mockFileDiscoveryService.shouldGitIgnoreFile(path);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@d',
|
||||
testCwd,
|
||||
true,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig as Config,
|
||||
),
|
||||
);
|
||||
|
||||
// Wait for async operations to complete
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150)); // Account for debounce
|
||||
});
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(1);
|
||||
expect(result.current.suggestions).toEqual(
|
||||
expect.arrayContaining([{ label: 'data', value: 'data' }]),
|
||||
);
|
||||
expect(result.current.showSuggestions).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter git-ignored directories from @ completions', async () => {
|
||||
// Mock fs.readdir to return both regular and git-ignored directories
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'src', isDirectory: () => true },
|
||||
{ name: 'node_modules', isDirectory: () => true },
|
||||
{ name: 'dist', isDirectory: () => true },
|
||||
{ name: 'README.md', isDirectory: () => false },
|
||||
{ name: '.env', isDirectory: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||
|
||||
// Mock git ignore service to ignore certain files
|
||||
mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation(
|
||||
(path: string) =>
|
||||
path.includes('node_modules') ||
|
||||
path.includes('dist') ||
|
||||
path.includes('.env'),
|
||||
);
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
|
||||
(path: string, options) => {
|
||||
if (options?.respectGitIgnore !== false) {
|
||||
return mockFileDiscoveryService.shouldGitIgnoreFile(path);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@',
|
||||
testCwd,
|
||||
true,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig as Config,
|
||||
),
|
||||
);
|
||||
|
||||
// Wait for async operations to complete
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150)); // Account for debounce
|
||||
});
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(2);
|
||||
expect(result.current.suggestions).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ label: 'src/', value: 'src/' },
|
||||
{ label: 'README.md', value: 'README.md' },
|
||||
]),
|
||||
);
|
||||
expect(result.current.showSuggestions).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle recursive search with git-aware filtering', async () => {
|
||||
// Mock the recursive file search scenario
|
||||
vi.mocked(fs.readdir).mockImplementation(
|
||||
async (dirPath: string | Buffer | URL) => {
|
||||
if (dirPath === testCwd) {
|
||||
return [
|
||||
{ name: 'src', isDirectory: () => true },
|
||||
{ name: 'node_modules', isDirectory: () => true },
|
||||
{ name: 'temp', isDirectory: () => true },
|
||||
] as Array<{ name: string; isDirectory: () => boolean }>;
|
||||
}
|
||||
if (dirPath.endsWith('/src')) {
|
||||
return [
|
||||
{ name: 'index.ts', isDirectory: () => false },
|
||||
{ name: 'components', isDirectory: () => true },
|
||||
] as Array<{ name: string; isDirectory: () => boolean }>;
|
||||
}
|
||||
if (dirPath.endsWith('/temp')) {
|
||||
return [{ name: 'temp.log', isDirectory: () => false }] as Array<{
|
||||
name: string;
|
||||
isDirectory: () => boolean;
|
||||
}>;
|
||||
}
|
||||
return [] as Array<{ name: string; isDirectory: () => boolean }>;
|
||||
},
|
||||
);
|
||||
|
||||
// Mock git ignore service
|
||||
mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation(
|
||||
(path: string) => path.includes('node_modules') || path.includes('temp'),
|
||||
);
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
|
||||
(path: string, options) => {
|
||||
if (options?.respectGitIgnore !== false) {
|
||||
return mockFileDiscoveryService.shouldGitIgnoreFile(path);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@t',
|
||||
testCwd,
|
||||
true,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig as Config,
|
||||
),
|
||||
);
|
||||
|
||||
// Wait for async operations to complete
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
// Should not include anything from node_modules or dist
|
||||
const suggestionLabels = result.current.suggestions.map((s) => s.label);
|
||||
expect(suggestionLabels).not.toContain('temp/');
|
||||
expect(suggestionLabels.some((l) => l.includes('node_modules'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not perform recursive search when disabled in config', async () => {
|
||||
const globResults = [`${testCwd}/data`, `${testCwd}/dist`];
|
||||
vi.mocked(glob).mockResolvedValue(globResults);
|
||||
|
||||
// Disable recursive search in the mock config
|
||||
const mockConfigNoRecursive = {
|
||||
...mockConfig,
|
||||
getEnableRecursiveFileSearch: vi.fn(() => false),
|
||||
} as unknown as Config;
|
||||
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'data', isDirectory: () => true },
|
||||
{ name: 'dist', isDirectory: () => true },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||
|
||||
renderHook(() =>
|
||||
useCompletion(
|
||||
'@d',
|
||||
testCwd,
|
||||
true,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
mockConfigNoRecursive,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
// `glob` should not be called because recursive search is disabled
|
||||
expect(glob).not.toHaveBeenCalled();
|
||||
// `fs.readdir` should be called for the top-level directory instead
|
||||
expect(fs.readdir).toHaveBeenCalledWith(testCwd, { withFileTypes: true });
|
||||
});
|
||||
|
||||
it('should work without config (fallback behavior)', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'src', isDirectory: () => true },
|
||||
{ name: 'node_modules', isDirectory: () => true },
|
||||
{ name: 'README.md', isDirectory: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@',
|
||||
testCwd,
|
||||
true,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
// Without config, should include all files
|
||||
expect(result.current.suggestions).toHaveLength(3);
|
||||
expect(result.current.suggestions).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ label: 'src/', value: 'src/' },
|
||||
{ label: 'node_modules/', value: 'node_modules/' },
|
||||
{ label: 'README.md', value: 'README.md' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle git discovery service initialization failure gracefully', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'src', isDirectory: () => true },
|
||||
{ name: 'README.md', isDirectory: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@',
|
||||
testCwd,
|
||||
true,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig as Config,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
// Since we use centralized service, initialization errors are handled at config level
|
||||
// This test should verify graceful fallback behavior
|
||||
expect(result.current.suggestions.length).toBeGreaterThanOrEqual(0);
|
||||
// Should still show completions even if git discovery fails
|
||||
expect(result.current.suggestions.length).toBeGreaterThan(0);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle directory-specific completions with git filtering', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'component.tsx', isDirectory: () => false },
|
||||
{ name: 'temp.log', isDirectory: () => false },
|
||||
{ name: 'index.ts', isDirectory: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||
|
||||
mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation(
|
||||
(path: string) => path.includes('.log'),
|
||||
);
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
|
||||
(path: string, options) => {
|
||||
if (options?.respectGitIgnore !== false) {
|
||||
return mockFileDiscoveryService.shouldGitIgnoreFile(path);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@src/comp',
|
||||
testCwd,
|
||||
true,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig as Config,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
// Should filter out .log files but include matching .tsx files
|
||||
expect(result.current.suggestions).toEqual([
|
||||
{ label: 'component.tsx', value: 'component.tsx' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use glob for top-level @ completions when available', async () => {
|
||||
const globResults = [`${testCwd}/src/index.ts`, `${testCwd}/README.md`];
|
||||
vi.mocked(glob).mockResolvedValue(globResults);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@s',
|
||||
testCwd,
|
||||
true,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig as Config,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
expect(glob).toHaveBeenCalledWith('**/s*', {
|
||||
cwd: testCwd,
|
||||
dot: false,
|
||||
nocase: true,
|
||||
});
|
||||
expect(fs.readdir).not.toHaveBeenCalled(); // Ensure glob is used instead of readdir
|
||||
expect(result.current.suggestions).toEqual([
|
||||
{ label: 'README.md', value: 'README.md' },
|
||||
{ label: 'src/index.ts', value: 'src/index.ts' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should include dotfiles in glob search when input starts with a dot', async () => {
|
||||
const globResults = [
|
||||
`${testCwd}/.env`,
|
||||
`${testCwd}/.gitignore`,
|
||||
`${testCwd}/src/index.ts`,
|
||||
];
|
||||
vi.mocked(glob).mockResolvedValue(globResults);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@.',
|
||||
testCwd,
|
||||
true,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig as Config,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
expect(glob).toHaveBeenCalledWith('**/.*', {
|
||||
cwd: testCwd,
|
||||
dot: true,
|
||||
nocase: true,
|
||||
});
|
||||
expect(fs.readdir).not.toHaveBeenCalled();
|
||||
expect(result.current.suggestions).toEqual([
|
||||
{ label: '.env', value: '.env' },
|
||||
{ label: '.gitignore', value: '.gitignore' },
|
||||
{ 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);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { glob } from 'glob';
|
||||
@@ -15,12 +15,16 @@ import {
|
||||
getErrorMessage,
|
||||
Config,
|
||||
FileDiscoveryService,
|
||||
DEFAULT_FILE_FILTERING_OPTIONS,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
MAX_SUGGESTIONS_TO_SHOW,
|
||||
Suggestion,
|
||||
} from '../components/SuggestionsDisplay.js';
|
||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import { TextBuffer } from '../components/shared/text-buffer.js';
|
||||
import { isSlashCommand } from '../utils/commandUtils.js';
|
||||
import { toCodePoints } from '../utils/textUtils.js';
|
||||
|
||||
export interface UseCompletionReturn {
|
||||
suggestions: Suggestion[];
|
||||
@@ -28,18 +32,19 @@ export interface UseCompletionReturn {
|
||||
visibleStartIndex: number;
|
||||
showSuggestions: boolean;
|
||||
isLoadingSuggestions: boolean;
|
||||
isPerfectMatch: boolean;
|
||||
setActiveSuggestionIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||
setShowSuggestions: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
resetCompletionState: () => void;
|
||||
navigateUp: () => void;
|
||||
navigateDown: () => void;
|
||||
handleAutocomplete: (indexToUse: number) => void;
|
||||
}
|
||||
|
||||
export function useCompletion(
|
||||
query: string,
|
||||
buffer: TextBuffer,
|
||||
cwd: string,
|
||||
isActive: boolean,
|
||||
slashCommands: SlashCommand[],
|
||||
slashCommands: readonly SlashCommand[],
|
||||
commandContext: CommandContext,
|
||||
config?: Config,
|
||||
): UseCompletionReturn {
|
||||
@@ -50,6 +55,7 @@ export function useCompletion(
|
||||
const [showSuggestions, setShowSuggestions] = useState<boolean>(false);
|
||||
const [isLoadingSuggestions, setIsLoadingSuggestions] =
|
||||
useState<boolean>(false);
|
||||
const [isPerfectMatch, setIsPerfectMatch] = useState<boolean>(false);
|
||||
|
||||
const resetCompletionState = useCallback(() => {
|
||||
setSuggestions([]);
|
||||
@@ -57,6 +63,7 @@ export function useCompletion(
|
||||
setVisibleStartIndex(0);
|
||||
setShowSuggestions(false);
|
||||
setIsLoadingSuggestions(false);
|
||||
setIsPerfectMatch(false);
|
||||
}, []);
|
||||
|
||||
const navigateUp = useCallback(() => {
|
||||
@@ -118,15 +125,50 @@ export function useCompletion(
|
||||
});
|
||||
}, [suggestions.length]);
|
||||
|
||||
// Check if cursor is after @ or / without unescaped spaces
|
||||
const isActive = useMemo(() => {
|
||||
if (isSlashCommand(buffer.text.trim())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For other completions like '@', we search backwards from the cursor.
|
||||
const [row, col] = buffer.cursor;
|
||||
const currentLine = buffer.lines[row] || '';
|
||||
const codePoints = toCodePoints(currentLine);
|
||||
|
||||
for (let i = col - 1; i >= 0; i--) {
|
||||
const char = codePoints[i];
|
||||
|
||||
if (char === ' ') {
|
||||
// Check for unescaped spaces.
|
||||
let backslashCount = 0;
|
||||
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
|
||||
backslashCount++;
|
||||
}
|
||||
if (backslashCount % 2 === 0) {
|
||||
return false; // Inactive on unescaped space.
|
||||
}
|
||||
} else if (char === '@') {
|
||||
// Active if we find an '@' before any unescaped space.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [buffer.text, buffer.cursor, buffer.lines]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
resetCompletionState();
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedQuery = query.trimStart();
|
||||
const trimmedQuery = buffer.text.trimStart();
|
||||
|
||||
if (trimmedQuery.startsWith('/')) {
|
||||
// Always reset perfect match at the beginning of processing.
|
||||
setIsPerfectMatch(false);
|
||||
|
||||
const fullPath = trimmedQuery.substring(1);
|
||||
const hasTrailingSpace = trimmedQuery.endsWith(' ');
|
||||
|
||||
@@ -144,7 +186,7 @@ export function useCompletion(
|
||||
}
|
||||
|
||||
// Traverse the Command Tree using the tentative completed path
|
||||
let currentLevel: SlashCommand[] | undefined = slashCommands;
|
||||
let currentLevel: readonly SlashCommand[] | undefined = slashCommands;
|
||||
let leafCommand: SlashCommand | null = null;
|
||||
|
||||
for (const part of commandPathParts) {
|
||||
@@ -154,11 +196,13 @@ export function useCompletion(
|
||||
break;
|
||||
}
|
||||
const found: SlashCommand | undefined = currentLevel.find(
|
||||
(cmd) => cmd.name === part || cmd.altName === part,
|
||||
(cmd) => cmd.name === part || cmd.altNames?.includes(part),
|
||||
);
|
||||
if (found) {
|
||||
leafCommand = found;
|
||||
currentLevel = found.subCommands;
|
||||
currentLevel = found.subCommands as
|
||||
| readonly SlashCommand[]
|
||||
| undefined;
|
||||
} else {
|
||||
leafCommand = null;
|
||||
currentLevel = [];
|
||||
@@ -170,7 +214,7 @@ export function useCompletion(
|
||||
if (!hasTrailingSpace && currentLevel) {
|
||||
const exactMatchAsParent = currentLevel.find(
|
||||
(cmd) =>
|
||||
(cmd.name === partial || cmd.altName === partial) &&
|
||||
(cmd.name === partial || cmd.altNames?.includes(partial)) &&
|
||||
cmd.subCommands,
|
||||
);
|
||||
|
||||
@@ -183,6 +227,24 @@ export function useCompletion(
|
||||
}
|
||||
}
|
||||
|
||||
// Check for perfect, executable match
|
||||
if (!hasTrailingSpace) {
|
||||
if (leafCommand && partial === '' && leafCommand.action) {
|
||||
// Case: /command<enter> - command has action, no sub-commands were suggested
|
||||
setIsPerfectMatch(true);
|
||||
} else if (currentLevel) {
|
||||
// Case: /command subcommand<enter>
|
||||
const perfectMatch = currentLevel.find(
|
||||
(cmd) =>
|
||||
(cmd.name === partial || cmd.altNames?.includes(partial)) &&
|
||||
cmd.action,
|
||||
);
|
||||
if (perfectMatch) {
|
||||
setIsPerfectMatch(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const depth = commandPathParts.length;
|
||||
|
||||
// Provide Suggestions based on the now-corrected context
|
||||
@@ -214,16 +276,17 @@ export function useCompletion(
|
||||
let potentialSuggestions = commandsToSearch.filter(
|
||||
(cmd) =>
|
||||
cmd.description &&
|
||||
(cmd.name.startsWith(partial) || cmd.altName?.startsWith(partial)),
|
||||
(cmd.name.startsWith(partial) ||
|
||||
cmd.altNames?.some((alt) => alt.startsWith(partial))),
|
||||
);
|
||||
|
||||
// If a user's input is an exact match and it is a leaf command,
|
||||
// enter should submit immediately.
|
||||
if (potentialSuggestions.length > 0 && !hasTrailingSpace) {
|
||||
const perfectMatch = potentialSuggestions.find(
|
||||
(s) => s.name === partial,
|
||||
(s) => s.name === partial || s.altNames?.includes(partial),
|
||||
);
|
||||
if (perfectMatch && !perfectMatch.subCommands) {
|
||||
if (perfectMatch && perfectMatch.action) {
|
||||
potentialSuggestions = [];
|
||||
}
|
||||
}
|
||||
@@ -247,13 +310,13 @@ export function useCompletion(
|
||||
}
|
||||
|
||||
// Handle At Command Completion
|
||||
const atIndex = query.lastIndexOf('@');
|
||||
const atIndex = buffer.text.lastIndexOf('@');
|
||||
if (atIndex === -1) {
|
||||
resetCompletionState();
|
||||
return;
|
||||
}
|
||||
|
||||
const partialPath = query.substring(atIndex + 1);
|
||||
const partialPath = buffer.text.substring(atIndex + 1);
|
||||
const lastSlashIndex = partialPath.lastIndexOf('/');
|
||||
const baseDirRelative =
|
||||
lastSlashIndex === -1
|
||||
@@ -364,13 +427,10 @@ export function useCompletion(
|
||||
});
|
||||
|
||||
const suggestions: Suggestion[] = files
|
||||
.map((file: string) => {
|
||||
const relativePath = path.relative(cwd, file);
|
||||
return {
|
||||
label: relativePath,
|
||||
value: escapePath(relativePath),
|
||||
};
|
||||
})
|
||||
.map((file: string) => ({
|
||||
label: file,
|
||||
value: escapePath(file),
|
||||
}))
|
||||
.filter((s) => {
|
||||
if (fileDiscoveryService) {
|
||||
return !fileDiscoveryService.shouldIgnoreFile(
|
||||
@@ -392,10 +452,8 @@ export function useCompletion(
|
||||
const fileDiscoveryService = config ? config.getFileService() : null;
|
||||
const enableRecursiveSearch =
|
||||
config?.getEnableRecursiveFileSearch() ?? true;
|
||||
const filterOptions = {
|
||||
respectGitIgnore: config?.getFileFilteringRespectGitIgnore() ?? true,
|
||||
respectGeminiIgnore: true,
|
||||
};
|
||||
const filterOptions =
|
||||
config?.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS;
|
||||
|
||||
try {
|
||||
// If there's no slash, or it's the root, do a recursive search from cwd
|
||||
@@ -414,7 +472,7 @@ export function useCompletion(
|
||||
fetchedSuggestions = await findFilesRecursively(
|
||||
cwd,
|
||||
prefix,
|
||||
fileDiscoveryService,
|
||||
null,
|
||||
filterOptions,
|
||||
);
|
||||
}
|
||||
@@ -457,6 +515,13 @@ export function useCompletion(
|
||||
});
|
||||
}
|
||||
|
||||
// Like glob, we always return forwardslashes, even in windows.
|
||||
fetchedSuggestions = fetchedSuggestions.map((suggestion) => ({
|
||||
...suggestion,
|
||||
label: suggestion.label.replace(/\\/g, '/'),
|
||||
value: suggestion.value.replace(/\\/g, '/'),
|
||||
}));
|
||||
|
||||
// Sort by depth, then directories first, then alphabetically
|
||||
fetchedSuggestions.sort((a, b) => {
|
||||
const depthA = (a.label.match(/\//g) || []).length;
|
||||
@@ -519,7 +584,7 @@ export function useCompletion(
|
||||
clearTimeout(debounceTimeout);
|
||||
};
|
||||
}, [
|
||||
query,
|
||||
buffer.text,
|
||||
cwd,
|
||||
isActive,
|
||||
resetCompletionState,
|
||||
@@ -528,16 +593,96 @@ export function useCompletion(
|
||||
config,
|
||||
]);
|
||||
|
||||
const handleAutocomplete = useCallback(
|
||||
(indexToUse: number) => {
|
||||
if (indexToUse < 0 || indexToUse >= suggestions.length) {
|
||||
return;
|
||||
}
|
||||
const query = buffer.text;
|
||||
const suggestion = suggestions[indexToUse].value;
|
||||
|
||||
if (query.trimStart().startsWith('/')) {
|
||||
const hasTrailingSpace = query.endsWith(' ');
|
||||
const parts = query
|
||||
.trimStart()
|
||||
.substring(1)
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
|
||||
let isParentPath = false;
|
||||
// If there's no trailing space, we need to check if the current query
|
||||
// is already a complete path to a parent command.
|
||||
if (!hasTrailingSpace) {
|
||||
let currentLevel: readonly SlashCommand[] | undefined = slashCommands;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const found: SlashCommand | undefined = currentLevel?.find(
|
||||
(cmd) => cmd.name === part || cmd.altNames?.includes(part),
|
||||
);
|
||||
|
||||
if (found) {
|
||||
if (i === parts.length - 1 && found.subCommands) {
|
||||
isParentPath = true;
|
||||
}
|
||||
currentLevel = found.subCommands as
|
||||
| readonly SlashCommand[]
|
||||
| undefined;
|
||||
} 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.
|
||||
// - If the last part is a complete argument, the whole command is the base.
|
||||
// - Otherwise, the base is everything EXCEPT the last partial part.
|
||||
const lastPart = parts.length > 0 ? parts[parts.length - 1] : '';
|
||||
const isLastPartACompleteArg =
|
||||
lastPart.startsWith('--') && lastPart.includes('=');
|
||||
|
||||
const basePath =
|
||||
hasTrailingSpace || isParentPath || isLastPartACompleteArg
|
||||
? parts
|
||||
: parts.slice(0, -1);
|
||||
const newValue = `/${[...basePath, suggestion].join(' ')} `;
|
||||
|
||||
buffer.setText(newValue);
|
||||
} else {
|
||||
const atIndex = query.lastIndexOf('@');
|
||||
if (atIndex === -1) return;
|
||||
const pathPart = query.substring(atIndex + 1);
|
||||
const lastSlashIndexInPath = pathPart.lastIndexOf('/');
|
||||
let autoCompleteStartIndex = atIndex + 1;
|
||||
if (lastSlashIndexInPath !== -1) {
|
||||
autoCompleteStartIndex += lastSlashIndexInPath + 1;
|
||||
}
|
||||
buffer.replaceRangeByOffset(
|
||||
autoCompleteStartIndex,
|
||||
buffer.text.length,
|
||||
suggestion,
|
||||
);
|
||||
}
|
||||
resetCompletionState();
|
||||
},
|
||||
[resetCompletionState, buffer, suggestions, slashCommands],
|
||||
);
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
activeSuggestionIndex,
|
||||
visibleStartIndex,
|
||||
showSuggestions,
|
||||
isLoadingSuggestions,
|
||||
isPerfectMatch,
|
||||
setActiveSuggestionIndex,
|
||||
setShowSuggestions,
|
||||
resetCompletionState,
|
||||
navigateUp,
|
||||
navigateDown,
|
||||
handleAutocomplete,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,127 +5,105 @@
|
||||
*/
|
||||
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useConsoleMessages } from './useConsoleMessages.js';
|
||||
import { ConsoleMessageItem } from '../types.js';
|
||||
|
||||
// Mock setTimeout and clearTimeout
|
||||
vi.useFakeTimers();
|
||||
import { vi } from 'vitest';
|
||||
import { useConsoleMessages } from './useConsoleMessages';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
describe('useConsoleMessages', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const useTestableConsoleMessages = () => {
|
||||
const { handleNewMessage, ...rest } = useConsoleMessages();
|
||||
const log = useCallback(
|
||||
(content: string) => handleNewMessage({ type: 'log', content, count: 1 }),
|
||||
[handleNewMessage],
|
||||
);
|
||||
const error = useCallback(
|
||||
(content: string) =>
|
||||
handleNewMessage({ type: 'error', content, count: 1 }),
|
||||
[handleNewMessage],
|
||||
);
|
||||
return {
|
||||
...rest,
|
||||
log,
|
||||
error,
|
||||
clearConsoleMessages: rest.clearConsoleMessages,
|
||||
};
|
||||
};
|
||||
|
||||
it('should initialize with an empty array of console messages', () => {
|
||||
const { result } = renderHook(() => useConsoleMessages());
|
||||
const { result } = renderHook(() => useTestableConsoleMessages());
|
||||
expect(result.current.consoleMessages).toEqual([]);
|
||||
});
|
||||
|
||||
it('should add a new message', () => {
|
||||
const { result } = renderHook(() => useConsoleMessages());
|
||||
const message: ConsoleMessageItem = {
|
||||
type: 'log',
|
||||
content: 'Test message',
|
||||
count: 1,
|
||||
};
|
||||
it('should add a new message when log is called', async () => {
|
||||
const { result } = renderHook(() => useTestableConsoleMessages());
|
||||
|
||||
act(() => {
|
||||
result.current.handleNewMessage(message);
|
||||
result.current.log('Test message');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.runAllTimers(); // Process the queue
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toEqual([{ ...message, count: 1 }]);
|
||||
});
|
||||
|
||||
it('should consolidate identical consecutive messages', () => {
|
||||
const { result } = renderHook(() => useConsoleMessages());
|
||||
const message: ConsoleMessageItem = {
|
||||
type: 'log',
|
||||
content: 'Test message',
|
||||
count: 1,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.handleNewMessage(message);
|
||||
result.current.handleNewMessage(message);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toEqual([{ ...message, count: 2 }]);
|
||||
});
|
||||
|
||||
it('should not consolidate different messages', () => {
|
||||
const { result } = renderHook(() => useConsoleMessages());
|
||||
const message1: ConsoleMessageItem = {
|
||||
type: 'log',
|
||||
content: 'Test message 1',
|
||||
count: 1,
|
||||
};
|
||||
const message2: ConsoleMessageItem = {
|
||||
type: 'error',
|
||||
content: 'Test message 2',
|
||||
count: 1,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.handleNewMessage(message1);
|
||||
result.current.handleNewMessage(message2);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toEqual([
|
||||
{ ...message1, count: 1 },
|
||||
{ ...message2, count: 1 },
|
||||
{ type: 'log', content: 'Test message', count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not consolidate messages if type is different', () => {
|
||||
const { result } = renderHook(() => useConsoleMessages());
|
||||
const message1: ConsoleMessageItem = {
|
||||
type: 'log',
|
||||
content: 'Test message',
|
||||
count: 1,
|
||||
};
|
||||
const message2: ConsoleMessageItem = {
|
||||
type: 'error',
|
||||
content: 'Test message',
|
||||
count: 1,
|
||||
};
|
||||
it('should batch and count identical consecutive messages', async () => {
|
||||
const { result } = renderHook(() => useTestableConsoleMessages());
|
||||
|
||||
act(() => {
|
||||
result.current.handleNewMessage(message1);
|
||||
result.current.handleNewMessage(message2);
|
||||
result.current.log('Test message');
|
||||
result.current.log('Test message');
|
||||
result.current.log('Test message');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toEqual([
|
||||
{ ...message1, count: 1 },
|
||||
{ ...message2, count: 1 },
|
||||
{ type: 'log', content: 'Test message', count: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should clear console messages', () => {
|
||||
const { result } = renderHook(() => useConsoleMessages());
|
||||
const message: ConsoleMessageItem = {
|
||||
type: 'log',
|
||||
content: 'Test message',
|
||||
count: 1,
|
||||
};
|
||||
it('should not batch different messages', async () => {
|
||||
const { result } = renderHook(() => useTestableConsoleMessages());
|
||||
|
||||
act(() => {
|
||||
result.current.handleNewMessage(message);
|
||||
result.current.log('First message');
|
||||
result.current.error('Second message');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toEqual([
|
||||
{ type: 'log', content: 'First message', count: 1 },
|
||||
{ type: 'error', content: 'Second message', count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should clear all messages when clearConsoleMessages is called', async () => {
|
||||
const { result } = renderHook(() => useTestableConsoleMessages());
|
||||
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
result.current.log('A message');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toHaveLength(1);
|
||||
@@ -134,79 +112,36 @@ describe('useConsoleMessages', () => {
|
||||
result.current.clearConsoleMessages();
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toEqual([]);
|
||||
expect(result.current.consoleMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should clear pending timeout on clearConsoleMessages', () => {
|
||||
const { result } = renderHook(() => useConsoleMessages());
|
||||
const message: ConsoleMessageItem = {
|
||||
type: 'log',
|
||||
content: 'Test message',
|
||||
count: 1,
|
||||
};
|
||||
it('should clear the pending timeout when clearConsoleMessages is called', () => {
|
||||
const { result } = renderHook(() => useTestableConsoleMessages());
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||
|
||||
act(() => {
|
||||
result.current.handleNewMessage(message); // This schedules a timeout
|
||||
result.current.log('A message');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.clearConsoleMessages();
|
||||
});
|
||||
|
||||
// Ensure the queue is empty and no more messages are processed
|
||||
act(() => {
|
||||
vi.runAllTimers(); // If timeout wasn't cleared, this would process the queue
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toEqual([]);
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
clearTimeoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should clear message queue on clearConsoleMessages', () => {
|
||||
const { result } = renderHook(() => useConsoleMessages());
|
||||
const message: ConsoleMessageItem = {
|
||||
type: 'log',
|
||||
content: 'Test message',
|
||||
count: 1,
|
||||
};
|
||||
it('should clean up the timeout on unmount', () => {
|
||||
const { result, unmount } = renderHook(() => useTestableConsoleMessages());
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||
|
||||
act(() => {
|
||||
// Add a message but don't process the queue yet
|
||||
result.current.handleNewMessage(message);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.clearConsoleMessages();
|
||||
});
|
||||
|
||||
// Process any pending timeouts (should be none related to message queue)
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
// The consoleMessages should be empty because the queue was cleared before processing
|
||||
expect(result.current.consoleMessages).toEqual([]);
|
||||
});
|
||||
|
||||
it('should cleanup timeout on unmount', () => {
|
||||
const { result, unmount } = renderHook(() => useConsoleMessages());
|
||||
const message: ConsoleMessageItem = {
|
||||
type: 'log',
|
||||
content: 'Test message',
|
||||
count: 1,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.handleNewMessage(message);
|
||||
result.current.log('A message');
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
// This is a bit indirect. We check that clearTimeout was called.
|
||||
// If clearTimeout was not called, and we run timers, an error might occur
|
||||
// or the state might change, which it shouldn't after unmount.
|
||||
// Vitest's vi.clearAllTimers() or specific checks for clearTimeout calls
|
||||
// would be more direct if available and easy to set up here.
|
||||
// For now, we rely on the useEffect cleanup pattern.
|
||||
expect(vi.getTimerCount()).toBe(0); // Check if all timers are cleared
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
clearTimeoutSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,13 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useReducer,
|
||||
useRef,
|
||||
useTransition,
|
||||
} from 'react';
|
||||
import { ConsoleMessageItem } from '../types.js';
|
||||
|
||||
export interface UseConsoleMessagesReturn {
|
||||
@@ -13,75 +19,90 @@ export interface UseConsoleMessagesReturn {
|
||||
clearConsoleMessages: () => void;
|
||||
}
|
||||
|
||||
export function useConsoleMessages(): UseConsoleMessagesReturn {
|
||||
const [consoleMessages, setConsoleMessages] = useState<ConsoleMessageItem[]>(
|
||||
[],
|
||||
);
|
||||
const messageQueueRef = useRef<ConsoleMessageItem[]>([]);
|
||||
const messageQueueTimeoutRef = useRef<number | null>(null);
|
||||
type Action =
|
||||
| { type: 'ADD_MESSAGES'; payload: ConsoleMessageItem[] }
|
||||
| { type: 'CLEAR' };
|
||||
|
||||
const processMessageQueue = useCallback(() => {
|
||||
if (messageQueueRef.current.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newMessagesToAdd = messageQueueRef.current;
|
||||
messageQueueRef.current = [];
|
||||
|
||||
setConsoleMessages((prevMessages) => {
|
||||
const newMessages = [...prevMessages];
|
||||
newMessagesToAdd.forEach((queuedMessage) => {
|
||||
function consoleMessagesReducer(
|
||||
state: ConsoleMessageItem[],
|
||||
action: Action,
|
||||
): ConsoleMessageItem[] {
|
||||
switch (action.type) {
|
||||
case 'ADD_MESSAGES': {
|
||||
const newMessages = [...state];
|
||||
for (const queuedMessage of action.payload) {
|
||||
const lastMessage = newMessages[newMessages.length - 1];
|
||||
if (
|
||||
newMessages.length > 0 &&
|
||||
newMessages[newMessages.length - 1].type === queuedMessage.type &&
|
||||
newMessages[newMessages.length - 1].content === queuedMessage.content
|
||||
lastMessage &&
|
||||
lastMessage.type === queuedMessage.type &&
|
||||
lastMessage.content === queuedMessage.content
|
||||
) {
|
||||
newMessages[newMessages.length - 1].count =
|
||||
(newMessages[newMessages.length - 1].count || 1) + 1;
|
||||
// Create a new object for the last message to ensure React detects
|
||||
// the change, preventing mutation of the existing state object.
|
||||
newMessages[newMessages.length - 1] = {
|
||||
...lastMessage,
|
||||
count: lastMessage.count + 1,
|
||||
};
|
||||
} else {
|
||||
newMessages.push({ ...queuedMessage, count: 1 });
|
||||
}
|
||||
});
|
||||
}
|
||||
return newMessages;
|
||||
});
|
||||
|
||||
messageQueueTimeoutRef.current = null; // Allow next scheduling
|
||||
}, []);
|
||||
|
||||
const scheduleQueueProcessing = useCallback(() => {
|
||||
if (messageQueueTimeoutRef.current === null) {
|
||||
messageQueueTimeoutRef.current = setTimeout(
|
||||
processMessageQueue,
|
||||
0,
|
||||
) as unknown as number;
|
||||
}
|
||||
}, [processMessageQueue]);
|
||||
case 'CLEAR':
|
||||
return [];
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useConsoleMessages(): UseConsoleMessagesReturn {
|
||||
const [consoleMessages, dispatch] = useReducer(consoleMessagesReducer, []);
|
||||
const messageQueueRef = useRef<ConsoleMessageItem[]>([]);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const processQueue = useCallback(() => {
|
||||
if (messageQueueRef.current.length > 0) {
|
||||
const messagesToProcess = messageQueueRef.current;
|
||||
messageQueueRef.current = [];
|
||||
startTransition(() => {
|
||||
dispatch({ type: 'ADD_MESSAGES', payload: messagesToProcess });
|
||||
});
|
||||
}
|
||||
timeoutRef.current = null;
|
||||
}, []);
|
||||
|
||||
const handleNewMessage = useCallback(
|
||||
(message: ConsoleMessageItem) => {
|
||||
messageQueueRef.current.push(message);
|
||||
scheduleQueueProcessing();
|
||||
if (!timeoutRef.current) {
|
||||
// Batch updates using a timeout. 16ms is a reasonable delay to batch
|
||||
// rapid-fire messages without noticeable lag.
|
||||
timeoutRef.current = setTimeout(processQueue, 16);
|
||||
}
|
||||
},
|
||||
[scheduleQueueProcessing],
|
||||
[processQueue],
|
||||
);
|
||||
|
||||
const clearConsoleMessages = useCallback(() => {
|
||||
setConsoleMessages([]);
|
||||
if (messageQueueTimeoutRef.current !== null) {
|
||||
clearTimeout(messageQueueTimeoutRef.current);
|
||||
messageQueueTimeoutRef.current = null;
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
messageQueueRef.current = [];
|
||||
startTransition(() => {
|
||||
dispatch({ type: 'CLEAR' });
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(
|
||||
() =>
|
||||
// Cleanup on unmount
|
||||
() => {
|
||||
if (messageQueueTimeoutRef.current !== null) {
|
||||
clearTimeout(messageQueueTimeoutRef.current);
|
||||
}
|
||||
},
|
||||
() => () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
119
packages/cli/src/ui/hooks/useFocus.test.ts
Normal file
119
packages/cli/src/ui/hooks/useFocus.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { EventEmitter } from 'events';
|
||||
import { useFocus } from './useFocus.js';
|
||||
import { vi } from 'vitest';
|
||||
import { useStdin, useStdout } from 'ink';
|
||||
|
||||
// Mock the ink hooks
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('ink')>();
|
||||
return {
|
||||
...original,
|
||||
useStdin: vi.fn(),
|
||||
useStdout: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedUseStdin = vi.mocked(useStdin);
|
||||
const mockedUseStdout = vi.mocked(useStdout);
|
||||
|
||||
describe('useFocus', () => {
|
||||
let stdin: EventEmitter;
|
||||
let stdout: { write: vi.Func };
|
||||
|
||||
beforeEach(() => {
|
||||
stdin = new EventEmitter();
|
||||
stdout = { write: vi.fn() };
|
||||
mockedUseStdin.mockReturnValue({ stdin } as ReturnType<typeof useStdin>);
|
||||
mockedUseStdout.mockReturnValue({ stdout } as unknown as ReturnType<
|
||||
typeof useStdout
|
||||
>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize with focus and enable focus reporting', () => {
|
||||
const { result } = renderHook(() => useFocus());
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
expect(stdout.write).toHaveBeenCalledWith('\x1b[?1004h');
|
||||
});
|
||||
|
||||
it('should set isFocused to false when a focus-out event is received', () => {
|
||||
const { result } = renderHook(() => useFocus());
|
||||
|
||||
// Initial state is focused
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
// Simulate focus-out event
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from('\x1b[O'));
|
||||
});
|
||||
|
||||
// State should now be unfocused
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should set isFocused to true when a focus-in event is received', () => {
|
||||
const { result } = renderHook(() => useFocus());
|
||||
|
||||
// Simulate focus-out to set initial state to false
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from('\x1b[O'));
|
||||
});
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
// Simulate focus-in event
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from('\x1b[I'));
|
||||
});
|
||||
|
||||
// State should now be focused
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should clean up and disable focus reporting on unmount', () => {
|
||||
const { unmount } = renderHook(() => useFocus());
|
||||
|
||||
// Ensure listener was attached
|
||||
expect(stdin.listenerCount('data')).toBe(1);
|
||||
|
||||
unmount();
|
||||
|
||||
// Assert that the cleanup function was called
|
||||
expect(stdout.write).toHaveBeenCalledWith('\x1b[?1004l');
|
||||
expect(stdin.listenerCount('data')).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle multiple focus events correctly', () => {
|
||||
const { result } = renderHook(() => useFocus());
|
||||
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from('\x1b[O'));
|
||||
});
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from('\x1b[O'));
|
||||
});
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from('\x1b[I'));
|
||||
});
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
stdin.emit('data', Buffer.from('\x1b[I'));
|
||||
});
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
48
packages/cli/src/ui/hooks/useFocus.ts
Normal file
48
packages/cli/src/ui/hooks/useFocus.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useStdin, useStdout } from 'ink';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// ANSI escape codes to enable/disable terminal focus reporting
|
||||
const ENABLE_FOCUS_REPORTING = '\x1b[?1004h';
|
||||
const DISABLE_FOCUS_REPORTING = '\x1b[?1004l';
|
||||
|
||||
// ANSI escape codes for focus events
|
||||
const FOCUS_IN = '\x1b[I';
|
||||
const FOCUS_OUT = '\x1b[O';
|
||||
|
||||
export const useFocus = () => {
|
||||
const { stdin } = useStdin();
|
||||
const { stdout } = useStdout();
|
||||
const [isFocused, setIsFocused] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const handleData = (data: Buffer) => {
|
||||
const sequence = data.toString();
|
||||
const lastFocusIn = sequence.lastIndexOf(FOCUS_IN);
|
||||
const lastFocusOut = sequence.lastIndexOf(FOCUS_OUT);
|
||||
|
||||
if (lastFocusIn > lastFocusOut) {
|
||||
setIsFocused(true);
|
||||
} else if (lastFocusOut > lastFocusIn) {
|
||||
setIsFocused(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Enable focus reporting
|
||||
stdout?.write(ENABLE_FOCUS_REPORTING);
|
||||
stdin?.on('data', handleData);
|
||||
|
||||
return () => {
|
||||
// Disable focus reporting on cleanup
|
||||
stdout?.write(DISABLE_FOCUS_REPORTING);
|
||||
stdin?.removeListener('data', handleData);
|
||||
};
|
||||
}, [stdin, stdout]);
|
||||
|
||||
return isFocused;
|
||||
};
|
||||
@@ -16,7 +16,12 @@ import {
|
||||
TrackedExecutingToolCall,
|
||||
TrackedCancelledToolCall,
|
||||
} from './useReactToolScheduler.js';
|
||||
import { Config, EditorType, AuthType } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
Config,
|
||||
EditorType,
|
||||
AuthType,
|
||||
GeminiEventType as ServerGeminiEventType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { Part, PartListUnion } from '@google/genai';
|
||||
import { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import {
|
||||
@@ -1053,6 +1058,65 @@ describe('useGeminiStream', () => {
|
||||
expect(mockSendMessageStream).not.toHaveBeenCalled(); // No LLM call made
|
||||
});
|
||||
});
|
||||
|
||||
it('should call Gemini with prompt content when slash command returns a `submit_prompt` action', async () => {
|
||||
const customCommandResult: SlashCommandProcessorResult = {
|
||||
type: 'submit_prompt',
|
||||
content: 'This is the actual prompt from the command file.',
|
||||
};
|
||||
mockHandleSlashCommand.mockResolvedValue(customCommandResult);
|
||||
|
||||
const { result, mockSendMessageStream: localMockSendMessageStream } =
|
||||
renderTestHook();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('/my-custom-command');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||
'/my-custom-command',
|
||||
);
|
||||
|
||||
expect(localMockSendMessageStream).not.toHaveBeenCalledWith(
|
||||
'/my-custom-command',
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
expect(localMockSendMessageStream).toHaveBeenCalledWith(
|
||||
'This is the actual prompt from the command file.',
|
||||
expect.any(AbortSignal),
|
||||
expect.any(String),
|
||||
);
|
||||
|
||||
expect(mockScheduleToolCalls).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly handle a submit_prompt action with empty content', async () => {
|
||||
const emptyPromptResult: SlashCommandProcessorResult = {
|
||||
type: 'submit_prompt',
|
||||
content: '',
|
||||
};
|
||||
mockHandleSlashCommand.mockResolvedValue(emptyPromptResult);
|
||||
|
||||
const { result, mockSendMessageStream: localMockSendMessageStream } =
|
||||
renderTestHook();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('/emptycmd');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith('/emptycmd');
|
||||
expect(localMockSendMessageStream).toHaveBeenCalledWith(
|
||||
'',
|
||||
expect.any(AbortSignal),
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Memory Refresh on save_memory', () => {
|
||||
@@ -1178,4 +1242,235 @@ describe('useGeminiStream', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleFinishedEvent', () => {
|
||||
it('should add info message for MAX_TOKENS finish reason', async () => {
|
||||
// Setup mock to return a stream with MAX_TOKENS finish reason
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.Content,
|
||||
value: 'This is a truncated response...',
|
||||
};
|
||||
yield { type: ServerGeminiEventType.Finished, value: 'MAX_TOKENS' };
|
||||
})(),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGeminiStream(
|
||||
new MockedGeminiClientClass(mockConfig),
|
||||
[],
|
||||
mockAddItem,
|
||||
mockSetShowHelp,
|
||||
mockConfig,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
false,
|
||||
() => 'vscode' as EditorType,
|
||||
() => {},
|
||||
() => Promise.resolve(),
|
||||
false,
|
||||
() => {},
|
||||
),
|
||||
);
|
||||
|
||||
// Submit a query
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('Generate long text');
|
||||
});
|
||||
|
||||
// Check that the info message was added
|
||||
await waitFor(() => {
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'info',
|
||||
text: '⚠️ Response truncated due to token limits.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not add message for STOP finish reason', async () => {
|
||||
// Setup mock to return a stream with STOP finish reason
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.Content,
|
||||
value: 'Complete response',
|
||||
};
|
||||
yield { type: ServerGeminiEventType.Finished, value: 'STOP' };
|
||||
})(),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGeminiStream(
|
||||
new MockedGeminiClientClass(mockConfig),
|
||||
[],
|
||||
mockAddItem,
|
||||
mockSetShowHelp,
|
||||
mockConfig,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
false,
|
||||
() => 'vscode' as EditorType,
|
||||
() => {},
|
||||
() => Promise.resolve(),
|
||||
false,
|
||||
() => {},
|
||||
),
|
||||
);
|
||||
|
||||
// Submit a query
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('Test normal completion');
|
||||
});
|
||||
|
||||
// Wait a bit to ensure no message is added
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Check that no info message was added for STOP
|
||||
const infoMessages = mockAddItem.mock.calls.filter(
|
||||
(call) => call[0].type === 'info',
|
||||
);
|
||||
expect(infoMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not add message for FINISH_REASON_UNSPECIFIED', async () => {
|
||||
// Setup mock to return a stream with FINISH_REASON_UNSPECIFIED
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.Content,
|
||||
value: 'Response with unspecified finish',
|
||||
};
|
||||
yield {
|
||||
type: ServerGeminiEventType.Finished,
|
||||
value: 'FINISH_REASON_UNSPECIFIED',
|
||||
};
|
||||
})(),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGeminiStream(
|
||||
new MockedGeminiClientClass(mockConfig),
|
||||
[],
|
||||
mockAddItem,
|
||||
mockSetShowHelp,
|
||||
mockConfig,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
false,
|
||||
() => 'vscode' as EditorType,
|
||||
() => {},
|
||||
() => Promise.resolve(),
|
||||
false,
|
||||
() => {},
|
||||
),
|
||||
);
|
||||
|
||||
// Submit a query
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('Test unspecified finish');
|
||||
});
|
||||
|
||||
// Wait a bit to ensure no message is added
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Check that no info message was added
|
||||
const infoMessages = mockAddItem.mock.calls.filter(
|
||||
(call) => call[0].type === 'info',
|
||||
);
|
||||
expect(infoMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should add appropriate messages for other finish reasons', async () => {
|
||||
const testCases = [
|
||||
{
|
||||
reason: 'SAFETY',
|
||||
message: '⚠️ Response stopped due to safety reasons.',
|
||||
},
|
||||
{
|
||||
reason: 'RECITATION',
|
||||
message: '⚠️ Response stopped due to recitation policy.',
|
||||
},
|
||||
{
|
||||
reason: 'LANGUAGE',
|
||||
message: '⚠️ Response stopped due to unsupported language.',
|
||||
},
|
||||
{
|
||||
reason: 'BLOCKLIST',
|
||||
message: '⚠️ Response stopped due to forbidden terms.',
|
||||
},
|
||||
{
|
||||
reason: 'PROHIBITED_CONTENT',
|
||||
message: '⚠️ Response stopped due to prohibited content.',
|
||||
},
|
||||
{
|
||||
reason: 'SPII',
|
||||
message:
|
||||
'⚠️ Response stopped due to sensitive personally identifiable information.',
|
||||
},
|
||||
{ reason: 'OTHER', message: '⚠️ Response stopped for other reasons.' },
|
||||
{
|
||||
reason: 'MALFORMED_FUNCTION_CALL',
|
||||
message: '⚠️ Response stopped due to malformed function call.',
|
||||
},
|
||||
{
|
||||
reason: 'IMAGE_SAFETY',
|
||||
message: '⚠️ Response stopped due to image safety violations.',
|
||||
},
|
||||
{
|
||||
reason: 'UNEXPECTED_TOOL_CALL',
|
||||
message: '⚠️ Response stopped due to unexpected tool call.',
|
||||
},
|
||||
];
|
||||
|
||||
for (const { reason, message } of testCases) {
|
||||
// Reset mocks for each test case
|
||||
mockAddItem.mockClear();
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.Content,
|
||||
value: `Response for ${reason}`,
|
||||
};
|
||||
yield { type: ServerGeminiEventType.Finished, value: reason };
|
||||
})(),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGeminiStream(
|
||||
new MockedGeminiClientClass(mockConfig),
|
||||
[],
|
||||
mockAddItem,
|
||||
mockSetShowHelp,
|
||||
mockConfig,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
false,
|
||||
() => 'vscode' as EditorType,
|
||||
() => {},
|
||||
() => Promise.resolve(),
|
||||
false,
|
||||
() => {},
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery(`Test ${reason}`);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'info',
|
||||
text: message,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ServerGeminiContentEvent as ContentEvent,
|
||||
ServerGeminiErrorEvent as ErrorEvent,
|
||||
ServerGeminiChatCompressedEvent,
|
||||
ServerGeminiFinishedEvent,
|
||||
getErrorMessage,
|
||||
isNodeError,
|
||||
MessageSenderType,
|
||||
@@ -26,7 +27,7 @@ import {
|
||||
UserPromptEvent,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { type Part, type PartListUnion } from '@google/genai';
|
||||
import { type Part, type PartListUnion, FinishReason } from '@google/genai';
|
||||
import {
|
||||
StreamingState,
|
||||
HistoryItem,
|
||||
@@ -239,19 +240,37 @@ export const useGeminiStream = (
|
||||
const slashCommandResult = await handleSlashCommand(trimmedQuery);
|
||||
|
||||
if (slashCommandResult) {
|
||||
if (slashCommandResult.type === 'schedule_tool') {
|
||||
const { toolName, toolArgs } = slashCommandResult;
|
||||
const toolCallRequest: ToolCallRequestInfo = {
|
||||
callId: `${toolName}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
name: toolName,
|
||||
args: toolArgs,
|
||||
isClientInitiated: true,
|
||||
prompt_id,
|
||||
};
|
||||
scheduleToolCalls([toolCallRequest], abortSignal);
|
||||
}
|
||||
switch (slashCommandResult.type) {
|
||||
case 'schedule_tool': {
|
||||
const { toolName, toolArgs } = slashCommandResult;
|
||||
const toolCallRequest: ToolCallRequestInfo = {
|
||||
callId: `${toolName}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
name: toolName,
|
||||
args: toolArgs,
|
||||
isClientInitiated: true,
|
||||
prompt_id,
|
||||
};
|
||||
scheduleToolCalls([toolCallRequest], abortSignal);
|
||||
return { queryToSend: null, shouldProceed: false };
|
||||
}
|
||||
case 'submit_prompt': {
|
||||
localQueryToSendToGemini = slashCommandResult.content;
|
||||
|
||||
return { queryToSend: null, shouldProceed: false };
|
||||
return {
|
||||
queryToSend: localQueryToSendToGemini,
|
||||
shouldProceed: true,
|
||||
};
|
||||
}
|
||||
case 'handled': {
|
||||
return { queryToSend: null, shouldProceed: false };
|
||||
}
|
||||
default: {
|
||||
const unreachable: never = slashCommandResult;
|
||||
throw new Error(
|
||||
`Unhandled slash command result type: ${unreachable}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shellModeActive && handleShellCommand(trimmedQuery, abortSignal)) {
|
||||
@@ -422,6 +441,46 @@ export const useGeminiStream = (
|
||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem, config],
|
||||
);
|
||||
|
||||
const handleFinishedEvent = useCallback(
|
||||
(event: ServerGeminiFinishedEvent, userMessageTimestamp: number) => {
|
||||
const finishReason = event.value;
|
||||
|
||||
const finishReasonMessages: Record<FinishReason, string | undefined> = {
|
||||
[FinishReason.FINISH_REASON_UNSPECIFIED]: undefined,
|
||||
[FinishReason.STOP]: undefined,
|
||||
[FinishReason.MAX_TOKENS]: 'Response truncated due to token limits.',
|
||||
[FinishReason.SAFETY]: 'Response stopped due to safety reasons.',
|
||||
[FinishReason.RECITATION]: 'Response stopped due to recitation policy.',
|
||||
[FinishReason.LANGUAGE]:
|
||||
'Response stopped due to unsupported language.',
|
||||
[FinishReason.BLOCKLIST]: 'Response stopped due to forbidden terms.',
|
||||
[FinishReason.PROHIBITED_CONTENT]:
|
||||
'Response stopped due to prohibited content.',
|
||||
[FinishReason.SPII]:
|
||||
'Response stopped due to sensitive personally identifiable information.',
|
||||
[FinishReason.OTHER]: 'Response stopped for other reasons.',
|
||||
[FinishReason.MALFORMED_FUNCTION_CALL]:
|
||||
'Response stopped due to malformed function call.',
|
||||
[FinishReason.IMAGE_SAFETY]:
|
||||
'Response stopped due to image safety violations.',
|
||||
[FinishReason.UNEXPECTED_TOOL_CALL]:
|
||||
'Response stopped due to unexpected tool call.',
|
||||
};
|
||||
|
||||
const message = finishReasonMessages[finishReason];
|
||||
if (message) {
|
||||
addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: `⚠️ ${message}`,
|
||||
},
|
||||
userMessageTimestamp,
|
||||
);
|
||||
}
|
||||
},
|
||||
[addItem],
|
||||
);
|
||||
|
||||
const handleChatCompressionEvent = useCallback(
|
||||
(eventValue: ServerGeminiChatCompressedEvent['value']) =>
|
||||
addItem(
|
||||
@@ -452,23 +511,6 @@ export const useGeminiStream = (
|
||||
[addItem, config],
|
||||
);
|
||||
|
||||
const handleSessionTokenLimitExceededEvent = useCallback(
|
||||
(value: { currentTokens: number; limit: number; message: string }) =>
|
||||
addItem(
|
||||
{
|
||||
type: 'error',
|
||||
text:
|
||||
`🚫 Session token limit exceeded: ${value.currentTokens.toLocaleString()} tokens > ${value.limit.toLocaleString()} limit.\n\n` +
|
||||
`💡 Solutions:\n` +
|
||||
` • Start a new session: Use /clear command\n` +
|
||||
` • Increase limit: Add "sessionTokenLimit": (e.g., 128000) to your settings.json\n` +
|
||||
` • Compress history: Use /compress command to compress history`,
|
||||
},
|
||||
Date.now(),
|
||||
),
|
||||
[addItem],
|
||||
);
|
||||
|
||||
const handleLoopDetectedEvent = useCallback(() => {
|
||||
addItem(
|
||||
{
|
||||
@@ -518,8 +560,11 @@ export const useGeminiStream = (
|
||||
case ServerGeminiEventType.MaxSessionTurns:
|
||||
handleMaxSessionTurnsEvent();
|
||||
break;
|
||||
case ServerGeminiEventType.SessionTokenLimitExceeded:
|
||||
handleSessionTokenLimitExceededEvent(event.value);
|
||||
case ServerGeminiEventType.Finished:
|
||||
handleFinishedEvent(
|
||||
event as ServerGeminiFinishedEvent,
|
||||
userMessageTimestamp,
|
||||
);
|
||||
break;
|
||||
case ServerGeminiEventType.LoopDetected:
|
||||
// handle later because we want to move pending history to history
|
||||
@@ -544,8 +589,8 @@ export const useGeminiStream = (
|
||||
handleErrorEvent,
|
||||
scheduleToolCalls,
|
||||
handleChatCompressionEvent,
|
||||
handleFinishedEvent,
|
||||
handleMaxSessionTurnsEvent,
|
||||
handleSessionTokenLimitExceededEvent,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ describe('useHistoryManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should not change history if updateHistoryItem is called with a non-existent ID', () => {
|
||||
it('should not change history if updateHistoryItem is called with a nonexistent ID', () => {
|
||||
const { result } = renderHook(() => useHistory());
|
||||
const timestamp = Date.now();
|
||||
const itemData: Omit<HistoryItem, 'id'> = {
|
||||
@@ -107,7 +107,7 @@ describe('useHistoryManager', () => {
|
||||
const originalHistory = [...result.current.history]; // Clone before update attempt
|
||||
|
||||
act(() => {
|
||||
result.current.updateItem(99999, { text: 'Should not apply' }); // Non-existent ID
|
||||
result.current.updateItem(99999, { text: 'Should not apply' }); // Nonexistent ID
|
||||
});
|
||||
|
||||
expect(result.current.history).toEqual(originalHistory);
|
||||
|
||||
@@ -14,7 +14,7 @@ interface UseInputHistoryProps {
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
interface UseInputHistoryReturn {
|
||||
export interface UseInputHistoryReturn {
|
||||
handleSubmit: (value: string) => void;
|
||||
navigateUp: () => boolean;
|
||||
navigateDown: () => boolean;
|
||||
|
||||
@@ -147,12 +147,15 @@ export function useKeypress(
|
||||
|
||||
let rl: readline.Interface;
|
||||
if (usePassthrough) {
|
||||
rl = readline.createInterface({ input: keypressStream });
|
||||
rl = readline.createInterface({
|
||||
input: keypressStream,
|
||||
escapeCodeTimeout: 0,
|
||||
});
|
||||
readline.emitKeypressEvents(keypressStream, rl);
|
||||
keypressStream.on('keypress', handleKeypress);
|
||||
stdin.on('data', handleRawKeypress);
|
||||
} else {
|
||||
rl = readline.createInterface({ input: stdin });
|
||||
rl = readline.createInterface({ input: stdin, escapeCodeTimeout: 0 });
|
||||
readline.emitKeypressEvents(stdin, rl);
|
||||
stdin.on('keypress', handleKeypress);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { GaxiosError } from 'gaxios';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Config,
|
||||
@@ -117,13 +116,18 @@ async function getRemoteDataCollectionOptIn(
|
||||
try {
|
||||
const resp = await server.getCodeAssistGlobalUserSetting();
|
||||
return resp.freeTierDataCollectionOptin;
|
||||
} catch (e) {
|
||||
if (e instanceof GaxiosError) {
|
||||
if (e.response?.status === 404) {
|
||||
} catch (error: unknown) {
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const gaxiosError = error as {
|
||||
response?: {
|
||||
status?: unknown;
|
||||
};
|
||||
};
|
||||
if (gaxiosError.response?.status === 404) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('useShellHistory', () => {
|
||||
expect(command).toBe('cmd2');
|
||||
});
|
||||
|
||||
it('should handle a non-existent history file gracefully', async () => {
|
||||
it('should handle a nonexistent history file gracefully', async () => {
|
||||
const error = new Error('File not found') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
mockedFs.readFile.mockRejectedValue(error);
|
||||
|
||||
@@ -12,6 +12,13 @@ import { isNodeError, getProjectTempDir } from '@qwen-code/qwen-code-core';
|
||||
const HISTORY_FILE = 'shell_history';
|
||||
const MAX_HISTORY_LENGTH = 100;
|
||||
|
||||
export interface UseShellHistoryReturn {
|
||||
addCommandToHistory: (command: string) => void;
|
||||
getPreviousCommand: () => string | null;
|
||||
getNextCommand: () => string | null;
|
||||
resetHistoryPosition: () => void;
|
||||
}
|
||||
|
||||
async function getHistoryFilePath(projectRoot: string): Promise<string> {
|
||||
const historyDir = getProjectTempDir(projectRoot);
|
||||
return path.join(historyDir, HISTORY_FILE);
|
||||
@@ -42,7 +49,7 @@ async function writeHistoryFile(
|
||||
}
|
||||
}
|
||||
|
||||
export function useShellHistory(projectRoot: string) {
|
||||
export function useShellHistory(projectRoot: string): UseShellHistoryReturn {
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
const [historyFilePath, setHistoryFilePath] = useState<string | null>(null);
|
||||
|
||||
@@ -25,39 +25,18 @@ export const useThemeCommand = (
|
||||
setThemeError: (error: string | null) => void,
|
||||
addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void,
|
||||
): UseThemeCommandReturn => {
|
||||
// Determine the effective theme
|
||||
const effectiveTheme = loadedSettings.merged.theme;
|
||||
const [isThemeDialogOpen, setIsThemeDialogOpen] = useState(false);
|
||||
|
||||
// Initial state: Open dialog if no theme is set in either user or workspace settings
|
||||
const [isThemeDialogOpen, setIsThemeDialogOpen] = useState(
|
||||
effectiveTheme === undefined && !process.env.NO_COLOR,
|
||||
);
|
||||
// TODO: refactor how theme's are accessed to avoid requiring a forced render.
|
||||
const [, setForceRender] = useState(0);
|
||||
|
||||
// Apply initial theme on component mount
|
||||
// Check for invalid theme configuration on startup
|
||||
useEffect(() => {
|
||||
if (effectiveTheme === undefined) {
|
||||
if (process.env.NO_COLOR) {
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Theme configuration unavailable due to NO_COLOR env variable.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
// If no theme is set and NO_COLOR is not set, the dialog is already open.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!themeManager.setActiveTheme(effectiveTheme)) {
|
||||
const effectiveTheme = loadedSettings.merged.theme;
|
||||
if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) {
|
||||
setIsThemeDialogOpen(true);
|
||||
setThemeError(`Theme "${effectiveTheme}" not found.`);
|
||||
} else {
|
||||
setThemeError(null);
|
||||
}
|
||||
}, [effectiveTheme, setThemeError, addItem]); // Re-run if effectiveTheme or setThemeError changes
|
||||
}, [loadedSettings.merged.theme, setThemeError]);
|
||||
|
||||
const openThemeDialog = useCallback(() => {
|
||||
if (process.env.NO_COLOR) {
|
||||
@@ -80,11 +59,10 @@ export const useThemeCommand = (
|
||||
setIsThemeDialogOpen(true);
|
||||
setThemeError(`Theme "${themeName}" not found.`);
|
||||
} else {
|
||||
setForceRender((v) => v + 1); // Trigger potential re-render
|
||||
setThemeError(null); // Clear any previous theme error on success
|
||||
}
|
||||
},
|
||||
[setForceRender, setThemeError],
|
||||
[setThemeError],
|
||||
);
|
||||
|
||||
const handleThemeHighlight = useCallback(
|
||||
@@ -96,15 +74,31 @@ export const useThemeCommand = (
|
||||
|
||||
const handleThemeSelect = useCallback(
|
||||
(themeName: string | undefined, scope: SettingScope) => {
|
||||
// Added scope parameter
|
||||
try {
|
||||
// Merge user and workspace custom themes (workspace takes precedence)
|
||||
const mergedCustomThemes = {
|
||||
...(loadedSettings.user.settings.customThemes || {}),
|
||||
...(loadedSettings.workspace.settings.customThemes || {}),
|
||||
};
|
||||
// Only allow selecting themes available in the merged custom themes or built-in themes
|
||||
const isBuiltIn = themeManager.findThemeByName(themeName);
|
||||
const isCustom = themeName && mergedCustomThemes[themeName];
|
||||
if (!isBuiltIn && !isCustom) {
|
||||
setThemeError(`Theme "${themeName}" not found in selected scope.`);
|
||||
setIsThemeDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
loadedSettings.setValue(scope, 'theme', themeName); // Update the merged settings
|
||||
if (loadedSettings.merged.customThemes) {
|
||||
themeManager.loadCustomThemes(loadedSettings.merged.customThemes);
|
||||
}
|
||||
applyTheme(loadedSettings.merged.theme); // Apply the current theme
|
||||
setThemeError(null);
|
||||
} finally {
|
||||
setIsThemeDialogOpen(false); // Close the dialog
|
||||
}
|
||||
},
|
||||
[applyTheme, loadedSettings],
|
||||
[applyTheme, loadedSettings, setThemeError],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
ToolCallResponseInfo,
|
||||
ToolCall, // Import from core
|
||||
Status as ToolCallStatusType,
|
||||
ApprovalMode, // Import from core
|
||||
ApprovalMode,
|
||||
Icon,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
HistoryItemWithoutId,
|
||||
@@ -56,6 +57,8 @@ const mockTool: Tool = {
|
||||
name: 'mockTool',
|
||||
displayName: 'Mock Tool',
|
||||
description: 'A mock tool for testing',
|
||||
icon: Icon.Hammer,
|
||||
toolLocations: vi.fn(),
|
||||
isOutputMarkdown: false,
|
||||
canUpdateOutput: false,
|
||||
schema: {},
|
||||
@@ -85,6 +88,8 @@ const mockToolRequiresConfirmation: Tool = {
|
||||
onConfirm: mockOnUserConfirmForToolConfirmation,
|
||||
fileName: 'mockToolRequiresConfirmation.ts',
|
||||
fileDiff: 'Mock tool requires confirmation',
|
||||
originalContent: 'Original content',
|
||||
newContent: 'New content',
|
||||
}),
|
||||
),
|
||||
};
|
||||
@@ -336,7 +341,7 @@ describe('useReactToolScheduler', () => {
|
||||
const schedule = result.current[1];
|
||||
const request: ToolCallRequestInfo = {
|
||||
callId: 'call1',
|
||||
name: 'nonExistentTool',
|
||||
name: 'nonexistentTool',
|
||||
args: {},
|
||||
};
|
||||
|
||||
@@ -356,7 +361,7 @@ describe('useReactToolScheduler', () => {
|
||||
request,
|
||||
response: expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: 'Tool "nonExistentTool" not found in registry.',
|
||||
message: 'Tool "nonexistentTool" not found in registry.',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -807,6 +812,8 @@ describe('mapToDisplay', () => {
|
||||
isOutputMarkdown: false,
|
||||
canUpdateOutput: false,
|
||||
schema: {},
|
||||
icon: Icon.Hammer,
|
||||
toolLocations: vi.fn(),
|
||||
validateToolParams: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
shouldConfirmExecute: vi.fn(),
|
||||
@@ -885,6 +892,8 @@ describe('mapToDisplay', () => {
|
||||
toolDisplayName: 'Test Tool Display',
|
||||
fileName: 'test.ts',
|
||||
fileDiff: 'Test diff',
|
||||
originalContent: 'Original content',
|
||||
newContent: 'New content',
|
||||
} as ToolCallConfirmationDetails,
|
||||
},
|
||||
expectedStatus: ToolCallStatus.Confirming,
|
||||
|
||||
1626
packages/cli/src/ui/hooks/vim.test.ts
Normal file
1626
packages/cli/src/ui/hooks/vim.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
774
packages/cli/src/ui/hooks/vim.ts
Normal file
774
packages/cli/src/ui/hooks/vim.ts
Normal file
@@ -0,0 +1,774 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useCallback, useReducer, useEffect } from 'react';
|
||||
import type { Key } from './useKeypress.js';
|
||||
import type { TextBuffer } from '../components/shared/text-buffer.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
|
||||
export type VimMode = 'NORMAL' | 'INSERT';
|
||||
|
||||
// Constants
|
||||
const DIGIT_MULTIPLIER = 10;
|
||||
const DEFAULT_COUNT = 1;
|
||||
const DIGIT_1_TO_9 = /^[1-9]$/;
|
||||
|
||||
// Command types
|
||||
const CMD_TYPES = {
|
||||
DELETE_WORD_FORWARD: 'dw',
|
||||
DELETE_WORD_BACKWARD: 'db',
|
||||
DELETE_WORD_END: 'de',
|
||||
CHANGE_WORD_FORWARD: 'cw',
|
||||
CHANGE_WORD_BACKWARD: 'cb',
|
||||
CHANGE_WORD_END: 'ce',
|
||||
DELETE_CHAR: 'x',
|
||||
DELETE_LINE: 'dd',
|
||||
CHANGE_LINE: 'cc',
|
||||
DELETE_TO_EOL: 'D',
|
||||
CHANGE_TO_EOL: 'C',
|
||||
CHANGE_MOVEMENT: {
|
||||
LEFT: 'ch',
|
||||
DOWN: 'cj',
|
||||
UP: 'ck',
|
||||
RIGHT: 'cl',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Helper function to clear pending state
|
||||
const createClearPendingState = () => ({
|
||||
count: 0,
|
||||
pendingOperator: null as 'g' | 'd' | 'c' | null,
|
||||
});
|
||||
|
||||
// State and action types for useReducer
|
||||
type VimState = {
|
||||
mode: VimMode;
|
||||
count: number;
|
||||
pendingOperator: 'g' | 'd' | 'c' | null;
|
||||
lastCommand: { type: string; count: number } | null;
|
||||
};
|
||||
|
||||
type VimAction =
|
||||
| { type: 'SET_MODE'; mode: VimMode }
|
||||
| { type: 'SET_COUNT'; count: number }
|
||||
| { type: 'INCREMENT_COUNT'; digit: number }
|
||||
| { type: 'CLEAR_COUNT' }
|
||||
| { type: 'SET_PENDING_OPERATOR'; operator: 'g' | 'd' | 'c' | null }
|
||||
| {
|
||||
type: 'SET_LAST_COMMAND';
|
||||
command: { type: string; count: number } | null;
|
||||
}
|
||||
| { type: 'CLEAR_PENDING_STATES' }
|
||||
| { type: 'ESCAPE_TO_NORMAL' };
|
||||
|
||||
const initialVimState: VimState = {
|
||||
mode: 'NORMAL',
|
||||
count: 0,
|
||||
pendingOperator: null,
|
||||
lastCommand: null,
|
||||
};
|
||||
|
||||
// Reducer function
|
||||
const vimReducer = (state: VimState, action: VimAction): VimState => {
|
||||
switch (action.type) {
|
||||
case 'SET_MODE':
|
||||
return { ...state, mode: action.mode };
|
||||
|
||||
case 'SET_COUNT':
|
||||
return { ...state, count: action.count };
|
||||
|
||||
case 'INCREMENT_COUNT':
|
||||
return { ...state, count: state.count * DIGIT_MULTIPLIER + action.digit };
|
||||
|
||||
case 'CLEAR_COUNT':
|
||||
return { ...state, count: 0 };
|
||||
|
||||
case 'SET_PENDING_OPERATOR':
|
||||
return { ...state, pendingOperator: action.operator };
|
||||
|
||||
case 'SET_LAST_COMMAND':
|
||||
return { ...state, lastCommand: action.command };
|
||||
|
||||
case 'CLEAR_PENDING_STATES':
|
||||
return {
|
||||
...state,
|
||||
...createClearPendingState(),
|
||||
};
|
||||
|
||||
case 'ESCAPE_TO_NORMAL':
|
||||
// Handle escape - clear all pending states (mode is updated via context)
|
||||
return {
|
||||
...state,
|
||||
...createClearPendingState(),
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* React hook that provides vim-style editing functionality for text input.
|
||||
*
|
||||
* Features:
|
||||
* - Modal editing (INSERT/NORMAL modes)
|
||||
* - Navigation: h,j,k,l,w,b,e,0,$,^,gg,G with count prefixes
|
||||
* - Editing: x,a,i,o,O,A,I,d,c,D,C with count prefixes
|
||||
* - Complex operations: dd,cc,dw,cw,db,cb,de,ce
|
||||
* - Command repetition (.)
|
||||
* - Settings persistence
|
||||
*
|
||||
* @param buffer - TextBuffer instance for text manipulation
|
||||
* @param onSubmit - Optional callback for command submission
|
||||
* @returns Object with vim state and input handler
|
||||
*/
|
||||
export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
||||
const { vimEnabled, vimMode, setVimMode } = useVimMode();
|
||||
const [state, dispatch] = useReducer(vimReducer, initialVimState);
|
||||
|
||||
// Sync vim mode from context to local state
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'SET_MODE', mode: vimMode });
|
||||
}, [vimMode]);
|
||||
|
||||
// Helper to update mode in both reducer and context
|
||||
const updateMode = useCallback(
|
||||
(mode: VimMode) => {
|
||||
setVimMode(mode);
|
||||
dispatch({ type: 'SET_MODE', mode });
|
||||
},
|
||||
[setVimMode],
|
||||
);
|
||||
|
||||
// Helper functions using the reducer state
|
||||
const getCurrentCount = useCallback(
|
||||
() => state.count || DEFAULT_COUNT,
|
||||
[state.count],
|
||||
);
|
||||
|
||||
/** Executes common commands to eliminate duplication in dot (.) repeat command */
|
||||
const executeCommand = useCallback(
|
||||
(cmdType: string, count: number) => {
|
||||
switch (cmdType) {
|
||||
case CMD_TYPES.DELETE_WORD_FORWARD: {
|
||||
buffer.vimDeleteWordForward(count);
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.DELETE_WORD_BACKWARD: {
|
||||
buffer.vimDeleteWordBackward(count);
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.DELETE_WORD_END: {
|
||||
buffer.vimDeleteWordEnd(count);
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.CHANGE_WORD_FORWARD: {
|
||||
buffer.vimChangeWordForward(count);
|
||||
updateMode('INSERT');
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.CHANGE_WORD_BACKWARD: {
|
||||
buffer.vimChangeWordBackward(count);
|
||||
updateMode('INSERT');
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.CHANGE_WORD_END: {
|
||||
buffer.vimChangeWordEnd(count);
|
||||
updateMode('INSERT');
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.DELETE_CHAR: {
|
||||
buffer.vimDeleteChar(count);
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.DELETE_LINE: {
|
||||
buffer.vimDeleteLine(count);
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.CHANGE_LINE: {
|
||||
buffer.vimChangeLine(count);
|
||||
updateMode('INSERT');
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.CHANGE_MOVEMENT.LEFT:
|
||||
case CMD_TYPES.CHANGE_MOVEMENT.DOWN:
|
||||
case CMD_TYPES.CHANGE_MOVEMENT.UP:
|
||||
case CMD_TYPES.CHANGE_MOVEMENT.RIGHT: {
|
||||
const movementMap: Record<string, 'h' | 'j' | 'k' | 'l'> = {
|
||||
[CMD_TYPES.CHANGE_MOVEMENT.LEFT]: 'h',
|
||||
[CMD_TYPES.CHANGE_MOVEMENT.DOWN]: 'j',
|
||||
[CMD_TYPES.CHANGE_MOVEMENT.UP]: 'k',
|
||||
[CMD_TYPES.CHANGE_MOVEMENT.RIGHT]: 'l',
|
||||
};
|
||||
const movementType = movementMap[cmdType];
|
||||
if (movementType) {
|
||||
buffer.vimChangeMovement(movementType, count);
|
||||
updateMode('INSERT');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.DELETE_TO_EOL: {
|
||||
buffer.vimDeleteToEndOfLine();
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.CHANGE_TO_EOL: {
|
||||
buffer.vimChangeToEndOfLine();
|
||||
updateMode('INSERT');
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[buffer, updateMode],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles key input in INSERT mode
|
||||
* @param normalizedKey - The normalized key input
|
||||
* @returns boolean indicating if the key was handled
|
||||
*/
|
||||
const handleInsertModeInput = useCallback(
|
||||
(normalizedKey: Key): boolean => {
|
||||
// Handle escape key immediately - switch to NORMAL mode on any escape
|
||||
if (normalizedKey.name === 'escape') {
|
||||
// Vim behavior: move cursor left when exiting insert mode (unless at beginning of line)
|
||||
buffer.vimEscapeInsertMode();
|
||||
dispatch({ type: 'ESCAPE_TO_NORMAL' });
|
||||
updateMode('NORMAL');
|
||||
return true;
|
||||
}
|
||||
|
||||
// In INSERT mode, let InputPrompt handle completion keys and special commands
|
||||
if (
|
||||
normalizedKey.name === 'tab' ||
|
||||
(normalizedKey.name === 'return' && !normalizedKey.ctrl) ||
|
||||
normalizedKey.name === 'up' ||
|
||||
normalizedKey.name === 'down'
|
||||
) {
|
||||
return false; // Let InputPrompt handle completion
|
||||
}
|
||||
|
||||
// Let InputPrompt handle Ctrl+V for clipboard image pasting
|
||||
if (normalizedKey.ctrl && normalizedKey.name === 'v') {
|
||||
return false; // Let InputPrompt handle clipboard functionality
|
||||
}
|
||||
|
||||
// Special handling for Enter key to allow command submission (lower priority than completion)
|
||||
if (
|
||||
normalizedKey.name === 'return' &&
|
||||
!normalizedKey.ctrl &&
|
||||
!normalizedKey.meta
|
||||
) {
|
||||
if (buffer.text.trim() && onSubmit) {
|
||||
// Handle command submission directly
|
||||
const submittedValue = buffer.text;
|
||||
buffer.setText('');
|
||||
onSubmit(submittedValue);
|
||||
return true;
|
||||
}
|
||||
return true; // Handled by vim (even if no onSubmit callback)
|
||||
}
|
||||
|
||||
// useKeypress already provides the correct format for TextBuffer
|
||||
buffer.handleInput(normalizedKey);
|
||||
return true; // Handled by vim
|
||||
},
|
||||
[buffer, dispatch, updateMode, onSubmit],
|
||||
);
|
||||
|
||||
/**
|
||||
* Normalizes key input to ensure all required properties are present
|
||||
* @param key - Raw key input
|
||||
* @returns Normalized key with all properties
|
||||
*/
|
||||
const normalizeKey = useCallback(
|
||||
(key: Key): Key => ({
|
||||
name: key.name || '',
|
||||
sequence: key.sequence || '',
|
||||
ctrl: key.ctrl || false,
|
||||
meta: key.meta || false,
|
||||
shift: key.shift || false,
|
||||
paste: key.paste || false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles change movement commands (ch, cj, ck, cl)
|
||||
* @param movement - The movement direction
|
||||
* @returns boolean indicating if command was handled
|
||||
*/
|
||||
const handleChangeMovement = useCallback(
|
||||
(movement: 'h' | 'j' | 'k' | 'l'): boolean => {
|
||||
const count = getCurrentCount();
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
buffer.vimChangeMovement(movement, count);
|
||||
updateMode('INSERT');
|
||||
|
||||
const cmdTypeMap = {
|
||||
h: CMD_TYPES.CHANGE_MOVEMENT.LEFT,
|
||||
j: CMD_TYPES.CHANGE_MOVEMENT.DOWN,
|
||||
k: CMD_TYPES.CHANGE_MOVEMENT.UP,
|
||||
l: CMD_TYPES.CHANGE_MOVEMENT.RIGHT,
|
||||
};
|
||||
|
||||
dispatch({
|
||||
type: 'SET_LAST_COMMAND',
|
||||
command: { type: cmdTypeMap[movement], count },
|
||||
});
|
||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||
return true;
|
||||
},
|
||||
[getCurrentCount, dispatch, buffer, updateMode],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles operator-motion commands (dw/cw, db/cb, de/ce)
|
||||
* @param operator - The operator type ('d' for delete, 'c' for change)
|
||||
* @param motion - The motion type ('w', 'b', 'e')
|
||||
* @returns boolean indicating if command was handled
|
||||
*/
|
||||
const handleOperatorMotion = useCallback(
|
||||
(operator: 'd' | 'c', motion: 'w' | 'b' | 'e'): boolean => {
|
||||
const count = getCurrentCount();
|
||||
|
||||
const commandMap = {
|
||||
d: {
|
||||
w: CMD_TYPES.DELETE_WORD_FORWARD,
|
||||
b: CMD_TYPES.DELETE_WORD_BACKWARD,
|
||||
e: CMD_TYPES.DELETE_WORD_END,
|
||||
},
|
||||
c: {
|
||||
w: CMD_TYPES.CHANGE_WORD_FORWARD,
|
||||
b: CMD_TYPES.CHANGE_WORD_BACKWARD,
|
||||
e: CMD_TYPES.CHANGE_WORD_END,
|
||||
},
|
||||
};
|
||||
|
||||
const cmdType = commandMap[operator][motion];
|
||||
executeCommand(cmdType, count);
|
||||
|
||||
dispatch({
|
||||
type: 'SET_LAST_COMMAND',
|
||||
command: { type: cmdType, count },
|
||||
});
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||
|
||||
return true;
|
||||
},
|
||||
[getCurrentCount, executeCommand, dispatch],
|
||||
);
|
||||
|
||||
const handleInput = useCallback(
|
||||
(key: Key): boolean => {
|
||||
if (!vimEnabled) {
|
||||
return false; // Let InputPrompt handle it
|
||||
}
|
||||
|
||||
let normalizedKey: Key;
|
||||
try {
|
||||
normalizedKey = normalizeKey(key);
|
||||
} catch (error) {
|
||||
// Handle malformed key inputs gracefully
|
||||
console.warn('Malformed key input in vim mode:', key, error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle INSERT mode
|
||||
if (state.mode === 'INSERT') {
|
||||
return handleInsertModeInput(normalizedKey);
|
||||
}
|
||||
|
||||
// Handle NORMAL mode
|
||||
if (state.mode === 'NORMAL') {
|
||||
// Handle Escape key in NORMAL mode - clear all pending states
|
||||
if (normalizedKey.name === 'escape') {
|
||||
dispatch({ type: 'CLEAR_PENDING_STATES' });
|
||||
return true; // Handled by vim
|
||||
}
|
||||
|
||||
// Handle count input (numbers 1-9, and 0 if count > 0)
|
||||
if (
|
||||
DIGIT_1_TO_9.test(normalizedKey.sequence) ||
|
||||
(normalizedKey.sequence === '0' && state.count > 0)
|
||||
) {
|
||||
dispatch({
|
||||
type: 'INCREMENT_COUNT',
|
||||
digit: parseInt(normalizedKey.sequence, 10),
|
||||
});
|
||||
return true; // Handled by vim
|
||||
}
|
||||
|
||||
const repeatCount = getCurrentCount();
|
||||
|
||||
switch (normalizedKey.sequence) {
|
||||
case 'h': {
|
||||
// Check if this is part of a change command (ch)
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleChangeMovement('h');
|
||||
}
|
||||
|
||||
// Normal left movement
|
||||
buffer.vimMoveLeft(repeatCount);
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'j': {
|
||||
// Check if this is part of a change command (cj)
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleChangeMovement('j');
|
||||
}
|
||||
|
||||
// Normal down movement
|
||||
buffer.vimMoveDown(repeatCount);
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'k': {
|
||||
// Check if this is part of a change command (ck)
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleChangeMovement('k');
|
||||
}
|
||||
|
||||
// Normal up movement
|
||||
buffer.vimMoveUp(repeatCount);
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'l': {
|
||||
// Check if this is part of a change command (cl)
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleChangeMovement('l');
|
||||
}
|
||||
|
||||
// Normal right movement
|
||||
buffer.vimMoveRight(repeatCount);
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'w': {
|
||||
// Check if this is part of a delete or change command (dw/cw)
|
||||
if (state.pendingOperator === 'd') {
|
||||
return handleOperatorMotion('d', 'w');
|
||||
}
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleOperatorMotion('c', 'w');
|
||||
}
|
||||
|
||||
// Normal word movement
|
||||
buffer.vimMoveWordForward(repeatCount);
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'b': {
|
||||
// Check if this is part of a delete or change command (db/cb)
|
||||
if (state.pendingOperator === 'd') {
|
||||
return handleOperatorMotion('d', 'b');
|
||||
}
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleOperatorMotion('c', 'b');
|
||||
}
|
||||
|
||||
// Normal backward word movement
|
||||
buffer.vimMoveWordBackward(repeatCount);
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'e': {
|
||||
// Check if this is part of a delete or change command (de/ce)
|
||||
if (state.pendingOperator === 'd') {
|
||||
return handleOperatorMotion('d', 'e');
|
||||
}
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleOperatorMotion('c', 'e');
|
||||
}
|
||||
|
||||
// Normal word end movement
|
||||
buffer.vimMoveWordEnd(repeatCount);
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'x': {
|
||||
// Delete character under cursor
|
||||
buffer.vimDeleteChar(repeatCount);
|
||||
dispatch({
|
||||
type: 'SET_LAST_COMMAND',
|
||||
command: { type: CMD_TYPES.DELETE_CHAR, count: repeatCount },
|
||||
});
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'i': {
|
||||
// Enter INSERT mode at current position
|
||||
buffer.vimInsertAtCursor();
|
||||
updateMode('INSERT');
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'a': {
|
||||
// Enter INSERT mode after current position
|
||||
buffer.vimAppendAtCursor();
|
||||
updateMode('INSERT');
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'o': {
|
||||
// Insert new line after current line and enter INSERT mode
|
||||
buffer.vimOpenLineBelow();
|
||||
updateMode('INSERT');
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'O': {
|
||||
// Insert new line before current line and enter INSERT mode
|
||||
buffer.vimOpenLineAbove();
|
||||
updateMode('INSERT');
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case '0': {
|
||||
// Move to start of line
|
||||
buffer.vimMoveToLineStart();
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case '$': {
|
||||
// Move to end of line
|
||||
buffer.vimMoveToLineEnd();
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case '^': {
|
||||
// Move to first non-whitespace character
|
||||
buffer.vimMoveToFirstNonWhitespace();
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'g': {
|
||||
if (state.pendingOperator === 'g') {
|
||||
// Second 'g' - go to first line (gg command)
|
||||
buffer.vimMoveToFirstLine();
|
||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||
} else {
|
||||
// First 'g' - wait for second g
|
||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'g' });
|
||||
}
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'G': {
|
||||
if (state.count > 0) {
|
||||
// Go to specific line number (1-based) when a count was provided
|
||||
buffer.vimMoveToLine(state.count);
|
||||
} else {
|
||||
// Go to last line when no count was provided
|
||||
buffer.vimMoveToLastLine();
|
||||
}
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'I': {
|
||||
// Enter INSERT mode at start of line (first non-whitespace)
|
||||
buffer.vimInsertAtLineStart();
|
||||
updateMode('INSERT');
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'A': {
|
||||
// Enter INSERT mode at end of line
|
||||
buffer.vimAppendAtLineEnd();
|
||||
updateMode('INSERT');
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'd': {
|
||||
if (state.pendingOperator === 'd') {
|
||||
// Second 'd' - delete N lines (dd command)
|
||||
const repeatCount = getCurrentCount();
|
||||
executeCommand(CMD_TYPES.DELETE_LINE, repeatCount);
|
||||
dispatch({
|
||||
type: 'SET_LAST_COMMAND',
|
||||
command: { type: CMD_TYPES.DELETE_LINE, count: repeatCount },
|
||||
});
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||
} else {
|
||||
// First 'd' - wait for movement command
|
||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'd' });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'c': {
|
||||
if (state.pendingOperator === 'c') {
|
||||
// Second 'c' - change N entire lines (cc command)
|
||||
const repeatCount = getCurrentCount();
|
||||
executeCommand(CMD_TYPES.CHANGE_LINE, repeatCount);
|
||||
dispatch({
|
||||
type: 'SET_LAST_COMMAND',
|
||||
command: { type: CMD_TYPES.CHANGE_LINE, count: repeatCount },
|
||||
});
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||
} else {
|
||||
// First 'c' - wait for movement command
|
||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'c' });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'D': {
|
||||
// Delete from cursor to end of line (equivalent to d$)
|
||||
executeCommand(CMD_TYPES.DELETE_TO_EOL, 1);
|
||||
dispatch({
|
||||
type: 'SET_LAST_COMMAND',
|
||||
command: { type: CMD_TYPES.DELETE_TO_EOL, count: 1 },
|
||||
});
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'C': {
|
||||
// Change from cursor to end of line (equivalent to c$)
|
||||
executeCommand(CMD_TYPES.CHANGE_TO_EOL, 1);
|
||||
dispatch({
|
||||
type: 'SET_LAST_COMMAND',
|
||||
command: { type: CMD_TYPES.CHANGE_TO_EOL, count: 1 },
|
||||
});
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case '.': {
|
||||
// Repeat last command
|
||||
if (state.lastCommand) {
|
||||
const cmdData = state.lastCommand;
|
||||
|
||||
// All repeatable commands are now handled by executeCommand
|
||||
executeCommand(cmdData.type, cmdData.count);
|
||||
}
|
||||
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
default: {
|
||||
// Check for arrow keys (they have different sequences but known names)
|
||||
if (normalizedKey.name === 'left') {
|
||||
// Left arrow - same as 'h'
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleChangeMovement('h');
|
||||
}
|
||||
|
||||
// Normal left movement (same as 'h')
|
||||
buffer.vimMoveLeft(repeatCount);
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedKey.name === 'down') {
|
||||
// Down arrow - same as 'j'
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleChangeMovement('j');
|
||||
}
|
||||
|
||||
// Normal down movement (same as 'j')
|
||||
buffer.vimMoveDown(repeatCount);
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedKey.name === 'up') {
|
||||
// Up arrow - same as 'k'
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleChangeMovement('k');
|
||||
}
|
||||
|
||||
// Normal up movement (same as 'k')
|
||||
buffer.vimMoveUp(repeatCount);
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalizedKey.name === 'right') {
|
||||
// Right arrow - same as 'l'
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleChangeMovement('l');
|
||||
}
|
||||
|
||||
// Normal right movement (same as 'l')
|
||||
buffer.vimMoveRight(repeatCount);
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Unknown command, clear count and pending states
|
||||
dispatch({ type: 'CLEAR_PENDING_STATES' });
|
||||
return true; // Still handled by vim to prevent other handlers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false; // Not handled by vim
|
||||
},
|
||||
[
|
||||
vimEnabled,
|
||||
normalizeKey,
|
||||
handleInsertModeInput,
|
||||
state.mode,
|
||||
state.count,
|
||||
state.pendingOperator,
|
||||
state.lastCommand,
|
||||
dispatch,
|
||||
getCurrentCount,
|
||||
handleChangeMovement,
|
||||
handleOperatorMotion,
|
||||
buffer,
|
||||
executeCommand,
|
||||
updateMode,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
mode: state.mode,
|
||||
vimModeEnabled: vimEnabled,
|
||||
handleInput, // Expose the input handler for InputPrompt to use
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user