Merge tag 'v0.1.15' into feature/yiheng/sync-gemini-cli-0.1.15

This commit is contained in:
奕桁
2025-08-01 23:06:11 +08:00
340 changed files with 36528 additions and 22931 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -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');
});
});
});

View File

@@ -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

View File

@@ -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(
`

View File

@@ -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

View File

@@ -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,
};
}

View File

@@ -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();
});
});

View File

@@ -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);
}
},
[],
);

View 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);
});
});

View 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;
};

View File

@@ -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),
);
});
}
});
});
});

View File

@@ -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,
],
);

View File

@@ -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);

View File

@@ -14,7 +14,7 @@ interface UseInputHistoryProps {
onChange: (value: string) => void;
}
interface UseInputHistoryReturn {
export interface UseInputHistoryReturn {
handleSubmit: (value: string) => void;
navigateUp: () => boolean;
navigateDown: () => boolean;

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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,

File diff suppressed because it is too large Load Diff

View 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
};
}