Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ tmp/
# code graph skills
.venv
.codegraph
.zvec-grep/
.qwen/computer-use/installed.json
# Auto-generated computer-use marker can also appear under nested packages.
**/.qwen/computer-use/
Expand Down
49 changes: 48 additions & 1 deletion packages/cli/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
Storage,
} from '@qwen-code/qwen-code-core';
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
import type { Settings } from './settings.js';
import { LoadedSettings, SettingScope, type Settings } from './settings.js';
import * as ServerConfig from '@qwen-code/qwen-code-core';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { resetMcpApprovalsForTesting } from './mcpApprovals.js';
Expand Down Expand Up @@ -2006,6 +2006,53 @@ describe('mergeExcludeTools', () => {
expect(config.getPermissionsDeny()).toContain('tool_search');
});

it('should leave zvec_grep disabled by default', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = {};
const config = await loadCliConfig(settings, argv, undefined, []);
expect(config.isZvecGrepEnabled()).toBe(false);
});

it('should enable zvec_grep when tools.zvecGrep.enabled is true', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = {
tools: { zvecGrep: { enabled: true } },
};
const config = await loadCliConfig(settings, argv, undefined, []);
expect(config.isZvecGrepEnabled()).toBe(true);
});

it('should persist the zvec_grep workspace opt-out', async () => {
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
const setValue = vi
.spyOn(LoadedSettings.prototype, 'setValue')
.mockImplementation(() => {});
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = {
tools: { zvecGrep: { enabled: true } },
};
try {
const config = await loadCliConfig(settings, argv, undefined, []);

expect(config.canDisableZvecGrepForWorkspace()).toBe(true);
await config.disableZvecGrepForWorkspace();

expect(setValue).toHaveBeenCalledWith(
SettingScope.Workspace,
'tools.zvecGrep.enabled',
false,
);
} finally {
setValue.mockRestore();
}
});

