mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 23:42:03 +00:00
Merge branch and resolve conflicts
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
commit
ff0ba0cc4e
69 changed files with 4460 additions and 1023 deletions
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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') },
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue