Compare commits

...

7 Commits

Author SHA1 Message Date
tanzhenxin
442a9aed58 Replace spawn with execFile for memory-safe command execution (#1068) 2025-11-20 15:04:00 +08:00
Mingholy
a15b84e2a1 refactor(auth): enhance useAuthCommand to include history management and improve error handling in QwenOAuth2Client (#1077) 2025-11-20 14:37:39 +08:00
tanzhenxin
07069f00d1 feat: remove prompt completion feature (#1076) 2025-11-20 14:36:51 +08:00
pomelo
e1e7a0d606 Merge pull request #1074 from cwtuan/patch-1
fix: remove broken link
2025-11-20 14:33:14 +08:00
cwtuan
fc638851e7 fix: remove broken link 2025-11-20 12:50:06 +08:00
citlalinda
e1f793b2e0 fix: character encoding corruption when executing the /copy command on Windows. (#1069)
Co-authored-by: linda <hxn@163.com>
2025-11-20 10:23:17 +08:00
tanzhenxin
3c64f7bff5 chore: pump version to 0.2.3 (#1073) 2025-11-20 10:09:12 +08:00
43 changed files with 830 additions and 1701 deletions

View File

@@ -25,7 +25,7 @@
</div>
Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli) ([details](./README.gemini.md)), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance.
Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance.
## 💡 Free Options Available

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.2.2",
"version": "0.2.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.2.2",
"version": "0.2.3",
"workspaces": [
"packages/*"
],
@@ -16024,7 +16024,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.2.2",
"version": "0.2.3",
"dependencies": {
"@google/genai": "1.16.0",
"@iarna/toml": "^2.2.5",
@@ -16139,7 +16139,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.2.2",
"version": "0.2.3",
"hasInstallScript": true,
"dependencies": {
"@google/genai": "1.16.0",
@@ -16278,7 +16278,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.2.2",
"version": "0.2.3",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -16290,7 +16290,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.2.2",
"version": "0.2.3",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.2.2",
"version": "0.2.3",
"engines": {
"node": ">=20.0.0"
},
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.2"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.3"
},
"scripts": {
"start": "cross-env node scripts/start.js",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.2.2",
"version": "0.2.3",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.2"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.3"
},
"dependencies": {
"@google/genai": "1.16.0",

View File

@@ -789,7 +789,6 @@ export async function loadCliConfig(
useBuiltinRipgrep: settings.tools?.useBuiltinRipgrep,
shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell,
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
enablePromptCompletion: settings.general?.enablePromptCompletion ?? false,
skipLoopDetection: settings.model?.skipLoopDetection ?? false,
skipStartupContext: settings.model?.skipStartupContext ?? false,
vlmSwitchMode,

View File

@@ -77,7 +77,6 @@ const MIGRATION_MAP: Record<string, string> = {
disableAutoUpdate: 'general.disableAutoUpdate',
disableUpdateNag: 'general.disableUpdateNag',
dnsResolutionOrder: 'advanced.dnsResolutionOrder',
enablePromptCompletion: 'general.enablePromptCompletion',
enforcedAuthType: 'security.auth.enforcedType',
excludeTools: 'tools.exclude',
excludeMCPServers: 'mcp.excluded',

View File

@@ -167,16 +167,6 @@ const SETTINGS_SCHEMA = {
},
},
},
enablePromptCompletion: {
type: 'boolean',
label: 'Enable Prompt Completion',
category: 'General',
requiresRestart: true,
default: false,
description:
'Enable AI-powered prompt completion suggestions while typing.',
showInDialog: true,
},
debugKeystrokeLogging: {
type: 'boolean',
label: 'Debug Keystroke Logging',

View File

@@ -353,7 +353,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleAuthSelect,
openAuthDialog,
cancelAuthentication,
} = useAuthCommand(settings, config);
} = useAuthCommand(settings, config, historyManager.addItem);
const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({
config,

View File

@@ -4,23 +4,28 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback, useEffect } from 'react';
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
import type { Config } from '@qwen-code/qwen-code-core';
import {
AuthEvent,
AuthType,
clearCachedCredentialFile,
getErrorMessage,
logAuth,
AuthEvent,
} from '@qwen-code/qwen-code-core';
import { AuthState } from '../types.js';
import { useQwenAuth } from '../hooks/useQwenAuth.js';
import { useCallback, useEffect, useState } from 'react';
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
import { useQwenAuth } from '../hooks/useQwenAuth.js';
import { AuthState, MessageType } from '../types.js';
import type { HistoryItem } from '../types.js';
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
export const useAuthCommand = (
settings: LoadedSettings,
config: Config,
addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void,
) => {
const unAuthenticated =
settings.merged.security?.auth?.selectedType === undefined;
@@ -117,8 +122,17 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
// Log authentication success
const authEvent = new AuthEvent(authType, 'manual', 'success');
logAuth(config, authEvent);
// Show success message
addItem(
{
type: MessageType.INFO,
text: `Authenticated successfully with ${authType} credentials.`,
},
Date.now(),
);
},
[settings, handleAuthFailure, config],
[settings, handleAuthFailure, config, addItem],
);
const performAuth = useCallback(

View File

@@ -164,11 +164,6 @@ describe('InputPrompt', () => {
setActiveSuggestionIndex: vi.fn(),
setShowSuggestions: vi.fn(),
handleAutocomplete: vi.fn(),
promptCompletion: {
text: '',
accept: vi.fn(),
clear: vi.fn(),
},
};
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);

View File

@@ -12,9 +12,8 @@ import { theme } from '../semantic-colors.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import type { TextBuffer } from './shared/text-buffer.js';
import { logicalPosToOffset } from './shared/text-buffer.js';
import { cpSlice, cpLen, toCodePoints } from '../utils/textUtils.js';
import { cpSlice, cpLen } from '../utils/textUtils.js';
import chalk from 'chalk';
import stringWidth from 'string-width';
import { useShellHistory } from '../hooks/useShellHistory.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
@@ -91,7 +90,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
commandContext,
placeholder = ' Type your message or @path/to/file',
focus = true,
inputWidth,
suggestionsWidth,
shellModeActive,
setShellModeActive,
@@ -526,16 +524,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}
// Handle Tab key for ghost text acceptance
if (
key.name === 'tab' &&
!completion.showSuggestions &&
completion.promptCompletion.text
) {
completion.promptCompletion.accept();
return;
}
if (!shellModeActive) {
if (keyMatchers[Command.REVERSE_SEARCH](key)) {
setCommandSearchActive(true);
@@ -657,18 +645,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// Fall back to the text buffer's default input handling for all other keys
buffer.handleInput(key);
// Clear ghost text when user types regular characters (not navigation/control keys)
if (
completion.promptCompletion.text &&
key.sequence &&
key.sequence.length === 1 &&
!key.ctrl &&
!key.meta
) {
completion.promptCompletion.clear();
setExpandedSuggestionIndex(-1);
}
},
[
focus,
@@ -703,118 +679,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.visualCursor;
const scrollVisualRow = buffer.visualScrollRow;
const getGhostTextLines = useCallback(() => {
if (
!completion.promptCompletion.text ||
!buffer.text ||
!completion.promptCompletion.text.startsWith(buffer.text)
) {
return { inlineGhost: '', additionalLines: [] };
}
const ghostSuffix = completion.promptCompletion.text.slice(
buffer.text.length,
);
if (!ghostSuffix) {
return { inlineGhost: '', additionalLines: [] };
}
const currentLogicalLine = buffer.lines[buffer.cursor[0]] || '';
const cursorCol = buffer.cursor[1];
const textBeforeCursor = cpSlice(currentLogicalLine, 0, cursorCol);
const usedWidth = stringWidth(textBeforeCursor);
const remainingWidth = Math.max(0, inputWidth - usedWidth);
const ghostTextLinesRaw = ghostSuffix.split('\n');
const firstLineRaw = ghostTextLinesRaw.shift() || '';
let inlineGhost = '';
let remainingFirstLine = '';
if (stringWidth(firstLineRaw) <= remainingWidth) {
inlineGhost = firstLineRaw;
} else {
const words = firstLineRaw.split(' ');
let currentLine = '';
let wordIdx = 0;
for (const word of words) {
const prospectiveLine = currentLine ? `${currentLine} ${word}` : word;
if (stringWidth(prospectiveLine) > remainingWidth) {
break;
}
currentLine = prospectiveLine;
wordIdx++;
}
inlineGhost = currentLine;
if (words.length > wordIdx) {
remainingFirstLine = words.slice(wordIdx).join(' ');
}
}
const linesToWrap = [];
if (remainingFirstLine) {
linesToWrap.push(remainingFirstLine);
}
linesToWrap.push(...ghostTextLinesRaw);
const remainingGhostText = linesToWrap.join('\n');
const additionalLines: string[] = [];
if (remainingGhostText) {
const textLines = remainingGhostText.split('\n');
for (const textLine of textLines) {
const words = textLine.split(' ');
let currentLine = '';
for (const word of words) {
const prospectiveLine = currentLine ? `${currentLine} ${word}` : word;
const prospectiveWidth = stringWidth(prospectiveLine);
if (prospectiveWidth > inputWidth) {
if (currentLine) {
additionalLines.push(currentLine);
}
let wordToProcess = word;
while (stringWidth(wordToProcess) > inputWidth) {
let part = '';
const wordCP = toCodePoints(wordToProcess);
let partWidth = 0;
let splitIndex = 0;
for (let i = 0; i < wordCP.length; i++) {
const char = wordCP[i];
const charWidth = stringWidth(char);
if (partWidth + charWidth > inputWidth) {
break;
}
part += char;
partWidth += charWidth;
splitIndex = i + 1;
}
additionalLines.push(part);
wordToProcess = cpSlice(wordToProcess, splitIndex);
}
currentLine = wordToProcess;
} else {
currentLine = prospectiveLine;
}
}
if (currentLine) {
additionalLines.push(currentLine);
}
}
}
return { inlineGhost, additionalLines };
}, [
completion.promptCompletion.text,
buffer.text,
buffer.lines,
buffer.cursor,
inputWidth,
]);
const { inlineGhost, additionalLines } = getGhostTextLines();
const getActiveCompletion = () => {
if (commandSearchActive) return commandSearchCompletion;
if (reverseSearchActive) return reverseSearchCompletion;
@@ -887,134 +751,96 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
<Text color={theme.text.secondary}>{placeholder}</Text>
)
) : (
linesToRender
.map((lineText, visualIdxInRenderedSet) => {
const absoluteVisualIdx =
scrollVisualRow + visualIdxInRenderedSet;
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
const cursorVisualRow =
cursorVisualRowAbsolute - scrollVisualRow;
const isOnCursorLine =
focus && visualIdxInRenderedSet === cursorVisualRow;
linesToRender.map((lineText, visualIdxInRenderedSet) => {
const absoluteVisualIdx =
scrollVisualRow + visualIdxInRenderedSet;
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow;
const isOnCursorLine =
focus && visualIdxInRenderedSet === cursorVisualRow;
const renderedLine: React.ReactNode[] = [];
const renderedLine: React.ReactNode[] = [];
const [logicalLineIdx, logicalStartCol] = mapEntry;
const logicalLine = buffer.lines[logicalLineIdx] || '';
const tokens = parseInputForHighlighting(
logicalLine,
logicalLineIdx,
);
const [logicalLineIdx, logicalStartCol] = mapEntry;
const logicalLine = buffer.lines[logicalLineIdx] || '';
const tokens = parseInputForHighlighting(
logicalLine,
logicalLineIdx,
);
const visualStart = logicalStartCol;
const visualEnd = logicalStartCol + cpLen(lineText);
const segments = buildSegmentsForVisualSlice(
tokens,
visualStart,
visualEnd,
);
const visualStart = logicalStartCol;
const visualEnd = logicalStartCol + cpLen(lineText);
const segments = buildSegmentsForVisualSlice(
tokens,
visualStart,
visualEnd,
);
let charCount = 0;
segments.forEach((seg, segIdx) => {
const segLen = cpLen(seg.text);
let display = seg.text;
let charCount = 0;
segments.forEach((seg, segIdx) => {
const segLen = cpLen(seg.text);
let display = seg.text;
if (isOnCursorLine) {
const relativeVisualColForHighlight =
cursorVisualColAbsolute;
const segStart = charCount;
const segEnd = segStart + segLen;
if (
relativeVisualColForHighlight >= segStart &&
relativeVisualColForHighlight < segEnd
) {
const charToHighlight = cpSlice(
if (isOnCursorLine) {
const relativeVisualColForHighlight = cursorVisualColAbsolute;
const segStart = charCount;
const segEnd = segStart + segLen;
if (
relativeVisualColForHighlight >= segStart &&
relativeVisualColForHighlight < segEnd
) {
const charToHighlight = cpSlice(
seg.text,
relativeVisualColForHighlight - segStart,
relativeVisualColForHighlight - segStart + 1,
);
const highlighted = showCursor
? chalk.inverse(charToHighlight)
: charToHighlight;
display =
cpSlice(
seg.text,
0,
relativeVisualColForHighlight - segStart,
) +
highlighted +
cpSlice(
seg.text,
relativeVisualColForHighlight - segStart + 1,
);
const highlighted = showCursor
? chalk.inverse(charToHighlight)
: charToHighlight;
display =
cpSlice(
seg.text,
0,
relativeVisualColForHighlight - segStart,
) +
highlighted +
cpSlice(
seg.text,
relativeVisualColForHighlight - segStart + 1,
);
}
charCount = segEnd;
}
const color =
seg.type === 'command' || seg.type === 'file'
? theme.text.accent
: theme.text.primary;
renderedLine.push(
<Text key={`token-${segIdx}`} color={color}>
{display}
</Text>,
);
});
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
if (
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText)
) {
if (!currentLineGhost) {
renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{showCursor ? chalk.inverse(' ') : ' '}
</Text>,
);
}
charCount = segEnd;
}
const showCursorBeforeGhost =
focus &&
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText) &&
currentLineGhost;
const color =
seg.type === 'command' || seg.type === 'file'
? theme.text.accent
: theme.text.primary;
return (
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
<Text>
{renderedLine}
{showCursorBeforeGhost &&
(showCursor ? chalk.inverse(' ') : ' ')}
{currentLineGhost && (
<Text color={theme.text.secondary}>
{currentLineGhost}
</Text>
)}
</Text>
</Box>
renderedLine.push(
<Text key={`token-${segIdx}`} color={color}>
{display}
</Text>,
);
})
.concat(
additionalLines.map((ghostLine, index) => {
const padding = Math.max(
0,
inputWidth - stringWidth(ghostLine),
);
return (
<Text
key={`ghost-line-${index}`}
color={theme.text.secondary}
>
{ghostLine}
{' '.repeat(padding)}
</Text>
);
}),
)
});
if (
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText)
) {
renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{showCursor ? chalk.inverse(' ') : ' '}
</Text>,
);
}
return (
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
<Text>{renderedLine}</Text>
</Box>
);
})
)}
</Box>
</Box>

View File

@@ -1271,7 +1271,6 @@ describe('SettingsDialog', () => {
vimMode: true,
disableAutoUpdate: true,
debugKeystrokeLogging: true,
enablePromptCompletion: true,
},
ui: {
hideWindowTitle: true,
@@ -1517,7 +1516,6 @@ describe('SettingsDialog', () => {
vimMode: false,
disableAutoUpdate: false,
debugKeystrokeLogging: false,
enablePromptCompletion: false,
},
ui: {
hideWindowTitle: false,

View File

@@ -10,8 +10,6 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
│ │
│ Disable Auto Update false │
│ │
│ Enable Prompt Completion false │
│ │
│ Debug Keystroke Logging false │
│ │
│ Output Format Text │
@@ -22,6 +20,8 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
│ │
│ Hide Tips false │
│ │
│ Hide Banner false │
│ │
│ ▼ │
│ │
│ │
@@ -44,8 +44,6 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
│ │
│ Disable Auto Update false │
│ │
│ Enable Prompt Completion false │
│ │
│ Debug Keystroke Logging false │
│ │
│ Output Format Text │
@@ -56,6 +54,8 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
│ │
│ Hide Tips false │
│ │
│ Hide Banner false │
│ │
│ ▼ │
│ │
│ │
@@ -78,8 +78,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
│ │
│ Disable Auto Update false │
│ │
│ Enable Prompt Completion false │
│ │
│ Debug Keystroke Logging false │
│ │
│ Output Format Text │
@@ -90,6 +88,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
│ │
│ Hide Tips false │
│ │
│ Hide Banner false │
│ │
│ ▼ │
│ │
│ │
@@ -112,8 +112,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
│ │
│ Disable Auto Update false* │
│ │
│ Enable Prompt Completion false* │
│ │
│ Debug Keystroke Logging false* │
│ │
│ Output Format Text │
@@ -124,6 +122,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
│ │
│ Hide Tips false* │
│ │
│ Hide Banner false │
│ │
│ ▼ │
│ │
│ │
@@ -146,8 +146,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ │
│ Disable Auto Update (Modified in System) false │
│ │
│ Enable Prompt Completion false │
│ │
│ Debug Keystroke Logging false │
│ │
│ Output Format Text │
@@ -158,6 +156,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ │
│ Hide Tips false │
│ │
│ Hide Banner false │
│ │
│ ▼ │
│ │
│ │
@@ -180,8 +180,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ │
│ Disable Auto Update false │
│ │
│ Enable Prompt Completion false │
│ │
│ Debug Keystroke Logging (Modified in Workspace) false │
│ │
│ Output Format Text │
@@ -192,6 +190,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ │
│ Hide Tips false │
│ │
│ Hide Banner false │
│ │
│ ▼ │
│ │
│ │
@@ -214,8 +214,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
│ │
│ Disable Auto Update false │
│ │
│ Enable Prompt Completion false │
│ │
│ Debug Keystroke Logging false │
│ │
│ Output Format Text │
@@ -226,6 +224,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
│ │
│ Hide Tips false │
│ │
│ Hide Banner false │
│ │
│ ▼ │
│ │
│ │
@@ -248,8 +248,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
│ │
│ Disable Auto Update true* │
│ │
│ Enable Prompt Completion false │
│ │
│ Debug Keystroke Logging false │
│ │
│ Output Format Text │
@@ -260,6 +258,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
│ │
│ Hide Tips false │
│ │
│ Hide Banner false │
│ │
│ ▼ │
│ │
│ │
@@ -282,8 +282,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
│ │
│ Disable Auto Update false │
│ │
│ Enable Prompt Completion false │
│ │
│ Debug Keystroke Logging false │
│ │
│ Output Format Text │
@@ -294,6 +292,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
│ │
│ Hide Tips false │
│ │
│ Hide Banner false │
│ │
│ ▼ │
│ │
│ │
@@ -316,8 +316,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
│ │
│ Disable Auto Update true* │
│ │
│ Enable Prompt Completion true* │
│ │
│ Debug Keystroke Logging true* │
│ │
│ Output Format Text │
@@ -328,6 +326,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
│ │
│ Hide Tips true* │
│ │
│ Hide Banner false │
│ │
│ ▼ │
│ │
│ │

View File

@@ -330,7 +330,7 @@ describe('BaseSelectionList', () => {
expect(output).not.toContain('Item 5');
});
it('should scroll up when activeIndex moves before the visible window', async () => {
it.skip('should scroll up when activeIndex moves before the visible window', async () => {
const { updateActiveIndex, lastFrame } = renderScrollableList(0);
await updateActiveIndex(4);

View File

@@ -83,9 +83,7 @@ const setupMocks = ({
describe('useCommandCompletion', () => {
const mockCommandContext = {} as CommandContext;
const mockConfig = {
getEnablePromptCompletion: () => false,
} as Config;
const mockConfig = {} as Config;
const testDirs: string[] = [];
const testRootDir = '/';
@@ -516,81 +514,4 @@ describe('useCommandCompletion', () => {
);
});
});
describe('prompt completion filtering', () => {
it('should not trigger prompt completion for line comments', async () => {
const mockConfig = {
getEnablePromptCompletion: () => true,
} as Config;
const { result } = renderHook(() => {
const textBuffer = useTextBufferForTest('// This is a line comment');
const completion = useCommandCompletion(
textBuffer,
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
);
return { ...completion, textBuffer };
});
// Should not trigger prompt completion for comments
expect(result.current.suggestions.length).toBe(0);
});
it('should not trigger prompt completion for block comments', async () => {
const mockConfig = {
getEnablePromptCompletion: () => true,
} as Config;
const { result } = renderHook(() => {
const textBuffer = useTextBufferForTest(
'/* This is a block comment */',
);
const completion = useCommandCompletion(
textBuffer,
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
);
return { ...completion, textBuffer };
});
// Should not trigger prompt completion for comments
expect(result.current.suggestions.length).toBe(0);
});
it('should trigger prompt completion for regular text when enabled', async () => {
const mockConfig = {
getEnablePromptCompletion: () => true,
} as Config;
const { result } = renderHook(() => {
const textBuffer = useTextBufferForTest(
'This is regular text that should trigger completion',
);
const completion = useCommandCompletion(
textBuffer,
testDirs,
testRootDir,
[],
mockCommandContext,
false,
mockConfig,
);
return { ...completion, textBuffer };
});
// This test verifies that comments are filtered out while regular text is not
expect(result.current.textBuffer.text).toBe(
'This is regular text that should trigger completion',
);
});
});
});

View File

@@ -13,11 +13,6 @@ import { isSlashCommand } from '../utils/commandUtils.js';
import { toCodePoints } from '../utils/textUtils.js';
import { useAtCompletion } from './useAtCompletion.js';
import { useSlashCompletion } from './useSlashCompletion.js';
import type { PromptCompletion } from './usePromptCompletion.js';
import {
usePromptCompletion,
PROMPT_COMPLETION_MIN_LENGTH,
} from './usePromptCompletion.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { useCompletion } from './useCompletion.js';
@@ -25,7 +20,6 @@ export enum CompletionMode {
IDLE = 'IDLE',
AT = 'AT',
SLASH = 'SLASH',
PROMPT = 'PROMPT',
}
export interface UseCommandCompletionReturn {
@@ -41,7 +35,6 @@ export interface UseCommandCompletionReturn {
navigateUp: () => void;
navigateDown: () => void;
handleAutocomplete: (indexToUse: number) => void;
promptCompletion: PromptCompletion;
}
export function useCommandCompletion(
@@ -126,32 +119,13 @@ export function useCommandCompletion(
}
}
// Check for prompt completion - only if enabled
const trimmedText = buffer.text.trim();
const isPromptCompletionEnabled =
config?.getEnablePromptCompletion() ?? false;
if (
isPromptCompletionEnabled &&
trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH &&
!isSlashCommand(trimmedText) &&
!trimmedText.includes('@')
) {
return {
completionMode: CompletionMode.PROMPT,
query: trimmedText,
completionStart: 0,
completionEnd: trimmedText.length,
};
}
return {
completionMode: CompletionMode.IDLE,
query: null,
completionStart: -1,
completionEnd: -1,
};
}, [cursorRow, cursorCol, buffer.lines, buffer.text, config]);
}, [cursorRow, cursorCol, buffer.lines]);
useAtCompletion({
enabled: completionMode === CompletionMode.AT,
@@ -172,12 +146,6 @@ export function useCommandCompletion(
setIsPerfectMatch,
});
const promptCompletion = usePromptCompletion({
buffer,
config,
enabled: completionMode === CompletionMode.PROMPT,
});
useEffect(() => {
setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1);
setVisibleStartIndex(0);
@@ -264,6 +232,5 @@ export function useCommandCompletion(
navigateUp,
navigateDown,
handleAutocomplete,
promptCompletion,
};
}

View File

@@ -4,13 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { MockedFunction } from 'vitest';
import type { Mock } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { useGitBranchName } from './useGitBranchName.js';
import { fs, vol } from 'memfs'; // For mocking fs
import { spawnAsync as mockSpawnAsync } from '@qwen-code/qwen-code-core';
import { isCommandAvailable, execCommand } from '@qwen-code/qwen-code-core';
// Mock @qwen-code/qwen-code-core
vi.mock('@qwen-code/qwen-code-core', async () => {
@@ -19,7 +19,8 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
>('@qwen-code/qwen-code-core');
return {
...original,
spawnAsync: vi.fn(),
execCommand: vi.fn(),
isCommandAvailable: vi.fn(),
};
});
@@ -47,6 +48,7 @@ describe('useGitBranchName', () => {
[GIT_LOGS_HEAD_PATH]: 'ref: refs/heads/main',
});
vi.useFakeTimers(); // Use fake timers for async operations
(isCommandAvailable as Mock).mockReturnValue({ available: true });
});
afterEach(() => {
@@ -55,11 +57,11 @@ describe('useGitBranchName', () => {
});
it('should return branch name', async () => {
(mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>).mockResolvedValue(
{
stdout: 'main\n',
} as { stdout: string; stderr: string },
);
(execCommand as Mock).mockResolvedValueOnce({
stdout: 'main\n',
stderr: '',
code: 0,
});
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
await act(async () => {
@@ -71,9 +73,7 @@ describe('useGitBranchName', () => {
});
it('should return undefined if git command fails', async () => {
(mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>).mockRejectedValue(
new Error('Git error'),
);
(execCommand as Mock).mockRejectedValue(new Error('Git error'));
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
expect(result.current).toBeUndefined();
@@ -86,16 +86,16 @@ describe('useGitBranchName', () => {
});
it('should return short commit hash if branch is HEAD (detached state)', async () => {
(
mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>
).mockImplementation(async (command: string, args: string[]) => {
if (args.includes('--abbrev-ref')) {
return { stdout: 'HEAD\n' } as { stdout: string; stderr: string };
} else if (args.includes('--short')) {
return { stdout: 'a1b2c3d\n' } as { stdout: string; stderr: string };
}
return { stdout: '' } as { stdout: string; stderr: string };
});
(execCommand as Mock).mockImplementation(
async (_command: string, args?: readonly string[] | null) => {
if (args?.includes('--abbrev-ref')) {
return { stdout: 'HEAD\n', stderr: '', code: 0 };
} else if (args?.includes('--short')) {
return { stdout: 'a1b2c3d\n', stderr: '', code: 0 };
}
return { stdout: '', stderr: '', code: 0 };
},
);
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
await act(async () => {
@@ -106,16 +106,16 @@ describe('useGitBranchName', () => {
});
it('should return undefined if branch is HEAD and getting commit hash fails', async () => {
(
mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>
).mockImplementation(async (command: string, args: string[]) => {
if (args.includes('--abbrev-ref')) {
return { stdout: 'HEAD\n' } as { stdout: string; stderr: string };
} else if (args.includes('--short')) {
throw new Error('Git error');
}
return { stdout: '' } as { stdout: string; stderr: string };
});
(execCommand as Mock).mockImplementation(
async (_command: string, args?: readonly string[] | null) => {
if (args?.includes('--abbrev-ref')) {
return { stdout: 'HEAD\n', stderr: '', code: 0 };
} else if (args?.includes('--short')) {
throw new Error('Git error');
}
return { stdout: '', stderr: '', code: 0 };
},
);
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
await act(async () => {
@@ -127,14 +127,16 @@ describe('useGitBranchName', () => {
it('should update branch name when .git/HEAD changes', async ({ skip }) => {
skip(); // TODO: fix
(mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>)
.mockResolvedValueOnce({ stdout: 'main\n' } as {
stdout: string;
stderr: string;
(execCommand as Mock)
.mockResolvedValueOnce({
stdout: 'main\n',
stderr: '',
code: 0,
})
.mockResolvedValueOnce({ stdout: 'develop\n' } as {
stdout: string;
stderr: string;
.mockResolvedValueOnce({
stdout: 'develop\n',
stderr: '',
code: 0,
});
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
@@ -162,11 +164,11 @@ describe('useGitBranchName', () => {
// Remove .git/logs/HEAD to cause an error in fs.watch setup
vol.unlinkSync(GIT_LOGS_HEAD_PATH);
(mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>).mockResolvedValue(
{
stdout: 'main\n',
} as { stdout: string; stderr: string },
);
(execCommand as Mock).mockResolvedValue({
stdout: 'main\n',
stderr: '',
code: 0,
});
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
@@ -177,11 +179,11 @@ describe('useGitBranchName', () => {
expect(result.current).toBe('main'); // Branch name should still be fetched initially
(
mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>
).mockResolvedValueOnce({
(execCommand as Mock).mockResolvedValueOnce({
stdout: 'develop\n',
} as { stdout: string; stderr: string });
stderr: '',
code: 0,
});
// This write would trigger the watcher if it was set up
// but since it failed, the branch name should not update
@@ -207,11 +209,11 @@ describe('useGitBranchName', () => {
close: closeMock,
} as unknown as ReturnType<typeof fs.watch>);
(mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>).mockResolvedValue(
{
stdout: 'main\n',
} as { stdout: string; stderr: string },
);
(execCommand as Mock).mockResolvedValue({
stdout: 'main\n',
stderr: '',
code: 0,
});
const { unmount, rerender } = renderHook(() => useGitBranchName(CWD));

View File

@@ -5,7 +5,7 @@
*/
import { useState, useEffect, useCallback } from 'react';
import { spawnAsync } from '@qwen-code/qwen-code-core';
import { isCommandAvailable, execCommand } from '@qwen-code/qwen-code-core';
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
@@ -15,7 +15,11 @@ export function useGitBranchName(cwd: string): string | undefined {
const fetchBranchName = useCallback(async () => {
try {
const { stdout } = await spawnAsync(
if (!isCommandAvailable('git').available) {
return;
}
const { stdout } = await execCommand(
'git',
['rev-parse', '--abbrev-ref', 'HEAD'],
{ cwd },
@@ -24,7 +28,7 @@ export function useGitBranchName(cwd: string): string | undefined {
if (branch && branch !== 'HEAD') {
setBranchName(branch);
} else {
const { stdout: hashStdout } = await spawnAsync(
const { stdout: hashStdout } = await execCommand(
'git',
['rev-parse', '--short', 'HEAD'],
{ cwd },

View File

@@ -1,254 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import type { Config } from '@qwen-code/qwen-code-core';
import {
DEFAULT_GEMINI_FLASH_LITE_MODEL,
getResponseText,
} from '@qwen-code/qwen-code-core';
import type { Content, GenerateContentConfig } from '@google/genai';
import type { TextBuffer } from '../components/shared/text-buffer.js';
import { isSlashCommand } from '../utils/commandUtils.js';
export const PROMPT_COMPLETION_MIN_LENGTH = 5;
export const PROMPT_COMPLETION_DEBOUNCE_MS = 250;
export interface PromptCompletion {
text: string;
isLoading: boolean;
isActive: boolean;
accept: () => void;
clear: () => void;
markSelected: (selectedText: string) => void;
}
export interface UsePromptCompletionOptions {
buffer: TextBuffer;
config?: Config;
enabled: boolean;
}
export function usePromptCompletion({
buffer,
config,
enabled,
}: UsePromptCompletionOptions): PromptCompletion {
const [ghostText, setGhostText] = useState<string>('');
const [isLoadingGhostText, setIsLoadingGhostText] = useState<boolean>(false);
const abortControllerRef = useRef<AbortController | null>(null);
const [justSelectedSuggestion, setJustSelectedSuggestion] =
useState<boolean>(false);
const lastSelectedTextRef = useRef<string>('');
const lastRequestedTextRef = useRef<string>('');
const isPromptCompletionEnabled =
enabled && (config?.getEnablePromptCompletion() ?? false);
const clearGhostText = useCallback(() => {
setGhostText('');
setIsLoadingGhostText(false);
}, []);
const acceptGhostText = useCallback(() => {
if (ghostText && ghostText.length > buffer.text.length) {
buffer.setText(ghostText);
setGhostText('');
setJustSelectedSuggestion(true);
lastSelectedTextRef.current = ghostText;
}
}, [ghostText, buffer]);
const markSuggestionSelected = useCallback((selectedText: string) => {
setJustSelectedSuggestion(true);
lastSelectedTextRef.current = selectedText;
}, []);
const generatePromptSuggestions = useCallback(async () => {
const trimmedText = buffer.text.trim();
const geminiClient = config?.getGeminiClient();
if (trimmedText === lastRequestedTextRef.current) {
return;
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
if (
trimmedText.length < PROMPT_COMPLETION_MIN_LENGTH ||
!geminiClient ||
isSlashCommand(trimmedText) ||
trimmedText.includes('@') ||
!isPromptCompletionEnabled
) {
clearGhostText();
lastRequestedTextRef.current = '';
return;
}
lastRequestedTextRef.current = trimmedText;
setIsLoadingGhostText(true);
abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal;
try {
const contents: Content[] = [
{
role: 'user',
parts: [
{
text: `You are a professional prompt engineering assistant. Complete the user's partial prompt with expert precision and clarity. User's input: "${trimmedText}" Continue this prompt by adding specific, actionable details that align with the user's intent. Focus on: clear, precise language; structured requirements; professional terminology; measurable outcomes. Length Guidelines: Keep suggestions concise (ideally 10-20 characters); prioritize brevity while maintaining clarity; use essential keywords only; avoid redundant phrases. Start your response with the exact user text ("${trimmedText}") followed by your completion. Provide practical, implementation-focused suggestions rather than creative interpretations. Format: Plain text only. Single completion. Match the user's language. Emphasize conciseness over elaboration.`,
},
],
},
];
const generationConfig: GenerateContentConfig = {
temperature: 0.3,
maxOutputTokens: 16000,
thinkingConfig: {
thinkingBudget: 0,
},
};
const response = await geminiClient.generateContent(
contents,
generationConfig,
signal,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
);
if (signal.aborted) {
return;
}
if (response) {
const responseText = getResponseText(response);
if (responseText) {
const suggestionText = responseText.trim();
if (
suggestionText.length > 0 &&
suggestionText.startsWith(trimmedText)
) {
setGhostText(suggestionText);
} else {
clearGhostText();
}
}
}
} catch (error) {
if (
!(
signal.aborted ||
(error instanceof Error && error.name === 'AbortError')
)
) {
console.error('prompt completion error:', error);
// Clear the last requested text to allow retry only on real errors
lastRequestedTextRef.current = '';
}
clearGhostText();
} finally {
if (!signal.aborted) {
setIsLoadingGhostText(false);
}
}
}, [buffer.text, config, clearGhostText, isPromptCompletionEnabled]);
const isCursorAtEnd = useCallback(() => {
const [cursorRow, cursorCol] = buffer.cursor;
const totalLines = buffer.lines.length;
if (cursorRow !== totalLines - 1) {
return false;
}
const lastLine = buffer.lines[cursorRow] || '';
return cursorCol === lastLine.length;
}, [buffer.cursor, buffer.lines]);
const handlePromptCompletion = useCallback(() => {
if (!isCursorAtEnd()) {
clearGhostText();
return;
}
const trimmedText = buffer.text.trim();
if (justSelectedSuggestion && trimmedText === lastSelectedTextRef.current) {
return;
}
if (trimmedText !== lastSelectedTextRef.current) {
setJustSelectedSuggestion(false);
lastSelectedTextRef.current = '';
}
generatePromptSuggestions();
}, [
buffer.text,
generatePromptSuggestions,
justSelectedSuggestion,
isCursorAtEnd,
clearGhostText,
]);
// Debounce prompt completion
useEffect(() => {
const timeoutId = setTimeout(
handlePromptCompletion,
PROMPT_COMPLETION_DEBOUNCE_MS,
);
return () => clearTimeout(timeoutId);
}, [buffer.text, buffer.cursor, handlePromptCompletion]);
// Ghost text validation - clear if it doesn't match current text or cursor not at end
useEffect(() => {
const currentText = buffer.text.trim();
if (ghostText && !isCursorAtEnd()) {
clearGhostText();
return;
}
if (
ghostText &&
currentText.length > 0 &&
!ghostText.startsWith(currentText)
) {
clearGhostText();
}
}, [buffer.text, buffer.cursor, ghostText, clearGhostText, isCursorAtEnd]);
// Cleanup on unmount
useEffect(() => () => abortControllerRef.current?.abort(), []);
const isActive = useMemo(() => {
if (!isPromptCompletionEnabled) return false;
if (!isCursorAtEnd()) return false;
const trimmedText = buffer.text.trim();
return (
trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH &&
!isSlashCommand(trimmedText) &&
!trimmedText.includes('@')
);
}, [buffer.text, isPromptCompletionEnabled, isCursorAtEnd]);
return {
text: ghostText,
isLoading: isLoadingGhostText,
isActive,
accept: acceptGhostText,
clear: clearGhostText,
markSelected: markSuggestionSelected,
};
}

View File

@@ -6,7 +6,7 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { spawnAsync } from '@qwen-code/qwen-code-core';
import { execCommand } from '@qwen-code/qwen-code-core';
/**
* Checks if the system clipboard contains an image (macOS only for now)
@@ -19,7 +19,7 @@ export async function clipboardHasImage(): Promise<boolean> {
try {
// Use osascript to check clipboard type
const { stdout } = await spawnAsync('osascript', ['-e', 'clipboard info']);
const { stdout } = await execCommand('osascript', ['-e', 'clipboard info']);
const imageRegex =
/«class PNGf»|TIFF picture|JPEG picture|GIF picture|«class JPEG»|«class TIFF»/;
return imageRegex.test(stdout);
@@ -80,7 +80,7 @@ export async function saveClipboardImage(
end try
`;
const { stdout } = await spawnAsync('osascript', ['-e', script]);
const { stdout } = await execCommand('osascript', ['-e', script]);
if (stdout.trim() === 'success') {
// Verify the file was created and has content

View File

@@ -13,6 +13,7 @@ import {
isSlashCommand,
copyToClipboard,
getUrlOpenCommand,
CodePage,
} from './commandUtils.js';
// Mock child_process
@@ -188,7 +189,10 @@ describe('commandUtils', () => {
await copyToClipboard(testText);
expect(mockSpawn).toHaveBeenCalledWith('clip', []);
expect(mockSpawn).toHaveBeenCalledWith('cmd', [
'/c',
`chcp ${CodePage.UTF8} >nul && clip`,
]);
expect(mockChild.stdin.write).toHaveBeenCalledWith(testText);
expect(mockChild.stdin.end).toHaveBeenCalled();
});

View File

@@ -7,6 +7,23 @@
import type { SpawnOptions } from 'node:child_process';
import { spawn } from 'node:child_process';
/**
* Common Windows console code pages (CP) used for encoding conversions.
*
* @remarks
* - `UTF8` (65001): Unicode (UTF-8) — recommended for cross-language scripts.
* - `GBK` (936): Simplified Chinese — default on most Chinese Windows systems.
* - `BIG5` (950): Traditional Chinese.
* - `LATIN1` (1252): Western European — default on many Western systems.
*/
export const CodePage = {
UTF8: 65001,
GBK: 936,
BIG5: 950,
LATIN1: 1252,
} as const;
export type CodePage = (typeof CodePage)[keyof typeof CodePage];
/**
* Checks if a query string potentially represents an '@' command.
* It triggers if the query starts with '@' or contains '@' preceded by whitespace
@@ -80,7 +97,7 @@ export const copyToClipboard = async (text: string): Promise<void> => {
switch (process.platform) {
case 'win32':
return run('clip', []);
return run('cmd', ['/c', `chcp ${CodePage.UTF8} >nul && clip`]);
case 'darwin':
return run('pbcopy', []);
case 'linux':

View File

@@ -67,11 +67,15 @@ const ripgrepAvailabilityCheck: WarningCheck = {
return null;
}
const isAvailable = await canUseRipgrep(options.useBuiltinRipgrep);
if (!isAvailable) {
return 'Ripgrep not available: Please install ripgrep globally to enable faster file content search. Falling back to built-in grep.';
try {
const isAvailable = await canUseRipgrep(options.useBuiltinRipgrep);
if (!isAvailable) {
return 'Ripgrep not available: Please install ripgrep globally to enable faster file content search. Falling back to built-in grep.';
}
return null;
} catch (error) {
return `Ripgrep not available: ${error instanceof Error ? error.message : 'Unknown error'}. Falling back to built-in grep.`;
}
return null;
},
};

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.2.2",
"version": "0.2.3",
"description": "Qwen Code Core",
"repository": {
"type": "git",

View File

@@ -1085,7 +1085,7 @@ describe('setApprovalMode with folder trust', () => {
expect.any(RipgrepFallbackEvent),
);
const event = (logRipgrepFallback as Mock).mock.calls[0][1];
expect(event.error).toContain('Ripgrep is not available');
expect(event.error).toContain('ripgrep is not available');
});
it('should fall back to GrepTool and log error when useRipgrep is true and builtin ripgrep is not available', async () => {
@@ -1109,7 +1109,7 @@ describe('setApprovalMode with folder trust', () => {
expect.any(RipgrepFallbackEvent),
);
const event = (logRipgrepFallback as Mock).mock.calls[0][1];
expect(event.error).toContain('Ripgrep is not available');
expect(event.error).toContain('ripgrep is not available');
});
it('should fall back to GrepTool and log error when canUseRipgrep throws an error', async () => {
@@ -1133,7 +1133,7 @@ describe('setApprovalMode with folder trust', () => {
expect.any(RipgrepFallbackEvent),
);
const event = (logRipgrepFallback as Mock).mock.calls[0][1];
expect(event.error).toBe(String(error));
expect(event.error).toBe(`ripGrep check failed`);
});
it('should register GrepTool when useRipgrep is false', async () => {

View File

@@ -82,6 +82,7 @@ import { shouldAttemptBrowserLaunch } from '../utils/browser.js';
import { FileExclusions } from '../utils/ignorePatterns.js';
import { WorkspaceContext } from '../utils/workspaceContext.js';
import { isToolEnabled, type ToolName } from '../utils/tool-utils.js';
import { getErrorMessage } from '../utils/errors.js';
// Local config modules
import type { FileFilteringOptions } from './constants.js';
@@ -280,7 +281,6 @@ export interface ConfigParameters {
skipNextSpeakerCheck?: boolean;
shellExecutionConfig?: ShellExecutionConfig;
extensionManagement?: boolean;
enablePromptCompletion?: boolean;
skipLoopDetection?: boolean;
vlmSwitchMode?: string;
truncateToolOutputThreshold?: number;
@@ -377,7 +377,6 @@ export class Config {
private readonly skipNextSpeakerCheck: boolean;
private shellExecutionConfig: ShellExecutionConfig;
private readonly extensionManagement: boolean = true;
private readonly enablePromptCompletion: boolean = false;
private readonly skipLoopDetection: boolean;
private readonly skipStartupContext: boolean;
private readonly vlmSwitchMode: string | undefined;
@@ -495,7 +494,6 @@ export class Config {
this.useSmartEdit = params.useSmartEdit ?? false;
this.extensionManagement = params.extensionManagement ?? true;
this.storage = new Storage(this.targetDir);
this.enablePromptCompletion = params.enablePromptCompletion ?? false;
this.vlmSwitchMode = params.vlmSwitchMode;
this.fileExclusions = new FileExclusions(this);
this.eventEmitter = params.eventEmitter;
@@ -1038,10 +1036,6 @@ export class Config {
return this.accessibility.screenReader ?? false;
}
getEnablePromptCompletion(): boolean {
return this.enablePromptCompletion;
}
getSkipLoopDetection(): boolean {
return this.skipLoopDetection;
}
@@ -1154,17 +1148,20 @@ export class Config {
try {
useRipgrep = await canUseRipgrep(this.getUseBuiltinRipgrep());
} catch (error: unknown) {
errorString = String(error);
errorString = getErrorMessage(error);
}
if (useRipgrep) {
registerCoreTool(RipGrepTool, this);
} else {
errorString =
errorString ||
'Ripgrep is not available. Please install ripgrep globally.';
// Log for telemetry
logRipgrepFallback(this, new RipgrepFallbackEvent(errorString));
logRipgrepFallback(
this,
new RipgrepFallbackEvent(
this.getUseRipgrep(),
this.getUseBuiltinRipgrep(),
errorString || 'ripgrep is not available',
),
);
registerCoreTool(GrepTool, this);
}
} else {

View File

@@ -623,14 +623,16 @@ describe('QwenOAuth2Client', () => {
});
it('should handle authorization_pending with HTTP 400 according to RFC 8628', async () => {
const errorData = {
error: 'authorization_pending',
error_description: 'The authorization request is still pending',
};
const mockResponse = {
ok: false,
status: 400,
statusText: 'Bad Request',
json: async () => ({
error: 'authorization_pending',
error_description: 'The authorization request is still pending',
}),
text: async () => JSON.stringify(errorData),
json: async () => errorData,
};
vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
@@ -646,14 +648,16 @@ describe('QwenOAuth2Client', () => {
});
it('should handle slow_down with HTTP 429 according to RFC 8628', async () => {
const errorData = {
error: 'slow_down',
error_description: 'The client is polling too frequently',
};
const mockResponse = {
ok: false,
status: 429,
statusText: 'Too Many Requests',
json: async () => ({
error: 'slow_down',
error_description: 'The client is polling too frequently',
}),
text: async () => JSON.stringify(errorData),
json: async () => errorData,
};
vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response);
@@ -1993,14 +1997,16 @@ describe('Enhanced Error Handling and Edge Cases', () => {
});
it('should handle authorization_pending with correct status', async () => {
const errorData = {
error: 'authorization_pending',
error_description: 'Authorization request is pending',
};
const mockResponse = {
ok: false,
status: 400,
statusText: 'Bad Request',
json: vi.fn().mockResolvedValue({
error: 'authorization_pending',
error_description: 'Authorization request is pending',
}),
text: vi.fn().mockResolvedValue(JSON.stringify(errorData)),
json: vi.fn().mockResolvedValue(errorData),
};
vi.mocked(global.fetch).mockResolvedValue(

View File

@@ -345,44 +345,47 @@ export class QwenOAuth2Client implements IQwenOAuth2Client {
});
if (!response.ok) {
// Parse the response as JSON to check for OAuth RFC 8628 standard errors
// Read response body as text first (can only be read once)
const responseText = await response.text();
// Try to parse as JSON to check for OAuth RFC 8628 standard errors
let errorData: ErrorData | null = null;
try {
const errorData = (await response.json()) as ErrorData;
// According to OAuth RFC 8628, handle standard polling responses
if (
response.status === 400 &&
errorData.error === 'authorization_pending'
) {
// User has not yet approved the authorization request. Continue polling.
return { status: 'pending' } as DeviceTokenPendingData;
}
if (response.status === 429 && errorData.error === 'slow_down') {
// Client is polling too frequently. Return pending with slowDown flag.
return {
status: 'pending',
slowDown: true,
} as DeviceTokenPendingData;
}
// Handle other 400 errors (access_denied, expired_token, etc.) as real errors
// For other errors, throw with proper error information
const error = new Error(
`Device token poll failed: ${errorData.error || 'Unknown error'} - ${errorData.error_description || 'No details provided'}`,
);
(error as Error & { status?: number }).status = response.status;
throw error;
errorData = JSON.parse(responseText) as ErrorData;
} catch (_parseError) {
// If JSON parsing fails, fall back to text response
const errorData = await response.text();
// If JSON parsing fails, use text response
const error = new Error(
`Device token poll failed: ${response.status} ${response.statusText}. Response: ${errorData}`,
`Device token poll failed: ${response.status} ${response.statusText}. Response: ${responseText}`,
);
(error as Error & { status?: number }).status = response.status;
throw error;
}
// According to OAuth RFC 8628, handle standard polling responses
if (
response.status === 400 &&
errorData.error === 'authorization_pending'
) {
// User has not yet approved the authorization request. Continue polling.
return { status: 'pending' } as DeviceTokenPendingData;
}
if (response.status === 429 && errorData.error === 'slow_down') {
// Client is polling too frequently. Return pending with slowDown flag.
return {
status: 'pending',
slowDown: true,
} as DeviceTokenPendingData;
}
// Handle other 400 errors (access_denied, expired_token, etc.) as real errors
// For other errors, throw with proper error information
const error = new Error(
`Device token poll failed: ${errorData.error || 'Unknown error'} - ${errorData.error_description}`,
);
(error as Error & { status?: number }).status = response.status;
throw error;
}
return (await response.json()) as DeviceTokenResponse;

View File

@@ -19,10 +19,10 @@ import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import { getProjectHash, QWEN_DIR } from '../utils/paths.js';
import { spawnAsync } from '../utils/shell-utils.js';
import { isCommandAvailable } from '../utils/shell-utils.js';
vi.mock('../utils/shell-utils.js', () => ({
spawnAsync: vi.fn(),
isCommandAvailable: vi.fn(),
}));
const hoistedMockEnv = vi.hoisted(() => vi.fn());
@@ -76,10 +76,7 @@ describe('GitService', () => {
vi.clearAllMocks();
hoistedIsGitRepositoryMock.mockReturnValue(true);
(spawnAsync as Mock).mockResolvedValue({
stdout: 'git version 2.0.0',
stderr: '',
});
(isCommandAvailable as Mock).mockReturnValue({ available: true });
hoistedMockHomedir.mockReturnValue(homedir);
@@ -119,23 +116,9 @@ describe('GitService', () => {
});
});
describe('verifyGitAvailability', () => {
it('should resolve true if git --version command succeeds', async () => {
const service = new GitService(projectRoot, storage);
await expect(service.verifyGitAvailability()).resolves.toBe(true);
expect(spawnAsync).toHaveBeenCalledWith('git', ['--version']);
});
it('should resolve false if git --version command fails', async () => {
(spawnAsync as Mock).mockRejectedValue(new Error('git not found'));
const service = new GitService(projectRoot, storage);
await expect(service.verifyGitAvailability()).resolves.toBe(false);
});
});
describe('initialize', () => {
it('should throw an error if Git is not available', async () => {
(spawnAsync as Mock).mockRejectedValue(new Error('git not found'));
(isCommandAvailable as Mock).mockReturnValue({ available: false });
const service = new GitService(projectRoot, storage);
await expect(service.initialize()).rejects.toThrow(
'Checkpointing is enabled, but Git is not installed. Please install Git or disable checkpointing to continue.',

View File

@@ -6,7 +6,7 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { spawnAsync } from '../utils/shell-utils.js';
import { isCommandAvailable } from '../utils/shell-utils.js';
import type { SimpleGit } from 'simple-git';
import { simpleGit, CheckRepoActions } from 'simple-git';
import type { Storage } from '../config/storage.js';
@@ -26,7 +26,7 @@ export class GitService {
}
async initialize(): Promise<void> {
const gitAvailable = await this.verifyGitAvailability();
const { available: gitAvailable } = isCommandAvailable('git');
if (!gitAvailable) {
throw new Error(
'Checkpointing is enabled, but Git is not installed. Please install Git or disable checkpointing to continue.',
@@ -41,15 +41,6 @@ export class GitService {
}
}
async verifyGitAvailability(): Promise<boolean> {
try {
await spawnAsync('git', ['--version']);
return true;
} catch (_error) {
return false;
}
}
/**
* Creates a hidden git repository in the project root.
* The Git repository is used to support checkpointing.

View File

@@ -447,7 +447,11 @@ describe('loggers', () => {
});
it('should log ripgrep fallback event', () => {
const event = new RipgrepFallbackEvent();
const event = new RipgrepFallbackEvent(
false,
false,
'ripgrep is not available',
);
logRipgrepFallback(mockConfig, event);
@@ -460,13 +464,13 @@ describe('loggers', () => {
'session.id': 'test-session-id',
'user.email': 'test-user@example.com',
'event.name': EVENT_RIPGREP_FALLBACK,
error: undefined,
error: 'ripgrep is not available',
}),
);
});
it('should log ripgrep fallback event with an error', () => {
const event = new RipgrepFallbackEvent('rg not found');
const event = new RipgrepFallbackEvent(false, false, 'rg not found');
logRipgrepFallback(mockConfig, event);

View File

@@ -314,7 +314,7 @@ export function logRipgrepFallback(
config: Config,
event: RipgrepFallbackEvent,
): void {
QwenLogger.getInstance(config)?.logRipgrepFallbackEvent();
QwenLogger.getInstance(config)?.logRipgrepFallbackEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {

View File

@@ -38,6 +38,7 @@ import type {
ModelSlashCommandEvent,
ExtensionDisableEvent,
AuthEvent,
RipgrepFallbackEvent,
} from '../types.js';
import { EndSessionEvent } from '../types.js';
import type {
@@ -778,8 +779,16 @@ export class QwenLogger {
this.flushIfNeeded();
}
logRipgrepFallbackEvent(): void {
const rumEvent = this.createActionEvent('misc', 'ripgrep_fallback', {});
logRipgrepFallbackEvent(event: RipgrepFallbackEvent): void {
const rumEvent = this.createActionEvent('misc', 'ripgrep_fallback', {
snapshots: JSON.stringify({
platform: process.platform,
arch: process.arch,
use_ripgrep: event.use_ripgrep,
use_builtin_ripgrep: event.use_builtin_ripgrep,
error: event.error ?? undefined,
}),
});
this.enqueueLogEvent(rumEvent);
this.flushIfNeeded();

View File

@@ -318,10 +318,20 @@ export class FlashFallbackEvent implements BaseTelemetryEvent {
export class RipgrepFallbackEvent implements BaseTelemetryEvent {
'event.name': 'ripgrep_fallback';
'event.timestamp': string;
use_ripgrep: boolean;
use_builtin_ripgrep: boolean;
error?: string;
constructor(public error?: string) {
constructor(
use_ripgrep: boolean,
use_builtin_ripgrep: boolean,
error?: string,
) {
this['event.name'] = 'ripgrep_fallback';
this['event.timestamp'] = new Date().toISOString();
this.use_ripgrep = use_ripgrep;
this.use_builtin_ripgrep = use_builtin_ripgrep;
this.error = error;
}
}

View File

@@ -18,19 +18,68 @@ import * as glob from 'glob';
vi.mock('glob', { spy: true });
// Mock the child_process module to control grep/git grep behavior
vi.mock('child_process', () => ({
spawn: vi.fn(() => ({
on: (event: string, cb: (...args: unknown[]) => void) => {
if (event === 'error' || event === 'close') {
// Simulate command not found or error for git grep and system grep
// to force it to fall back to JS implementation.
setTimeout(() => cb(1), 0); // cb(1) for error/close
}
},
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
})),
}));
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return {
...actual,
spawn: vi.fn(() => {
// Create a proper mock EventEmitter-like child process
const listeners: Map<
string,
Set<(...args: unknown[]) => void>
> = new Map();
const createStream = () => ({
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
const key = `stream:${event}`;
if (!listeners.has(key)) listeners.set(key, new Set());
listeners.get(key)!.add(cb);
}),
removeListener: vi.fn(
(event: string, cb: (...args: unknown[]) => void) => {
const key = `stream:${event}`;
listeners.get(key)?.delete(cb);
},
),
});
return {
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
const key = `child:${event}`;
if (!listeners.has(key)) listeners.set(key, new Set());
listeners.get(key)!.add(cb);
// Simulate command not found or error for git grep and system grep
// to force it to fall back to JS implementation.
if (event === 'error') {
setTimeout(() => cb(new Error('Command not found')), 0);
} else if (event === 'close') {
setTimeout(() => cb(1), 0); // Exit code 1 for error
}
}),
removeListener: vi.fn(
(event: string, cb: (...args: unknown[]) => void) => {
const key = `child:${event}`;
listeners.get(key)?.delete(cb);
},
),
stdout: createStream(),
stderr: createStream(),
connected: false,
disconnect: vi.fn(),
};
}),
exec: vi.fn(
(
cmd: string,
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => {
// Mock exec to fail for git grep commands
callback(new Error('Command not found'), '', '');
},
),
};
});
describe('GrepTool', () => {
let tempRootDir: string;

View File

@@ -18,6 +18,7 @@ import { isGitRepository } from '../utils/gitUtils.js';
import type { Config } from '../config/config.js';
import type { FileExclusions } from '../utils/ignorePatterns.js';
import { ToolErrorType } from './tool-error.js';
import { isCommandAvailable } from '../utils/shell-utils.js';
// --- Interfaces ---
@@ -195,29 +196,6 @@ class GrepToolInvocation extends BaseToolInvocation<
}
}
/**
* Checks if a command is available in the system's PATH.
* @param {string} command The command name (e.g., 'git', 'grep').
* @returns {Promise<boolean>} True if the command is available, false otherwise.
*/
private isCommandAvailable(command: string): Promise<boolean> {
return new Promise((resolve) => {
const checkCommand = process.platform === 'win32' ? 'where' : 'command';
const checkArgs =
process.platform === 'win32' ? [command] : ['-v', command];
try {
const child = spawn(checkCommand, checkArgs, {
stdio: 'ignore',
shell: process.platform === 'win32',
});
child.on('close', (code) => resolve(code === 0));
child.on('error', () => resolve(false));
} catch {
resolve(false);
}
});
}
/**
* Parses the standard output of grep-like commands (git grep, system grep).
* Expects format: filePath:lineNumber:lineContent
@@ -297,7 +275,7 @@ class GrepToolInvocation extends BaseToolInvocation<
try {
// --- Strategy 1: git grep ---
const isGit = isGitRepository(absolutePath);
const gitAvailable = isGit && (await this.isCommandAvailable('git'));
const gitAvailable = isGit && isCommandAvailable('git').available;
if (gitAvailable) {
strategyUsed = 'git grep';
@@ -350,7 +328,7 @@ class GrepToolInvocation extends BaseToolInvocation<
}
// --- Strategy 2: System grep ---
const grepAvailable = await this.isCommandAvailable('grep');
const { available: grepAvailable } = isCommandAvailable('grep');
if (grepAvailable) {
strategyUsed = 'system grep';
const grepArgs = ['-r', '-n', '-H', '-E'];

View File

@@ -20,14 +20,13 @@ import fs from 'node:fs/promises';
import os, { EOL } from 'node:os';
import type { Config } from '../config/config.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
import { getRipgrepCommand } from '../utils/ripgrepUtils.js';
import { runRipgrep } from '../utils/ripgrepUtils.js';
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
// Mock ripgrepUtils
vi.mock('../utils/ripgrepUtils.js', () => ({
getRipgrepCommand: vi.fn(),
runRipgrep: vi.fn(),
}));
// Mock child_process for ripgrep calls
@@ -37,60 +36,6 @@ vi.mock('child_process', () => ({
const mockSpawn = vi.mocked(spawn);
// Helper function to create mock spawn implementations
function createMockSpawn(
options: {
outputData?: string;
exitCode?: number;
signal?: string;
onCall?: (
command: string,
args: readonly string[],
spawnOptions?: unknown,
) => void;
} = {},
) {
const { outputData, exitCode = 0, signal, onCall } = options;
return (command: string, args: readonly string[], spawnOptions?: unknown) => {
onCall?.(command, args, spawnOptions);
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
// Set up event listeners immediately
setTimeout(() => {
const stdoutDataHandler = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const closeHandler = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (stdoutDataHandler && outputData) {
stdoutDataHandler(Buffer.from(outputData));
}
if (closeHandler) {
closeHandler(exitCode, signal);
}
}, 0);
return mockProcess as unknown as ChildProcess;
};
}
describe('RipGrepTool', () => {
let tempRootDir: string;
let grepTool: RipGrepTool;
@@ -109,7 +54,6 @@ describe('RipGrepTool', () => {
beforeEach(async () => {
vi.clearAllMocks();
(getRipgrepCommand as Mock).mockResolvedValue('/mock/path/to/rg');
mockSpawn.mockReset();
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-'));
fileExclusionsMock = {
@@ -200,12 +144,11 @@ describe('RipGrepTool', () => {
describe('execute', () => {
it('should find matches for a simple pattern in all files', async () => {
mockSpawn.mockImplementationOnce(
createMockSpawn({
outputData: `fileA.txt:1:hello world${EOL}fileA.txt:2:second line with world${EOL}sub/fileC.txt:1:another world in sub dir${EOL}`,
exitCode: 0,
}),
);
(runRipgrep as Mock).mockResolvedValue({
stdout: `fileA.txt:1:hello world${EOL}fileA.txt:2:second line with world${EOL}sub/fileC.txt:1:another world in sub dir${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'world' };
const invocation = grepTool.build(params);
@@ -223,12 +166,11 @@ describe('RipGrepTool', () => {
it('should find matches in a specific path', async () => {
// Setup specific mock for this test - searching in 'sub' should only return matches from that directory
mockSpawn.mockImplementationOnce(
createMockSpawn({
outputData: `fileC.txt:1:another world in sub dir${EOL}`,
exitCode: 0,
}),
);
(runRipgrep as Mock).mockResolvedValue({
stdout: `fileC.txt:1:another world in sub dir${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'world', path: 'sub' };
const invocation = grepTool.build(params);
@@ -243,16 +185,11 @@ describe('RipGrepTool', () => {
});
it('should use target directory when path is not provided', async () => {
mockSpawn.mockImplementationOnce(
createMockSpawn({
outputData: `fileA.txt:1:hello world${EOL}`,
exitCode: 0,
onCall: (_, args) => {
// Should search in the target directory (tempRootDir)
expect(args[args.length - 1]).toBe(tempRootDir);
},
}),
);
(runRipgrep as Mock).mockResolvedValue({
stdout: `fileA.txt:1:hello world${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'world' };
const invocation = grepTool.build(params);
@@ -264,12 +201,11 @@ describe('RipGrepTool', () => {
it('should find matches with a glob filter', async () => {
// Setup specific mock for this test
mockSpawn.mockImplementationOnce(
createMockSpawn({
outputData: `fileB.js:2:function baz() { return "hello"; }${EOL}`,
exitCode: 0,
}),
);
(runRipgrep as Mock).mockResolvedValue({
stdout: `fileB.js:2:function baz() { return "hello"; }${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'hello', glob: '*.js' };
const invocation = grepTool.build(params);
@@ -290,39 +226,10 @@ describe('RipGrepTool', () => {
);
// Setup specific mock for this test - searching for 'hello' in 'sub' with '*.js' filter
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onData = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onData) {
// Only return match from the .js file in sub directory
onData(Buffer.from(`another.js:1:const greeting = "hello";${EOL}`));
}
if (onClose) {
onClose(0);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: `another.js:1:const greeting = "hello";${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = {
@@ -346,15 +253,11 @@ describe('RipGrepTool', () => {
path.join(tempRootDir, '.qwenignore'),
'ignored.txt\n',
);
mockSpawn.mockImplementationOnce(
createMockSpawn({
exitCode: 1,
onCall: (_, args) => {
expect(args).toContain('--ignore-file');
expect(args).toContain(path.join(tempRootDir, '.qwenignore'));
},
}),
);
(runRipgrep as Mock).mockResolvedValue({
stdout: '',
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'secret' };
const invocation = grepTool.build(params);
@@ -375,16 +278,11 @@ describe('RipGrepTool', () => {
}),
});
mockSpawn.mockImplementationOnce(
createMockSpawn({
outputData: `kept.txt:1:keep me${EOL}`,
exitCode: 0,
onCall: (_, args) => {
expect(args).not.toContain('--ignore-file');
expect(args).not.toContain(path.join(tempRootDir, '.qwenignore'));
},
}),
);
(runRipgrep as Mock).mockResolvedValue({
stdout: `kept.txt:1:keep me${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'keep' };
const invocation = grepTool.build(params);
@@ -404,14 +302,11 @@ describe('RipGrepTool', () => {
}),
});
mockSpawn.mockImplementationOnce(
createMockSpawn({
exitCode: 1,
onCall: (_, args) => {
expect(args).toContain('--no-ignore-vcs');
},
}),
);
(runRipgrep as Mock).mockResolvedValue({
stdout: '',
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'ignored' };
const invocation = grepTool.build(params);
@@ -421,12 +316,11 @@ describe('RipGrepTool', () => {
it('should truncate llm content when exceeding maximum length', async () => {
const longMatch = 'fileA.txt:1:' + 'a'.repeat(30_000);
mockSpawn.mockImplementationOnce(
createMockSpawn({
outputData: `${longMatch}${EOL}`,
exitCode: 0,
}),
);
(runRipgrep as Mock).mockResolvedValue({
stdout: `${longMatch}${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'a+' };
const invocation = grepTool.build(params);
@@ -439,11 +333,11 @@ describe('RipGrepTool', () => {
it('should return "No matches found" when pattern does not exist', async () => {
// Setup specific mock for no matches
mockSpawn.mockImplementationOnce(
createMockSpawn({
exitCode: 1, // No matches found
}),
);
(runRipgrep as Mock).mockResolvedValue({
stdout: '',
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'nonexistentpattern' };
const invocation = grepTool.build(params);
@@ -463,39 +357,10 @@ describe('RipGrepTool', () => {
it('should handle regex special characters correctly', async () => {
// Setup specific mock for this test - regex pattern 'foo.*bar' should match 'const foo = "bar";'
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onData = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onData) {
// Return match for the regex pattern
onData(Buffer.from(`fileB.js:1:const foo = "bar";${EOL}`));
}
if (onClose) {
onClose(0);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: `fileB.js:1:const foo = "bar";${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'foo.*bar' }; // Matches 'const foo = "bar";'
@@ -509,43 +374,10 @@ describe('RipGrepTool', () => {
it('should be case-insensitive by default (JS fallback)', async () => {
// Setup specific mock for this test - case insensitive search for 'HELLO'
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onData = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onData) {
// Return case-insensitive matches for 'HELLO'
onData(
Buffer.from(
`fileA.txt:1:hello world${EOL}fileB.js:2:function baz() { return "hello"; }${EOL}`,
),
);
}
if (onClose) {
onClose(0);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: `fileA.txt:1:hello world${EOL}fileB.js:2:function baz() { return "hello"; }${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'HELLO' };
@@ -568,12 +400,11 @@ describe('RipGrepTool', () => {
});
it('should search within a single file when path is a file', async () => {
mockSpawn.mockImplementationOnce(
createMockSpawn({
outputData: `fileA.txt:1:hello world${EOL}fileA.txt:2:second line with world${EOL}`,
exitCode: 0,
}),
);
(runRipgrep as Mock).mockResolvedValue({
stdout: `fileA.txt:1:hello world${EOL}fileA.txt:2:second line with world${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = {
pattern: 'world',
@@ -588,7 +419,11 @@ describe('RipGrepTool', () => {
});
it('should throw an error if ripgrep is not available', async () => {
(getRipgrepCommand as Mock).mockResolvedValue(null);
(runRipgrep as Mock).mockResolvedValue({
stdout: '',
truncated: false,
error: new Error('ripgrep binary not found.'),
});
const params: RipGrepToolParams = { pattern: 'world' };
const invocation = grepTool.build(params);
@@ -612,54 +447,6 @@ describe('RipGrepTool', () => {
const result = await invocation.execute(controller.signal);
expect(result).toBeDefined();
});
it('should abort streaming search when signal is triggered', async () => {
// Setup specific mock for this test - simulate process being killed due to abort
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
// Simulate process being aborted - use setTimeout to ensure handlers are registered first
setTimeout(() => {
const closeHandler = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (closeHandler) {
// Simulate process killed by signal (code is null, signal is SIGTERM)
closeHandler(null, 'SIGTERM');
}
}, 0);
return mockProcess as unknown as ChildProcess;
});
const controller = new AbortController();
const params: RipGrepToolParams = { pattern: 'test' };
const invocation = grepTool.build(params);
// Abort immediately before starting the search
controller.abort();
const result = await invocation.execute(controller.signal);
expect(result.llmContent).toContain(
'Error during grep search operation: ripgrep exited with code null',
);
expect(result.returnDisplay).toContain(
'Error: ripgrep exited with code null',
);
});
});
describe('error handling and edge cases', () => {
@@ -675,32 +462,10 @@ describe('RipGrepTool', () => {
await fs.mkdir(emptyDir);
// Setup specific mock for this test - searching in empty directory should return no matches
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onClose) {
onClose(1);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: '',
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'test', path: 'empty' };
@@ -715,32 +480,10 @@ describe('RipGrepTool', () => {
await fs.writeFile(path.join(tempRootDir, 'empty.txt'), '');
// Setup specific mock for this test - searching for anything in empty files should return no matches
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onClose) {
onClose(1);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: '',
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'anything' };
@@ -758,42 +501,10 @@ describe('RipGrepTool', () => {
);
// Setup specific mock for this test - searching for 'world' should find the file with special characters
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onData = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onData) {
onData(
Buffer.from(
`${specialFileName}:1:hello world with special chars${EOL}`,
),
);
}
if (onClose) {
onClose(0);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: `file with spaces & symbols!.txt:1:hello world with special chars${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'world' };
@@ -813,42 +524,10 @@ describe('RipGrepTool', () => {
);
// Setup specific mock for this test - searching for 'deep' should find the deeply nested file
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onData = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onData) {
onData(
Buffer.from(
`a/b/c/d/e/deep.txt:1:content in deep directory${EOL}`,
),
);
}
if (onClose) {
onClose(0);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: `a/b/c/d/e/deep.txt:1:content in deep directory${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'deep' };
@@ -868,42 +547,10 @@ describe('RipGrepTool', () => {
);
// Setup specific mock for this test - regex pattern should match function declarations
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onData = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onData) {
onData(
Buffer.from(
`code.js:1:function getName() { return "test"; }${EOL}`,
),
);
}
if (onClose) {
onClose(0);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: `code.js:1:function getName() { return "test"; }${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'function\\s+\\w+\\s*\\(' };
@@ -921,42 +568,10 @@ describe('RipGrepTool', () => {
);
// Setup specific mock for this test - case insensitive search should match all variants
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onData = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onData) {
onData(
Buffer.from(
`case.txt:1:Hello World${EOL}case.txt:2:hello world${EOL}case.txt:3:HELLO WORLD${EOL}`,
),
);
}
if (onClose) {
onClose(0);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: `case.txt:1:Hello World${EOL}case.txt:2:hello world${EOL}case.txt:3:HELLO WORLD${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: 'hello' };
@@ -975,38 +590,10 @@ describe('RipGrepTool', () => {
);
// Setup specific mock for this test - escaped regex pattern should match price format
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onData = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onData) {
onData(Buffer.from(`special.txt:1:Price: $19.99${EOL}`));
}
if (onClose) {
onClose(0);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: `special.txt:1:Price: $19.99${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = { pattern: '\\$\\d+\\.\\d+' };
@@ -1032,42 +619,10 @@ describe('RipGrepTool', () => {
await fs.writeFile(path.join(tempRootDir, 'test.txt'), 'text content');
// Setup specific mock for this test - glob pattern should filter to only ts/tsx files
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onData = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onData) {
onData(
Buffer.from(
`test.ts:1:typescript content${EOL}test.tsx:1:tsx content${EOL}`,
),
);
}
if (onClose) {
onClose(0);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: `test.ts:1:typescript content${EOL}test.tsx:1:tsx content${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = {
@@ -1092,38 +647,10 @@ describe('RipGrepTool', () => {
await fs.writeFile(path.join(tempRootDir, 'other.ts'), 'other code');
// Setup specific mock for this test - glob pattern should filter to only src/** files
mockSpawn.mockImplementationOnce(() => {
const mockProcess = {
stdout: {
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
on: vi.fn(),
removeListener: vi.fn(),
},
on: vi.fn(),
removeListener: vi.fn(),
kill: vi.fn(),
};
setTimeout(() => {
const onData = mockProcess.stdout.on.mock.calls.find(
(call) => call[0] === 'data',
)?.[1];
const onClose = mockProcess.on.mock.calls.find(
(call) => call[0] === 'close',
)?.[1];
if (onData) {
onData(Buffer.from(`src/main.ts:1:source code${EOL}`));
}
if (onClose) {
onClose(0);
}
}, 0);
return mockProcess as unknown as ChildProcess;
(runRipgrep as Mock).mockResolvedValue({
stdout: `src/main.ts:1:source code${EOL}`,
truncated: false,
error: undefined,
});
const params: RipGrepToolParams = {

View File

@@ -6,14 +6,13 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolNames } from './tool-names.js';
import { resolveAndValidatePath } from '../utils/paths.js';
import { getErrorMessage } from '../utils/errors.js';
import type { Config } from '../config/config.js';
import { getRipgrepCommand } from '../utils/ripgrepUtils.js';
import { runRipgrep } from '../utils/ripgrepUtils.js';
import { SchemaValidator } from '../utils/schemaValidator.js';
import type { FileFilteringOptions } from '../config/constants.js';
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
@@ -208,60 +207,12 @@ class GrepToolInvocation extends BaseToolInvocation<
rgArgs.push('--threads', '4');
rgArgs.push(absolutePath);
try {
const rgCommand = await getRipgrepCommand(
this.config.getUseBuiltinRipgrep(),
);
if (!rgCommand) {
throw new Error('ripgrep binary not found.');
}
const output = await new Promise<string>((resolve, reject) => {
const child = spawn(rgCommand, rgArgs, {
windowsHide: true,
});
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
const cleanup = () => {
if (options.signal.aborted) {
child.kill();
}
};
options.signal.addEventListener('abort', cleanup, { once: true });
child.stdout.on('data', (chunk) => stdoutChunks.push(chunk));
child.stderr.on('data', (chunk) => stderrChunks.push(chunk));
child.on('error', (err) => {
options.signal.removeEventListener('abort', cleanup);
reject(new Error(`failed to start ripgrep: ${err.message}.`));
});
child.on('close', (code) => {
options.signal.removeEventListener('abort', cleanup);
const stdoutData = Buffer.concat(stdoutChunks).toString('utf8');
const stderrData = Buffer.concat(stderrChunks).toString('utf8');
if (code === 0) {
resolve(stdoutData);
} else if (code === 1) {
resolve(''); // No matches found
} else {
reject(
new Error(`ripgrep exited with code ${code}: ${stderrData}`),
);
}
});
});
return output;
} catch (error: unknown) {
console.error(`Ripgrep failed: ${getErrorMessage(error)}`);
throw error;
const result = await runRipgrep(rgArgs, options.signal);
if (result.error && !result.stdout) {
throw result.error;
}
return result.stdout;
}
private getFileFilteringOptions(): FileFilteringOptions {

View File

@@ -4,30 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import {
canUseRipgrep,
getRipgrepCommand,
getBuiltinRipgrep,
} from './ripgrepUtils.js';
import { fileExists } from './fileUtils.js';
import { describe, it, expect } from 'vitest';
import { getBuiltinRipgrep } from './ripgrepUtils.js';
import path from 'node:path';
// Mock fileUtils
vi.mock('./fileUtils.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./fileUtils.js')>();
return {
...actual,
fileExists: vi.fn(),
};
});
describe('ripgrepUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getBulltinRipgrepPath', () => {
describe('getBuiltinRipgrep', () => {
it('should return path with .exe extension on Windows', () => {
const originalPlatform = process.platform;
const originalArch = process.arch;
@@ -150,99 +132,4 @@ describe('ripgrepUtils', () => {
Object.defineProperty(process, 'arch', { value: originalArch });
});
});
describe('canUseRipgrep', () => {
it('should return true if ripgrep binary exists (builtin)', async () => {
(fileExists as Mock).mockResolvedValue(true);
const result = await canUseRipgrep(true);
expect(result).toBe(true);
expect(fileExists).toHaveBeenCalledOnce();
});
it('should return true if ripgrep binary exists (default)', async () => {
(fileExists as Mock).mockResolvedValue(true);
const result = await canUseRipgrep();
expect(result).toBe(true);
expect(fileExists).toHaveBeenCalledOnce();
});
});
describe('ensureRipgrepPath', () => {
it('should return bundled ripgrep path if binary exists (useBuiltin=true)', async () => {
(fileExists as Mock).mockResolvedValue(true);
const rgPath = await getRipgrepCommand(true);
expect(rgPath).toBeDefined();
expect(rgPath).toContain('rg');
expect(rgPath).not.toBe('rg'); // Should be full path, not just 'rg'
expect(fileExists).toHaveBeenCalledOnce();
expect(fileExists).toHaveBeenCalledWith(rgPath);
});
it('should return bundled ripgrep path if binary exists (default)', async () => {
(fileExists as Mock).mockResolvedValue(true);
const rgPath = await getRipgrepCommand();
expect(rgPath).toBeDefined();
expect(rgPath).toContain('rg');
expect(fileExists).toHaveBeenCalledOnce();
});
it('should fall back to system rg if bundled binary does not exist', async () => {
(fileExists as Mock).mockResolvedValue(false);
// When useBuiltin is true but bundled binary doesn't exist,
// it should fall back to checking system rg
// The test result depends on whether system rg is actually available
const rgPath = await getRipgrepCommand(true);
expect(fileExists).toHaveBeenCalledOnce();
// If system rg is available, it should return 'rg' (or 'rg.exe' on Windows)
// This test will pass if system ripgrep is installed
expect(rgPath).toBeDefined();
});
it('should use system rg when useBuiltin=false', async () => {
// When useBuiltin is false, should skip bundled check and go straight to system rg
const rgPath = await getRipgrepCommand(false);
// Should not check for bundled binary
expect(fileExists).not.toHaveBeenCalled();
// If system rg is available, it should return 'rg' (or 'rg.exe' on Windows)
expect(rgPath).toBeDefined();
});
it('should throw error if neither bundled nor system ripgrep is available', async () => {
// This test only makes sense in an environment where system rg is not installed
// We'll skip this test in CI/local environments where rg might be available
// Instead, we test the error message format
const originalPlatform = process.platform;
// Use an unsupported platform to trigger the error path
Object.defineProperty(process, 'platform', { value: 'freebsd' });
try {
await getRipgrepCommand();
// If we get here without error, system rg was available, which is fine
} catch (error) {
expect(error).toBeInstanceOf(Error);
const errorMessage = (error as Error).message;
// Should contain helpful error information
expect(
errorMessage.includes('Ripgrep binary not found') ||
errorMessage.includes('Failed to locate ripgrep') ||
errorMessage.includes('Unsupported platform'),
).toBe(true);
}
// Restore original value
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
});
});

View File

@@ -6,7 +6,53 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFile } from 'node:child_process';
import { fileExists } from './fileUtils.js';
import { execCommand, isCommandAvailable } from './shell-utils.js';
const RIPGREP_COMMAND = 'rg';
const RIPGREP_BUFFER_LIMIT = 20_000_000; // Keep buffers aligned with the original bundle.
const RIPGREP_TEST_TIMEOUT_MS = 5_000;
const RIPGREP_RUN_TIMEOUT_MS = 10_000;
const RIPGREP_WSL_TIMEOUT_MS = 60_000;
type RipgrepMode = 'builtin' | 'system';
interface RipgrepSelection {
mode: RipgrepMode;
command: string;
}
interface RipgrepHealth {
working: boolean;
lastTested: number;
selection: RipgrepSelection;
}
export interface RipgrepRunResult {
/**
* The stdout output from ripgrep
*/
stdout: string;
/**
* Whether the results were truncated due to buffer overflow or signal termination
*/
truncated: boolean;
/**
* Any error that occurred during execution (non-fatal errors like no matches won't populate this)
*/
error?: Error;
}
let cachedSelection: RipgrepSelection | null = null;
let cachedHealth: RipgrepHealth | null = null;
let macSigningAttempted = false;
function wslTimeout(): number {
return process.platform === 'linux' && process.env['WSL_INTEROP']
? RIPGREP_WSL_TIMEOUT_MS
: RIPGREP_RUN_TIMEOUT_MS;
}
// Get the directory of the current module
const __filename = fileURLToPath(import.meta.url);
@@ -88,59 +134,201 @@ export function getBuiltinRipgrep(): string | null {
return vendorPath;
}
/**
* Checks if system ripgrep is available and returns the command to use
* @returns The ripgrep command ('rg' or 'rg.exe') if available, or null if not found
*/
export async function getSystemRipgrep(): Promise<string | null> {
try {
const { spawn } = await import('node:child_process');
const rgCommand = process.platform === 'win32' ? 'rg.exe' : 'rg';
const isAvailable = await new Promise<boolean>((resolve) => {
const proc = spawn(rgCommand, ['--version']);
proc.on('error', () => resolve(false));
proc.on('exit', (code) => resolve(code === 0));
});
return isAvailable ? rgCommand : null;
} catch (_error) {
return null;
}
}
/**
* Checks if ripgrep binary exists and returns its path
* @param useBuiltin If true, tries bundled ripgrep first, then falls back to system ripgrep.
* If false, only checks for system ripgrep.
* @returns The path to ripgrep binary ('rg' or 'rg.exe' for system ripgrep, or full path for bundled), or null if not available
* @throws {Error} If an error occurs while resolving the ripgrep binary.
*/
export async function getRipgrepCommand(
export async function resolveRipgrep(
useBuiltin: boolean = true,
): Promise<string | null> {
try {
if (useBuiltin) {
// Try bundled ripgrep first
const rgPath = getBuiltinRipgrep();
if (rgPath && (await fileExists(rgPath))) {
return rgPath;
}
// Fallback to system rg if bundled binary is not available
}
): Promise<RipgrepSelection | null> {
if (cachedSelection) return cachedSelection;
// Check for system ripgrep
return await getSystemRipgrep();
} catch (_error) {
return null;
if (useBuiltin) {
// Try bundled ripgrep first
const rgPath = getBuiltinRipgrep();
if (rgPath && (await fileExists(rgPath))) {
cachedSelection = { mode: 'builtin', command: rgPath };
return cachedSelection;
}
// Fallback to system rg if bundled binary is not available
}
const { available, error } = isCommandAvailable(RIPGREP_COMMAND);
if (available) {
cachedSelection = { mode: 'system', command: RIPGREP_COMMAND };
return cachedSelection;
}
if (error) {
throw error;
}
return null;
}
/**
* Ensures that ripgrep is healthy by checking its version.
* @param selection The ripgrep selection to check.
* @throws {Error} If ripgrep is not found or is not healthy.
*/
export async function ensureRipgrepHealthy(
selection: RipgrepSelection,
): Promise<void> {
if (
cachedHealth &&
cachedHealth.selection.command === selection.command &&
cachedHealth.working
)
return;
try {
const { stdout, code } = await execCommand(
selection.command,
['--version'],
{
timeout: RIPGREP_TEST_TIMEOUT_MS,
},
);
const working = code === 0 && stdout.startsWith('ripgrep');
cachedHealth = { working, lastTested: Date.now(), selection };
} catch (error) {
cachedHealth = { working: false, lastTested: Date.now(), selection };
throw error;
}
}
export async function ensureMacBinarySigned(
selection: RipgrepSelection,
): Promise<void> {
if (process.platform !== 'darwin') return;
if (macSigningAttempted) return;
macSigningAttempted = true;
if (selection.mode !== 'builtin') return;
const binaryPath = selection.command;
const inspect = await execCommand('codesign', ['-vv', '-d', binaryPath], {
preserveOutputOnError: false,
});
const alreadySigned =
inspect.stdout
?.split('\n')
.some((line) => line.includes('linker-signed')) ?? false;
if (!alreadySigned) return;
await execCommand('codesign', [
'--sign',
'-',
'--force',
'--preserve-metadata=entitlements,requirements,flags,runtime',
binaryPath,
]);
await execCommand('xattr', ['-d', 'com.apple.quarantine', binaryPath]);
}
/**
* Checks if ripgrep binary is available
* @param useBuiltin If true, tries bundled ripgrep first, then falls back to system ripgrep.
* If false, only checks for system ripgrep.
* @returns True if ripgrep is available, false otherwise.
* @throws {Error} If an error occurs while resolving the ripgrep binary.
*/
export async function canUseRipgrep(
useBuiltin: boolean = true,
): Promise<boolean> {
const rgPath = await getRipgrepCommand(useBuiltin);
return rgPath !== null;
const selection = await resolveRipgrep(useBuiltin);
if (!selection) {
return false;
}
await ensureRipgrepHealthy(selection);
return true;
}
/**
* Runs ripgrep with the provided arguments
* @param args The arguments to pass to ripgrep
* @param signal The signal to abort the ripgrep process
* @returns The result of running ripgrep
* @throws {Error} If an error occurs while running ripgrep.
*/
export async function runRipgrep(
args: string[],
signal?: AbortSignal,
): Promise<RipgrepRunResult> {
const selection = await resolveRipgrep();
if (!selection) {
throw new Error('ripgrep not found.');
}
await ensureRipgrepHealthy(selection);
return new Promise<RipgrepRunResult>((resolve) => {
const child = execFile(
selection.command,
args,
{
maxBuffer: RIPGREP_BUFFER_LIMIT,
timeout: wslTimeout(),
signal,
},
(error, stdout = '', stderr = '') => {
if (!error) {
// Success case
resolve({
stdout,
truncated: false,
});
return;
}
// Exit code 1 = no matches found (not an error)
// The error.code from execFile can be string | number | undefined | null
const errorCode = (
error as Error & { code?: string | number | undefined | null }
).code;
if (errorCode === 1) {
resolve({ stdout: '', truncated: false });
return;
}
// Detect various error conditions
const wasKilled =
error.signal === 'SIGTERM' || error.name === 'AbortError';
const overflow = errorCode === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER';
const syntaxError = errorCode === 2;
const truncated = wasKilled || overflow;
let partialOutput = stdout;
// If killed or overflow with partial output, remove the last potentially incomplete line
if (truncated && partialOutput.length > 0) {
const lines = partialOutput.split('\n');
if (lines.length > 0) {
lines.pop();
partialOutput = lines.join('\n');
}
}
// Log warnings for abnormal exits (except syntax errors)
if (!syntaxError && truncated) {
console.warn(
`ripgrep exited abnormally (signal=${error.signal} code=${error.code}) with stderr:\n${stderr.trim() || '(empty)'}`,
);
}
resolve({
stdout: partialOutput,
truncated,
error: error instanceof Error ? error : undefined,
});
},
);
// Handle spawn errors
child.on('error', (err) =>
resolve({ stdout: '', truncated: false, error: err }),
);
});
}

View File

@@ -10,7 +10,12 @@ import os from 'node:os';
import { quote } from 'shell-quote';
import { doesToolInvocationMatch } from './tool-utils.js';
import { isShellCommandReadOnly } from './shellReadOnlyChecker.js';
import { spawn, type SpawnOptionsWithoutStdio } from 'node:child_process';
import {
execFile,
execFileSync,
type ExecFileOptions,
} from 'node:child_process';
import { accessSync, constants as fsConstants } from 'node:fs';
const SHELL_TOOL_NAMES = ['run_shell_command', 'ShellTool'];
@@ -455,46 +460,101 @@ export function checkCommandPermissions(
}
/**
* Determines whether a given shell command is allowed to execute based on
* the tool's configuration including allowlists and blocklists.
* Executes a command with the given arguments without using a shell.
*
* This function operates in "default allow" mode. It is a wrapper around
* `checkCommandPermissions`.
* This is a wrapper around Node.js's `execFile`, which spawns a process
* directly without invoking a shell, making it safer than `exec`.
* It's suitable for short-running commands with limited output.
*
* @param command The shell command string to validate.
* @param config The application configuration.
* @returns An object with 'allowed' boolean and optional 'reason' string if not allowed.
* @param command The command to execute (e.g., 'git', 'osascript').
* @param args Array of arguments to pass to the command.
* @param options Optional spawn options including:
* - preserveOutputOnError: If false (default), rejects on error.
* If true, resolves with output and error code.
* - Other standard spawn options (e.g., cwd, env).
* @returns A promise that resolves with stdout, stderr strings, and exit code.
* @throws Rejects with an error if the command fails (unless preserveOutputOnError is true).
*/
export const spawnAsync = (
export function execCommand(
command: string,
args: string[],
options?: SpawnOptionsWithoutStdio,
): Promise<{ stdout: string; stderr: string }> =>
new Promise((resolve, reject) => {
const child = spawn(command, args, options);
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
resolve({ stdout, stderr });
} else {
reject(new Error(`Command failed with exit code ${code}:\n${stderr}`));
}
});
child.on('error', (err) => {
reject(err);
});
options: { preserveOutputOnError?: boolean } & ExecFileOptions = {},
): Promise<{ stdout: string; stderr: string; code: number }> {
return new Promise((resolve, reject) => {
const child = execFile(
command,
args,
{ encoding: 'utf8', ...options },
(error, stdout, stderr) => {
if (error) {
if (!options.preserveOutputOnError) {
reject(error);
} else {
resolve({
stdout: stdout ?? '',
stderr: stderr ?? '',
code: typeof error.code === 'number' ? error.code : 1,
});
}
return;
}
resolve({ stdout: stdout ?? '', stderr: stderr ?? '', code: 0 });
},
);
child.on('error', reject);
});
}
/**
* Resolves the path of a command in the system's PATH.
* @param {string} command The command name (e.g., 'git', 'grep').
* @returns {path: string | null; error?: Error} The path of the command, or null if it is not found and any error that occurred.
*/
export function resolveCommandPath(command: string): {
path: string | null;
error?: Error;
} {
try {
const isWin = process.platform === 'win32';
const checkCommand = isWin ? 'where' : 'command';
const checkArgs = isWin ? [command] : ['-v', command];
let result: string | null = null;
try {
result = execFileSync(checkCommand, checkArgs, {
encoding: 'utf8',
shell: isWin,
}).trim();
} catch {
console.warn(`Command ${checkCommand} not found`);
}
if (!result) return { path: null, error: undefined };
if (!isWin) {
accessSync(result, fsConstants.X_OK);
}
return { path: result, error: undefined };
} catch (error) {
return {
path: null,
error: error instanceof Error ? error : new Error(String(error)),
};
}
}
/**
* Checks if a command is available in the system's PATH.
* @param {string} command The command name (e.g., 'git', 'grep').
* @returns {available: boolean; error?: Error} The availability of the command and any error that occurred.
*/
export function isCommandAvailable(command: string): {
available: boolean;
error?: Error;
} {
const { path, error } = resolveCommandPath(command);
return { available: path !== null, error };
}
export function isCommandAllowed(
command: string,

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.2.2",
"version": "0.2.3",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",

View File

@@ -2,7 +2,7 @@
"name": "qwen-code-vscode-ide-companion",
"displayName": "Qwen Code Companion",
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
"version": "0.2.2",
"version": "0.2.3",
"publisher": "qwenlm",
"icon": "assets/icon.png",
"repository": {