it('should auto-disable tool_search for deepseek-v4 models', async () => {
process.argv = ['node', 'script.js', '--model', 'deepseek-v4-flash'];
const argv = await parseArguments();
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2014,6 +2014,16 @@ export async function loadCliConfig(
disabledSlashCommands.length > 0 ? disabledSlashCommands : undefined,
disabledSkillNamesProvider:
bareMode || safeMode ? undefined : disabledSkillNamesProvider,
zvecGrepEnabled:
bareMode || safeMode ? false : settings.tools?.zvecGrep?.enabled === true,
onDisableZvecGrepForWorkspace: async () => {
const currentSettings = loadSettings(cwd);
currentSettings.setValue(
SettingScope.Workspace,
'tools.zvecGrep.enabled',
false,
);
},
disabledTools: disabledTools.length > 0 ? disabledTools : undefined,
visibleTools: visibleTools.length > 0 ? visibleTools : undefined,
// New unified permissions (PermissionManager source of truth).
Expand Down
21 changes: 21 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2224,6 +2224,27 @@ const SETTINGS_SCHEMA = {
},
},
},
zvecGrep: {
type: 'object',
label: 'Zvec Grep',
category: 'Tools',
requiresRestart: true,
default: {},
description: 'Settings for the zvec-grep built-in search tool.',
showInDialog: false,
properties: {
enabled: {
type: 'boolean',
label: 'Enable Zvec Grep',
category: 'Tools',
requiresRestart: true,
default: false,
description:
'When enabled, registers the zvec_grep built-in tool. Disabled by default.',
showInDialog: false,
},
},
},
shell: {
type: 'object',
label: 'Shell',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ export default {
'toolDisplayName.WriteFile': 'toolDisplayName.WriteFile',
'toolDisplayName.ReadFile': 'toolDisplayName.ReadFile',
'toolDisplayName.Grep': 'toolDisplayName.Grep',
'toolDisplayName.ZvecGrep': 'toolDisplayName.ZvecGrep',
'toolDisplayName.Glob': 'toolDisplayName.Glob',
'toolDisplayName.Shell': 'toolDisplayName.Shell',
'toolDisplayName.Shell Command': 'toolDisplayName.Shell Command',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/zh-TW.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ export default {
'toolDisplayName.WriteFile': '寫入檔案',
'toolDisplayName.ReadFile': '讀取檔案',
'toolDisplayName.Grep': 'Grep',
'toolDisplayName.ZvecGrep': '語義搜尋',
'toolDisplayName.Glob': 'Glob',
'toolDisplayName.Shell': '運行命令',
'toolDisplayName.Shell Command': 'Shell 命令',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/zh.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ export default {
'toolDisplayName.WriteFile': '写入文件',
'toolDisplayName.ReadFile': '读取文件',
'toolDisplayName.Grep': 'Grep',
'toolDisplayName.ZvecGrep': '语义搜索',
'toolDisplayName.Glob': 'Glob',
'toolDisplayName.Shell': '运行命令',
'toolDisplayName.Shell Command': 'Shell 命令',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,22 @@ describe('<AskUserQuestionDialog />', () => {
expect(lastFrame()).toContain('Type something...');
});

it('hides custom input when the question only supports fixed choices', () => {
const details = createConfirmationDetails({
questions: [createSingleQuestion({ allowCustomInput: false })],
});
const onConfirm = vi.fn();

const { lastFrame } = renderWithProviders(
<AskUserQuestionDialog
confirmationDetails={details}
onConfirm={onConfirm}
/>,
);

expect(lastFrame()).not.toContain('Type something...');
});

it('renders help text for single select', () => {
const details = createConfirmationDetails();
const onConfirm = vi.fn();
Expand Down
17 changes: 14 additions & 3 deletions packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,17 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
? null
: confirmationDetails.questions[currentQuestionIndex];
const isMultiSelect = currentQuestion?.multiSelect ?? false;
const allowCustomInput = currentQuestion?.allowCustomInput !== false;
// Options + custom input ("Other")
const totalOptions = currentQuestion ? currentQuestion.options.length + 1 : 2;
const totalOptions = currentQuestion
? currentQuestion.options.length + (allowCustomInput ? 1 : 0)
: 2;

// Check if the custom input option is selected
const isCustomInputSelected =
!isSubmitTab &&
currentQuestion &&
allowCustomInput &&
selectedIndex === currentQuestion.options.length;

const getCustomInputValue = (idx: number) =>
Expand All @@ -74,6 +78,7 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
const isCustomInputAnswer =
!isSubmitTab &&
currentQuestion &&
allowCustomInput &&
!isMultiSelect &&
selectedOptions[currentQuestionIndex] !== undefined &&
!currentQuestion.options.some(
Expand Down Expand Up @@ -277,7 +282,10 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
// Handle multi-select: Enter advances to next question / submits
if (isMultiSelect && currentQuestion) {
// Custom input is handled by TextInput's onSubmit
if (selectedIndex === currentQuestion.options.length) {
if (
allowCustomInput &&
selectedIndex === currentQuestion.options.length
) {
return;
}
handleMultiSelectSubmit();
Expand Down Expand Up @@ -469,7 +477,10 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
})}

{/* Type something option/input */}
<Box flexDirection="column">
<Box
flexDirection="column"
display={allowCustomInput ? 'flex' : 'none'}
>
{isCustomInputSelected ? (
// Inline TextInput replaces the option text
<Box>
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6295,6 +6295,34 @@ describe('setApprovalMode with folder trust', () => {
vi.clearAllMocks();
});

it('should not register zvec-grep tool by default', async () => {
const config = new Config({ ...baseParams, useRipgrep: false });
await config.initialize();

const calls = (ToolRegistry.prototype.registerFactory as Mock).mock.calls;
const zvecGrepRegistrations = calls.filter(
(call) => call[0] === ToolNames.ZVEC_GREP,
);

expect(zvecGrepRegistrations.length).toBe(0);
});

it('should register zvec-grep tool when enabled', async () => {
const config = new Config({
...baseParams,
useRipgrep: false,
zvecGrepEnabled: true,
});
await config.initialize();

const calls = (ToolRegistry.prototype.registerFactory as Mock).mock.calls;
const zvecGrepRegistrations = calls.filter(
(call) => call[0] === ToolNames.ZVEC_GREP,
);

expect(zvecGrepRegistrations.length).toBe(1);
});

it('should register grep tool when useRipgrep is true and it is available', async () => {
(canUseRipgrep as Mock).mockResolvedValue(true);
const config = new Config({ ...baseParams, useRipgrep: true });
Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,9 @@ export interface ConfigParameters {
* Names returned must be lower-cased; consumers compare case-insensitively.
*/
disabledSkillNamesProvider?: () => ReadonlySet<string>;
zvecGrepEnabled?: boolean;
/** Persists a workspace-scoped opt-out selected from zvec-grep setup. */
onDisableZvecGrepForWorkspace?: () => Promise<void>;
/**
* Tool names hidden from the registry at construction time. Unlike
* `permissions.deny` (which keeps the tool registered and rejects
Expand Down Expand Up @@ -1639,6 +1642,7 @@ export class Config {
private readonly disabledSkillNamesProvider:
| (() => ReadonlySet<string>)
| null;
private readonly zvecGrepEnabled: boolean;
// `disabledTools` is set at construction
// time but can be re-synced by the daemon mutation surface
// (`setWorkspaceToolEnabled` propagates through ACP) so a subsequent
Expand Down Expand Up @@ -1808,6 +1812,7 @@ export class Config {
ruleType: 'allow' | 'ask' | 'deny',
rule: string,
) => Promise<void>;
private readonly onDisableZvecGrepForWorkspaceCallback?: () => Promise<void>;
private initialized: boolean = false;
storage: Storage;
private runtimeStatusWrite: Promise<void> = Promise.resolve();
Expand Down Expand Up @@ -1901,6 +1906,7 @@ export class Config {
...(params.disabledSlashCommands ?? []),
]);
this.disabledSkillNamesProvider = params.disabledSkillNamesProvider ?? null;
this.zvecGrepEnabled = params.zvecGrepEnabled ?? false;
this.disabledTools = new Set(params.disabledTools ?? []);
this.visibleTools = new Set(
(params.visibleTools ?? []).filter(
Expand Down Expand Up @@ -2057,6 +2063,8 @@ export class Config {
this.addLegacyPlanLocationWarning();
this.allowedHttpHookUrls = params.allowedHttpHookUrls ?? [];
this.onPersistPermissionRuleCallback = params.onPersistPermissionRule;
this.onDisableZvecGrepForWorkspaceCallback =
params.onDisableZvecGrepForWorkspace;

// (web search removed)
this.useRipgrep = params.useRipgrep ?? true;
Expand Down Expand Up @@ -4258,6 +4266,18 @@ export class Config {
return this.disabledSkillNamesProvider?.() ?? EMPTY_DISABLED_SKILL_NAMES;
}

isZvecGrepEnabled(): boolean {
return this.zvecGrepEnabled;
}

canDisableZvecGrepForWorkspace(): boolean {
return this.onDisableZvecGrepForWorkspaceCallback !== undefined;
}

async disableZvecGrepForWorkspace(): Promise<void> {
await this.onDisableZvecGrepForWorkspaceCallback?.();
}

/**
* Returns the read-only set of tool names hidden from this Config's
* ToolRegistry. Consulted by `ToolRegistry.registerTool` and
Expand Down Expand Up @@ -6514,6 +6534,13 @@ export class Config {
return new ReadFileTool(this);
});

if (this.isZvecGrepEnabled()) {
await registerLazy(ToolNames.ZVEC_GREP, async () => {
const { ZvecGrepTool } = await import('../tools/zvec-grep.js');
return new ZvecGrepTool(this);
});
}

// --- Grep / RipGrep (conditional) ---
if (this.getUseRipgrep()) {
let useRipgrep = false;
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/core/coreToolScheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12760,6 +12760,19 @@ describe('extractToolFilePaths', () => {
).toEqual(['packages/core', 'packages/core/**/*.ts']);
});

it('extracts zvec_grep paths and globs as path-shaped file filters', () => {
expect(
extractToolFilePaths('zvec_grep', {
operation: 'rg',
query: 'validate',
path: 'packages/core',
paths: ['src', 'include'],
glob: '**/*.{h,cc}',
exclude: ['thirdparty/**'],
}),
).toEqual(['packages/core', 'src', 'include', 'packages/core/**/*.{h,cc}']);
});

it('decodes file:// URIs for lsp via fileURLToPath', () => {
// Regression: LSP `filePath` is allowed to be a `file://` URI.
// Forwarding the URI as-is to the activation registry would never
Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/core/coreToolScheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,7 @@ const FS_PATH_TOOL_NAMES: ReadonlySet<string> = new Set<string>([
ToolNames.EDIT,
ToolNames.WRITE_FILE,
ToolNames.GREP,
ToolNames.ZVEC_GREP,
ToolNames.GLOB,
ToolNames.LS,
ToolNames.LSP,
Expand Down Expand Up @@ -662,6 +663,27 @@ export function extractToolFilePaths(
return out;
}

case ToolNames.ZVEC_GREP: {
const pathField = obj['path'];
const pathsField = obj['paths'];
const globField = obj['glob'];
push(pathField);
if (Array.isArray(pathsField)) {
for (const item of pathsField) {
push(item);
}
}
if (typeof globField === 'string' && globField.length > 0) {
push(
joinSearchRootAndGlob(
typeof pathField === 'string' ? pathField : undefined,
globField,
),
);
}
return out;
}

case ToolNames.LS:
push(obj['path']);
return out;
Expand Down
Loading
Loading