Merge branch and resolve conflicts

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
tanzhenxin 2026-02-05 13:41:37 +08:00
commit ff0ba0cc4e
69 changed files with 4460 additions and 1023 deletions

View file

@ -12,10 +12,98 @@ import { ACP_ERROR_CODES } from '../errorCodes.js';
const createFallback = (): FileSystemService => ({
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
detectFileBOM: vi.fn().mockResolvedValue(false),
findFiles: vi.fn().mockReturnValue([]),
});
describe('AcpFileSystemService', () => {
describe('detectFileBOM', () => {
it('detects BOM through ACP client when content starts with U+FEFF', async () => {
const client = {
readTextFile: vi
.fn()
.mockResolvedValue({ content: '\ufeff// BOM file' }),
} as unknown as import('../acp.js').Client;
const svc = new AcpFileSystemService(
client,
'session-1',
{ readTextFile: true, writeTextFile: true },
createFallback(),
);
const result = await svc.detectFileBOM('/test/file.txt');
expect(result).toBe(true);
expect(client.readTextFile).toHaveBeenCalledWith({
path: '/test/file.txt',
sessionId: 'session-1',
line: null,
limit: 1,
});
});
it('detects no BOM through ACP client when content does not start with U+FEFF', async () => {
const client = {
readTextFile: vi.fn().mockResolvedValue({ content: '// No BOM file' }),
} as unknown as import('../acp.js').Client;
const svc = new AcpFileSystemService(
client,
'session-2',
{ readTextFile: true, writeTextFile: true },
createFallback(),
);
const result = await svc.detectFileBOM('/test/file.txt');
expect(result).toBe(false);
});
it('falls back to local filesystem when ACP client fails', async () => {
const client = {
readTextFile: vi.fn().mockRejectedValue(new Error('Network error')),
} as unknown as import('../acp.js').Client;
const fallback = createFallback();
(fallback.detectFileBOM as ReturnType<typeof vi.fn>).mockResolvedValue(
true,
);
const svc = new AcpFileSystemService(
client,
'session-3',
{ readTextFile: true, writeTextFile: true },
fallback,
);
const result = await svc.detectFileBOM('/test/file.txt');
expect(result).toBe(true);
expect(fallback.detectFileBOM).toHaveBeenCalledWith('/test/file.txt');
});
it('falls back to local filesystem when readTextFile capability is disabled', async () => {
const client = {
readTextFile: vi.fn(),
} as unknown as import('../acp.js').Client;
const fallback = createFallback();
(fallback.detectFileBOM as ReturnType<typeof vi.fn>).mockResolvedValue(
false,
);
const svc = new AcpFileSystemService(
client,
'session-4',
{ readTextFile: false, writeTextFile: true },
fallback,
);
const result = await svc.detectFileBOM('/test/file.txt');
expect(result).toBe(false);
expect(fallback.detectFileBOM).toHaveBeenCalledWith('/test/file.txt');
expect(client.readTextFile).not.toHaveBeenCalled();
});
});
describe('readTextFile ENOENT handling', () => {
it('converts RESOURCE_NOT_FOUND error to ENOENT', async () => {
const resourceNotFoundError = {

View file

@ -54,17 +54,45 @@ export class AcpFileSystemService implements FileSystemService {
return response.content;
}
async writeTextFile(filePath: string, content: string): Promise<void> {
async writeTextFile(
filePath: string,
content: string,
options?: { bom?: boolean },
): Promise<void> {
if (!this.capabilities.writeTextFile) {
return this.fallback.writeTextFile(filePath, content);
return this.fallback.writeTextFile(filePath, content, options);
}
// Prepend BOM character if requested
const finalContent = options?.bom ? '\uFEFF' + content : content;
await this.client.writeTextFile({
path: filePath,
content,
content: finalContent,
sessionId: this.sessionId,
});
}
async detectFileBOM(filePath: string): Promise<boolean> {
// Try to detect BOM through ACP client first by reading first line
if (this.capabilities.readTextFile) {
try {
const response = await this.client.readTextFile({
path: filePath,
sessionId: this.sessionId,
line: null,
limit: 1,
});
// Check if content starts with BOM character (U+FEFF)
return response.content.charCodeAt(0) === 0xfeff;
} catch {
// Fall through to fallback if ACP read fails
}
}
// Fall back to local filesystem detection
return this.fallback.detectFileBOM(filePath);
}
findFiles(fileName: string, searchPaths: readonly string[]): string[] {
return this.fallback.findFiles(fileName, searchPaths);
}

View file

@ -10,6 +10,7 @@ import {
Config,
DEFAULT_QWEN_EMBEDDING_MODEL,
FileDiscoveryService,
FileEncoding,
getCurrentGeminiMdFilename,
loadServerHierarchicalMemory,
setGeminiMdFilename as setServerGeminiMdFilename,
@ -1030,6 +1031,8 @@ export async function loadCliConfig(
// always be true and the settings file can never disable recording.
chatRecording:
argv.chatRecording ?? settings.general?.chatRecording ?? true,
defaultFileEncoding:
settings.general?.defaultFileEncoding ?? FileEncoding.UTF8,
lsp: {
enabled: lspEnabled,
},

View file

@ -236,6 +236,20 @@ const SETTINGS_SCHEMA = {
'Enable saving chat history to disk. Disabling this will also prevent --continue and --resume from working.',
showInDialog: false,
},
defaultFileEncoding: {
type: 'enum',
label: 'Default File Encoding',
category: 'General',
requiresRestart: false,
default: 'utf-8',
description:
'Default encoding for new files. Use "utf-8" (default) for UTF-8 without BOM, or "utf-8-bom" for UTF-8 with BOM. Only change this if your project specifically requires BOM.',
showInDialog: false,
options: [
{ value: 'utf-8', label: 'UTF-8 (without BOM)' },
{ value: 'utf-8-bom', label: 'UTF-8 with BOM' },
],
},
},
},
output: {

View file

@ -341,6 +341,9 @@ export async function main() {
process.cwd(),
argv.extensions,
);
// Register cleanup for MCP clients as early as possible
// This ensures MCP server subprocesses are properly terminated on exit
registerCleanup(() => config.shutdown());
// FIXME: list extensions after the config initialize

View file

@ -23,6 +23,7 @@ export default {
'auto-accept edits': 'Änderungen automatisch akzeptieren',
'Accepting edits': 'Änderungen werden akzeptiert',
'(shift + tab to cycle)': '(Umschalt + Tab zum Wechseln)',
'(tab to cycle)': '(Tab zum Wechseln)',
'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).':
'Shell-Befehle über {{symbol}} ausführen (z.B. {{example1}}) oder natürliche Sprache verwenden (z.B. {{example2}}).',
'!': '!',
@ -1368,4 +1369,8 @@ export default {
'Erweiterungsseite wird im Browser geöffnet: {{url}}',
'Failed to open browser. Check out the extensions gallery at {{url}}':
'Browser konnte nicht geöffnet werden. Besuchen Sie die Erweiterungsgalerie unter {{url}}',
'You can switch permission mode quickly with Shift+Tab or /approval-mode.':
'Sie können den Berechtigungsmodus schnell mit Shift+Tab oder /approval-mode wechseln.',
'You can switch permission mode quickly with Tab or /approval-mode.':
'Sie können den Berechtigungsmodus schnell mit Tab oder /approval-mode wechseln.',
};

View file

@ -23,6 +23,7 @@ export default {
'auto-accept edits': 'auto-accept edits',
'Accepting edits': 'Accepting edits',
'(shift + tab to cycle)': '(shift + tab to cycle)',
'(tab to cycle)': '(tab to cycle)',
'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).':
'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).',
'!': '!',
@ -1101,6 +1102,8 @@ export default {
'You can resume a previous conversation by running qwen --continue or qwen --resume.',
'You can switch permission mode quickly with Shift+Tab or /approval-mode.':
'You can switch permission mode quickly with Shift+Tab or /approval-mode.',
'You can switch permission mode quickly with Tab or /approval-mode.':
'You can switch permission mode quickly with Tab or /approval-mode.',
// ============================================================================
// Exit Screen / Stats

View file

@ -23,6 +23,7 @@ export default {
'auto-accept edits': 'Режим принятия правок',
'Accepting edits': 'Принятие правок',
'(shift + tab to cycle)': '(shift + tab для переключения)',
'(tab to cycle)': '(Tab для переключения)',
'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).':
'Выполняйте команды терминала через {{symbol}} (например, {{example1}}) или используйте естественный язык (например, {{example2}}).',
'!': '!',
@ -1372,4 +1373,8 @@ export default {
'Открываем страницу расширений в браузере: {{url}}',
'Failed to open browser. Check out the extensions gallery at {{url}}':
'Не удалось открыть браузер. Посетите галерею расширений по адресу {{url}}',
'You can switch permission mode quickly with Shift+Tab or /approval-mode.':
'Вы можете быстро переключать режим разрешений с помощью Shift+Tab или /approval-mode.',
'You can switch permission mode quickly with Tab or /approval-mode.':
'Вы можете быстро переключать режим разрешений с помощью Tab или /approval-mode.',
};

View file

@ -22,6 +22,7 @@ export default {
'auto-accept edits': '自动接受编辑',
'Accepting edits': '接受编辑',
'(shift + tab to cycle)': '(shift + tab 切换)',
'(tab to cycle)': '(按 tab 切换)',
'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).':
'通过 {{symbol}} 执行 shell 命令(例如,{{example1}})或使用自然语言(例如,{{example2}}',
'!': '!',
@ -1041,6 +1042,8 @@ export default {
'运行 qwen --continue 或 qwen --resume 可继续之前的会话。',
'You can switch permission mode quickly with Shift+Tab or /approval-mode.':
'按 Shift+Tab 或输入 /approval-mode 可快速切换权限模式。',
'You can switch permission mode quickly with Tab or /approval-mode.':
'按 Tab 或输入 /approval-mode 可快速切换权限模式。',
// ============================================================================
// Exit Screen / Stats

View file

@ -438,9 +438,11 @@ describe('AuthDialog', () => {
await wait();
// Should show error message instead of calling handleAuthSelect
expect(lastFrame()).toContain(
'You must select an auth method to proceed. Press Ctrl+C again to exit.',
);
await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('You must select an auth method');
expect(frame).toContain('Press Ctrl+C again to exit');
});
expect(handleAuthSelect).not.toHaveBeenCalled();
unmount();
});

View file

@ -21,21 +21,26 @@ export const AutoAcceptIndicator: React.FC<AutoAcceptIndicatorProps> = ({
let textContent = '';
let subText = '';
const cycleText =
process.platform === 'win32'
? ` ${t('(tab to cycle)')}`
: ` ${t('(shift + tab to cycle)')}`;
switch (approvalMode) {
case ApprovalMode.PLAN:
textColor = theme.status.success;
textContent = t('plan mode');
subText = ` ${t('(shift + tab to cycle)')}`;
subText = cycleText;
break;
case ApprovalMode.AUTO_EDIT:
textColor = theme.status.warning;
textContent = t('auto-accept edits');
subText = ` ${t('(shift + tab to cycle)')}`;
subText = cycleText;
break;
case ApprovalMode.YOLO:
textColor = theme.status.error;
textContent = t('YOLO mode');
subText = ` ${t('(shift + tab to cycle)')}`;
subText = cycleText;
break;
case ApprovalMode.DEFAULT:
default:

View file

@ -46,6 +46,18 @@ const mockCommands: readonly SlashCommand[] = [
];
describe('Help Component', () => {
it('should render platform-specific keyboard shortcuts', () => {
const { lastFrame } = render(<Help commands={mockCommands} />);
const output = lastFrame();
if (process.platform === 'win32') {
expect(output).toContain('Tab');
expect(output).not.toContain('Shift+Tab');
} else {
expect(output).toContain('Shift+Tab');
}
});
it('should not render hidden commands', () => {
const { lastFrame } = render(<Help commands={mockCommands} />);
const output = lastFrame();

View file

@ -154,7 +154,7 @@ export const Help: React.FC<Help> = ({ commands, width }) => (
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Shift+Tab
{process.platform === 'win32' ? 'Tab' : 'Shift+Tab'}
</Text>{' '}
- {t('Cycle approval modes')}
</Text>

View file

@ -28,7 +28,10 @@ const getShortcuts = (): Shortcut[] => [
{ key: '/', description: t('for commands') },
{ key: '@', description: t('for file paths') },
{ key: 'esc esc', description: t('to clear input') },
{ key: 'shift+tab', description: t('to cycle approvals') },
{
key: process.platform === 'win32' ? 'tab' : 'shift+tab',
description: t('to cycle approvals'),
},
{ key: 'ctrl+c', description: t('to quit') },
{ key: getNewlineKey(), description: t('for newline') + ' ⏎' },
{ key: 'ctrl+l', description: t('to clear screen') },

View file

@ -17,7 +17,9 @@ const startupTips = [
'You can run any shell commands from Qwen Code using ! (e.g. !ls).',
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.',
'You can resume a previous conversation by running qwen --continue or qwen --resume.',
'You can switch permission mode quickly with Shift+Tab or /approval-mode.',
process.platform === 'win32'
? 'You can switch permission mode quickly with Tab or /approval-mode.'
: 'You can switch permission mode quickly with Shift+Tab or /approval-mode.',
] as const;
export const Tips: React.FC = () => {

View file

@ -26,6 +26,7 @@ import * as path from 'node:path';
describe('handleAtCommand', () => {
let testRootDir: string;
let mockConfig: Config;
let registry: ToolRegistry;
const mockAddItem: Mock<UseHistoryManagerReturn['addItem']> = vi.fn();
const mockOnDebugMessage: Mock<(message: string) => void> = vi.fn();
@ -53,6 +54,7 @@ describe('handleAtCommand', () => {
getToolRegistry,
getTargetDir: () => testRootDir,
isSandboxed: () => false,
isTrustedFolder: () => true,
getFileService: () => new FileDiscoveryService(testRootDir),
getFileFilteringRespectGitIgnore: () => true,
getFileFilteringRespectQwenIgnore: () => true,
@ -84,7 +86,7 @@ describe('handleAtCommand', () => {
getTruncateToolOutputLines: () => 500,
} as unknown as Config;
const registry = new ToolRegistry(mockConfig);
registry = new ToolRegistry(mockConfig);
registry.registerTool(new ReadManyFilesTool(mockConfig));
registry.registerTool(new GlobTool(mockConfig));
getToolRegistry.mockReturnValue(registry);
@ -204,6 +206,288 @@ describe('handleAtCommand', () => {
);
});
it('should expand an MCP resource reference in @server: resource format', async () => {
(mockConfig as unknown as { getMcpServers: () => unknown }).getMcpServers =
() =>
({
github: {},
}) as unknown;
vi.spyOn(registry, 'readMcpResource').mockResolvedValue({
contents: [
{
uri: 'github://repos/owner/repo/issues',
mimeType: 'application/json',
text: '{"ok":true}',
},
],
} as unknown as Awaited<ReturnType<ToolRegistry['readMcpResource']>>);
const query = 'Show me the data from @github: repos/owner/repo/issues';
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 1000,
signal: abortController.signal,
});
expect(result).toEqual({
processedQuery: [
{ text: 'Show me the data from @github:repos/owner/repo/issues' },
{ text: '\n--- Content from referenced MCP resources ---' },
{ text: '\nContent from @github:repos/owner/repo/issues:\n' },
{ text: '{"ok":true}' },
{ text: '\n--- End of MCP resource content ---' },
],
shouldProceed: true,
});
expect(registry.readMcpResource).toHaveBeenCalledWith(
'github',
'github://repos/owner/repo/issues',
expect.objectContaining({ signal: abortController.signal }),
);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'tool_group',
tools: [expect.objectContaining({ status: ToolCallStatus.Success })],
}),
1000,
);
});
it('should expand an MCP resource reference in @server:resource format', async () => {
(mockConfig as unknown as { getMcpServers: () => unknown }).getMcpServers =
() =>
({
github: {},
}) as unknown;
vi.spyOn(registry, 'readMcpResource').mockResolvedValue({
contents: [
{
uri: 'github://repos/owner/repo/issues',
mimeType: 'application/json',
text: '{"ok":true}',
},
],
} as unknown as Awaited<ReturnType<ToolRegistry['readMcpResource']>>);
const query = 'Show me the data from @github:repos/owner/repo/issues';
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 1001,
signal: abortController.signal,
});
expect(result).toEqual({
processedQuery: [
{ text: query },
{ text: '\n--- Content from referenced MCP resources ---' },
{ text: '\nContent from @github:repos/owner/repo/issues:\n' },
{ text: '{"ok":true}' },
{ text: '\n--- End of MCP resource content ---' },
],
shouldProceed: true,
});
expect(registry.readMcpResource).toHaveBeenCalledWith(
'github',
'github://repos/owner/repo/issues',
expect.objectContaining({ signal: abortController.signal }),
);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'tool_group',
tools: [expect.objectContaining({ status: ToolCallStatus.Success })],
}),
1001,
);
});
it('should expand an MCP resource reference with a leading slash', async () => {
(mockConfig as unknown as { getMcpServers: () => unknown }).getMcpServers =
() =>
({
github: {},
}) as unknown;
vi.spyOn(registry, 'readMcpResource').mockResolvedValue({
contents: [
{
uri: 'github://repos/owner/repo/issues',
mimeType: 'application/json',
text: '{"ok":true}',
},
],
} as unknown as Awaited<ReturnType<ToolRegistry['readMcpResource']>>);
const query = 'Show me the data from @github:/repos/owner/repo/issues';
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 1002,
signal: abortController.signal,
});
expect(result).toEqual({
processedQuery: [
{ text: 'Show me the data from @github:repos/owner/repo/issues' },
{ text: '\n--- Content from referenced MCP resources ---' },
{ text: '\nContent from @github:repos/owner/repo/issues:\n' },
{ text: '{"ok":true}' },
{ text: '\n--- End of MCP resource content ---' },
],
shouldProceed: true,
});
expect(registry.readMcpResource).toHaveBeenCalledWith(
'github',
'github://repos/owner/repo/issues',
expect.objectContaining({ signal: abortController.signal }),
);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'tool_group',
tools: [expect.objectContaining({ status: ToolCallStatus.Success })],
}),
1002,
);
});
it('should ignore @server: when no MCP resource is provided', async () => {
(mockConfig as unknown as { getMcpServers: () => unknown }).getMcpServers =
() =>
({
github: {},
}) as unknown;
const readMcpResourceSpy = vi.spyOn(registry, 'readMcpResource');
const query = 'Show me the data from @github:';
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 1003,
signal: abortController.signal,
});
expect(result).toEqual({
processedQuery: [{ text: query }],
shouldProceed: true,
});
expect(readMcpResourceSpy).not.toHaveBeenCalled();
expect(mockAddItem).not.toHaveBeenCalled();
});
it('should not expand MCP resources in untrusted folders', async () => {
(mockConfig as unknown as { getMcpServers: () => unknown }).getMcpServers =
() =>
({
github: {},
}) as unknown;
const configWithTrust = mockConfig as unknown as {
isTrustedFolder: () => boolean;
};
configWithTrust.isTrustedFolder = () => false;
const readMcpResourceSpy = vi.spyOn(registry, 'readMcpResource');
const query = 'Show me the data from @github: repos/owner/repo/issues';
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 1004,
signal: abortController.signal,
});
expect(result).toEqual({
processedQuery: null,
shouldProceed: false,
});
expect(readMcpResourceSpy).toHaveBeenCalledWith(
'github',
'github://repos/owner/repo/issues',
expect.objectContaining({ signal: abortController.signal }),
);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'tool_group',
tools: [
expect.objectContaining({
status: ToolCallStatus.Error,
resultDisplay: expect.stringContaining('untrusted'),
}),
],
}),
1004,
);
});
it('should preserve trailing punctuation after an MCP resource reference', async () => {
(mockConfig as unknown as { getMcpServers: () => unknown }).getMcpServers =
() =>
({
github: {},
}) as unknown;
vi.spyOn(registry, 'readMcpResource').mockResolvedValue({
contents: [
{
uri: 'github://repos/owner/repo/issues',
mimeType: 'application/json',
text: '{"ok":true}',
},
],
} as unknown as Awaited<ReturnType<ToolRegistry['readMcpResource']>>);
const query = 'Show me the data from @github: repos/owner/repo/issues.';
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 1005,
signal: abortController.signal,
});
expect(result).toEqual({
processedQuery: [
{ text: 'Show me the data from @github:repos/owner/repo/issues.' },
{ text: '\n--- Content from referenced MCP resources ---' },
{ text: '\nContent from @github:repos/owner/repo/issues:\n' },
{ text: '{"ok":true}' },
{ text: '\n--- End of MCP resource content ---' },
],
shouldProceed: true,
});
expect(registry.readMcpResource).toHaveBeenCalledWith(
'github',
'github://repos/owner/repo/issues',
expect.objectContaining({ signal: abortController.signal }),
);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'tool_group',
tools: [expect.objectContaining({ status: ToolCallStatus.Success })],
}),
1005,
);
});
it('should handle query with text before and after @command', async () => {
const fileContent = 'Markdown content.';
const filePath = await createTestFile(

View file

@ -36,6 +36,12 @@ interface AtCommandPart {
content: string;
}
interface McpResourceAtReference {
atCommand: string; // e.g. "@github:repos/owner/repo/issues"
serverName: string;
uri: string; // e.g. "github://repos/owner/repo/issues"
}
/**
* Parses a query string to find all '@<path>' commands and text segments.
* Handles \ escaped spaces within paths.
@ -110,6 +116,199 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
);
}
function getConfiguredMcpServerNames(config: Config): Set<string> {
const names = new Set(Object.keys(config.getMcpServers() ?? {}));
if (config.getMcpServerCommand()) {
names.add('mcp');
}
return names;
}
function normalizeMcpResourceUri(serverName: string, resource: string): string {
if (resource.includes('://')) {
return resource;
}
const cleaned = resource.startsWith('/') ? resource.slice(1) : resource;
return `${serverName}://${cleaned}`;
}
function splitLeadingToken(
text: string,
): { token: string; rest: string } | null {
let i = 0;
while (i < text.length && /\s/.test(text[i])) {
i++;
}
if (i >= text.length) {
return null;
}
let token = '';
let inEscape = false;
while (i < text.length) {
const char = text[i];
if (inEscape) {
token += char;
inEscape = false;
i++;
continue;
}
if (char === '\\') {
inEscape = true;
i++;
continue;
}
if (/[,\s;!?()[\]{}]/.test(char)) {
break;
}
if (char === '.') {
const nextChar = i + 1 < text.length ? text[i + 1] : '';
if (nextChar === '' || /\s/.test(nextChar)) {
break;
}
}
token += char;
i++;
}
if (!token) {
return null;
}
return { token, rest: text.slice(i) };
}
function extractMcpResourceAtReferences(
parts: AtCommandPart[],
config: Config,
): { parts: AtCommandPart[]; refs: McpResourceAtReference[] } {
const configuredServers = getConfiguredMcpServerNames(config);
const refs: McpResourceAtReference[] = [];
const merged: AtCommandPart[] = [];
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part.type !== 'atPath') {
merged.push(part);
continue;
}
const atText = part.content; // e.g. "@github:" or "@github:repos/..."
const colonIndex = atText.indexOf(':');
if (!atText.startsWith('@') || colonIndex <= 1) {
merged.push(part);
continue;
}
const serverName = atText.slice(1, colonIndex);
if (!configuredServers.has(serverName)) {
merged.push(part);
continue;
}
let resource = atText.slice(colonIndex + 1);
// Support the documented "@server: resource" format where the resource is
// separated into the following text part.
if (!resource) {
const next = parts[i + 1];
if (next?.type === 'text') {
const tokenInfo = splitLeadingToken(next.content);
if (tokenInfo) {
resource = tokenInfo.token;
const remainingText = tokenInfo.rest;
// Update the next part in place, and let the next iteration handle it.
parts[i + 1] = { type: 'text', content: remainingText };
}
}
}
if (!resource) {
// Treat "@server:" without a resource as plain text, rather than falling
// through to file resolution for a path like "server:".
merged.push({ type: 'text', content: atText });
continue;
}
const normalizedResource = resource.includes('://')
? resource
: resource.startsWith('/')
? resource.slice(1)
: resource;
const normalizedAtCommand = `@${serverName}:${normalizedResource}`;
refs.push({
atCommand: normalizedAtCommand,
serverName,
uri: normalizeMcpResourceUri(serverName, normalizedResource),
});
merged.push({ type: 'atPath', content: normalizedAtCommand });
}
return {
parts: merged.filter(
(p) => !(p.type === 'text' && p.content.trim() === ''),
),
refs,
};
}
function formatMcpResourceContents(
raw: unknown,
limits: { maxCharsPerResource: number; maxLinesPerResource: number },
): string {
if (!raw || typeof raw !== 'object') {
return '[Error: Invalid MCP resource response]';
}
const contents = (raw as { contents?: unknown }).contents;
if (!Array.isArray(contents)) {
return '[Error: Invalid MCP resource response]';
}
const parts: string[] = [];
for (const item of contents) {
if (!item || typeof item !== 'object') {
continue;
}
const text = (item as { text?: unknown }).text;
const blob = (item as { blob?: unknown }).blob;
const mimeType = (item as { mimeType?: unknown }).mimeType;
if (typeof text === 'string') {
parts.push(text);
continue;
}
if (typeof blob === 'string') {
const mimeTypeLabel =
typeof mimeType === 'string' ? mimeType : 'application/octet-stream';
parts.push(
`[Binary MCP resource omitted (mimeType: ${mimeTypeLabel}, bytes: ${blob.length})]`,
);
}
}
let combined = parts.join('\n\n');
const maxLines = limits.maxLinesPerResource;
if (Number.isFinite(maxLines)) {
const lines = combined.split('\n');
if (lines.length > maxLines) {
combined = `${lines.slice(0, maxLines).join('\n')}\n[truncated]`;
}
}
const maxChars = limits.maxCharsPerResource;
if (Number.isFinite(maxChars) && combined.length > maxChars) {
combined = `${combined.slice(0, maxChars)}\n[truncated]`;
}
return combined;
}
/**
* Processes user input potentially containing one or more '@<path>' commands.
* If found, it attempts to read the specified files/directories using the
@ -127,10 +326,17 @@ export async function handleAtCommand({
messageId: userMessageTimestamp,
signal,
}: HandleAtCommandParams): Promise<HandleAtCommandResult> {
const commandParts = parseAllAtCommands(query);
const parsedParts = parseAllAtCommands(query);
const { parts: commandParts, refs: mcpResourceRefs } =
extractMcpResourceAtReferences(parsedParts, config);
const mcpAtCommands = new Set(mcpResourceRefs.map((r) => r.atCommand));
const atPathCommandParts = commandParts.filter(
(part) => part.type === 'atPath',
);
const fileAtPathCommandParts = atPathCommandParts.filter(
(part) => !mcpAtCommands.has(part.content),
);
if (atPathCommandParts.length === 0) {
return { processedQuery: [{ text: query }], shouldProceed: true };
@ -154,15 +360,7 @@ export async function handleAtCommand({
const readManyFilesTool = toolRegistry.getTool('read_many_files');
const globTool = toolRegistry.getTool('glob');
if (!readManyFilesTool) {
addItem(
{ type: 'error', text: 'Error: read_many_files tool not found.' },
userMessageTimestamp,
);
return { processedQuery: null, shouldProceed: false };
}
for (const atPathPart of atPathCommandParts) {
for (const atPathPart of fileAtPathCommandParts) {
const originalAtPath = atPathPart.content; // e.g., "@file.txt" or "@"
if (originalAtPath === '@') {
@ -377,7 +575,7 @@ export async function handleAtCommand({
}
// Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText
if (pathSpecsToRead.length === 0) {
if (pathSpecsToRead.length === 0 && mcpResourceRefs.length === 0) {
onDebugMessage('No valid file paths found in @ commands to read.');
if (initialQueryText === '@' && query.trim() === '@') {
// If the only thing was a lone @, pass original query (which might have spaces)
@ -395,86 +593,165 @@ export async function handleAtCommand({
const processedQueryParts: PartUnion[] = [{ text: initialQueryText }];
const toolArgs = {
paths: pathSpecsToRead,
file_filtering_options: {
respect_git_ignore: respectFileIgnore.respectGitIgnore,
respect_qwen_ignore: respectFileIgnore.respectQwenIgnore,
},
// Use configuration setting
};
let toolCallDisplay: IndividualToolCallDisplay;
const toolDisplays: IndividualToolCallDisplay[] = [];
let invocation: AnyToolInvocation | undefined = undefined;
try {
invocation = readManyFilesTool.build(toolArgs);
const result = await invocation.execute(signal);
toolCallDisplay = {
callId: `client-read-${userMessageTimestamp}`,
name: readManyFilesTool.displayName,
description: invocation.getDescription(),
status: ToolCallStatus.Success,
resultDisplay:
result.returnDisplay ||
`Successfully read: ${contentLabelsForDisplay.join(', ')}`,
confirmationDetails: undefined,
};
if (Array.isArray(result.llmContent)) {
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
processedQueryParts.push({
text: '\n--- Content from referenced files ---',
});
for (const part of result.llmContent) {
if (typeof part === 'string') {
const match = fileContentRegex.exec(part);
if (match) {
const filePathSpecInContent = match[1]; // This is a resolved pathSpec
const fileActualContent = match[2].trim();
processedQueryParts.push({
text: `\nContent from @${filePathSpecInContent}:\n`,
});
processedQueryParts.push({ text: fileActualContent });
} else {
processedQueryParts.push({ text: part });
}
} else {
// part is a Part object.
processedQueryParts.push(part);
}
}
} else {
onDebugMessage(
'read_many_files tool returned no content or empty content.',
if (pathSpecsToRead.length > 0) {
if (!readManyFilesTool) {
addItem(
{ type: 'error', text: 'Error: read_many_files tool not found.' },
userMessageTimestamp,
);
return { processedQuery: null, shouldProceed: false };
}
addItem(
{ type: 'tool_group', tools: [toolCallDisplay] } as Omit<
HistoryItem,
'id'
>,
userMessageTimestamp,
);
return { processedQuery: processedQueryParts, shouldProceed: true };
} catch (error: unknown) {
toolCallDisplay = {
callId: `client-read-${userMessageTimestamp}`,
name: readManyFilesTool.displayName,
description:
invocation?.getDescription() ??
'Error attempting to execute tool to read files',
status: ToolCallStatus.Error,
resultDisplay: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
confirmationDetails: undefined,
const toolArgs = {
paths: pathSpecsToRead,
file_filtering_options: {
respect_git_ignore: respectFileIgnore.respectGitIgnore,
respect_qwen_ignore: respectFileIgnore.respectQwenIgnore,
},
// Use configuration setting
};
let invocation: AnyToolInvocation | undefined = undefined;
try {
invocation = readManyFilesTool.build(toolArgs);
const result = await invocation.execute(signal);
toolDisplays.push({
callId: `client-read-${userMessageTimestamp}`,
name: readManyFilesTool.displayName,
description: invocation.getDescription(),
status: ToolCallStatus.Success,
resultDisplay:
result.returnDisplay ||
`Successfully read: ${contentLabelsForDisplay.join(', ')}`,
confirmationDetails: undefined,
});
if (Array.isArray(result.llmContent)) {
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
processedQueryParts.push({
text: '\n--- Content from referenced files ---',
});
for (const part of result.llmContent) {
if (typeof part === 'string') {
const match = fileContentRegex.exec(part);
if (match) {
const filePathSpecInContent = match[1]; // This is a resolved pathSpec
const fileActualContent = match[2].trim();
processedQueryParts.push({
text: `\nContent from @${filePathSpecInContent}:\n`,
});
processedQueryParts.push({ text: fileActualContent });
} else {
processedQueryParts.push({ text: part });
}
} else {
// part is a Part object.
processedQueryParts.push(part);
}
}
} else {
onDebugMessage(
'read_many_files tool returned no content or empty content.',
);
}
} catch (error: unknown) {
toolDisplays.push({
callId: `client-read-${userMessageTimestamp}`,
name: readManyFilesTool.displayName,
description:
invocation?.getDescription() ??
'Error attempting to execute tool to read files',
status: ToolCallStatus.Error,
resultDisplay: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
confirmationDetails: undefined,
});
addItem(
{ type: 'tool_group', tools: toolDisplays } as Omit<HistoryItem, 'id'>,
userMessageTimestamp,
);
return { processedQuery: null, shouldProceed: false };
}
}
if (mcpResourceRefs.length > 0) {
const totalCharLimit = config.getTruncateToolOutputThreshold();
const totalLineLimit = config.getTruncateToolOutputLines();
const maxCharsPerResource = Number.isFinite(totalCharLimit)
? Math.floor(totalCharLimit / Math.max(1, mcpResourceRefs.length))
: Number.POSITIVE_INFINITY;
const maxLinesPerResource = Number.isFinite(totalLineLimit)
? Math.floor(totalLineLimit / Math.max(1, mcpResourceRefs.length))
: Number.POSITIVE_INFINITY;
processedQueryParts.push({
text: '\n--- Content from referenced MCP resources ---',
});
for (let i = 0; i < mcpResourceRefs.length; i++) {
const ref = mcpResourceRefs[i];
let resourceResult: unknown;
try {
if (signal.aborted) {
const error = new Error('MCP resource read aborted');
error.name = 'AbortError';
throw error;
}
resourceResult = await toolRegistry.readMcpResource(
ref.serverName,
ref.uri,
{ signal },
);
toolDisplays.push({
callId: `client-mcp-resource-${userMessageTimestamp}-${i}`,
name: 'McpResourceRead',
description: `Read MCP resource ${ref.uri} (server: ${ref.serverName})`,
status: ToolCallStatus.Success,
resultDisplay: `Read: ${ref.uri}`,
confirmationDetails: undefined,
});
} catch (error: unknown) {
toolDisplays.push({
callId: `client-mcp-resource-${userMessageTimestamp}-${i}`,
name: 'McpResourceRead',
description: `Read MCP resource ${ref.uri} (server: ${ref.serverName})`,
status: ToolCallStatus.Error,
resultDisplay: `Error reading MCP resource (${ref.uri}): ${getErrorMessage(error)}`,
confirmationDetails: undefined,
});
addItem(
{ type: 'tool_group', tools: toolDisplays } as Omit<
HistoryItem,
'id'
>,
userMessageTimestamp,
);
return { processedQuery: null, shouldProceed: false };
}
processedQueryParts.push({
text: `\nContent from ${ref.atCommand}:\n`,
});
processedQueryParts.push({
text: formatMcpResourceContents(resourceResult, {
maxCharsPerResource,
maxLinesPerResource,
}),
});
}
processedQueryParts.push({ text: '\n--- End of MCP resource content ---' });
}
if (toolDisplays.length > 0) {
addItem(
{ type: 'tool_group', tools: [toolCallDisplay] } as Omit<
HistoryItem,
'id'
>,
{ type: 'tool_group', tools: toolDisplays } as Omit<HistoryItem, 'id'>,
userMessageTimestamp,
);
return { processedQuery: null, shouldProceed: false };
}
return { processedQuery: processedQueryParts, shouldProceed: true };
}

View file

@ -240,7 +240,13 @@ describe('useAutoAcceptIndicator', () => {
shift: false,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
if (process.platform === 'win32') {
// On Windows, Tab alone toggles approval mode
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalled();
mockConfigInstance.setApprovalMode.mockClear();
} else {
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
}
act(() => {
capturedUseKeypressHandler({

View file

@ -36,7 +36,16 @@ export function useAutoAcceptIndicator({
useKeypress(
(key) => {
// Handle Shift+Tab to cycle through all modes
if (key.shift && key.name === 'tab') {
// On Windows, Shift+Tab is indistinguishable from Tab (\t) in some terminals,
// so we allow Tab to switch modes as well to support the shortcut.
const isShiftTab = key.shift && key.name === 'tab';
const isWindowsTab =
process.platform === 'win32' &&
key.name === 'tab' &&
!key.ctrl &&
!key.meta;
if (isShiftTab || isWindowsTab) {
const currentMode = config.getApprovalMode();
const currentIndex = APPROVAL_MODES.indexOf(currentMode);
const nextIndex =

View file

@ -6,8 +6,14 @@
/// <reference types="vitest" />
import { defineConfig } from 'vitest/config';
import path from 'node:path';
export default defineConfig({
resolve: {
alias: {
'@qwen-code/qwen-code-core': path.resolve(__dirname, '../core/index.ts'),
},
},
test: {
include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)', 'config.test.ts'],
exclude: ['**/node_modules/**', '**/dist/**', '**/cypress/**'],
@ -41,7 +47,7 @@ export default defineConfig({
},
server: {
deps: {
inline: [/@google\/gemini-cli-core/],
inline: [/@qwen-code\/qwen-code-core/],
},
},
},