Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"test-browser": "npx playwright install && node test/unit/browser/index.js",
"test-browser-no-install": "node test/unit/browser/index.js",
"test-node": "mocha test/unit/node/index.js --delay --ui=tdd --timeout=5000 --exit",
"test-phase0-qa": "npm run test-node -- --runGlob \"**/cortexide/test/common/{providerSettingsValidation,onboardingHelpers,attachFileToChat,chatThreadStorageReviver,providerToolFormat,phase0ClaimVerification,modelCapabilities,menubarStackingFix,designSystem}.test.js\"",
"test-phase0-qa": "npm run test-node -- --runGlob \"**/cortexide/test/common/{providerSettingsValidation,onboardingHelpers,attachFileToChat,chatThreadStorageReviver,providerToolFormat,phase0ClaimVerification,modelCapabilities,menubarStackingFix,designSystem,atReferenceTokens,resolveAtReferences}.test.js\"",
"test-extension": "vscode-test",
"test-build-scripts": "cd build && npm run test",
"check-cyclic-dependencies": "node build/lib/checkCyclicDependencies.ts out",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import { CommandBarInChat } from './composer/CommandBarInChat.js';
import { ChatMessageList } from './composer/ChatMessageList.js';
import { StagingContextChips } from './composer/StagingContextChips.js';
import { useContextUsage } from './composer/useContextUsage.js';
import { resolveAtReferencesInMessage } from './composer/resolveAtReferences.js';
import { resolveAtReferencesInMessage } from '../../../../common/resolveAtReferences.js';
import { handleSlashCommand } from './composer/handleSlashCommand.js';
import { LandingPage } from './landing/LandingPage.js';
import { ComposerTabs } from './chrome/ComposerTabs.js';
import { ThreadHeader } from './chrome/ThreadHeader.js';
Expand Down Expand Up @@ -210,60 +211,22 @@ export const SidebarChat = () => {

// send message to LLM
let userMessage = _forceSubmit || textAreaRef.current?.value || ''

// allow-any-unicode-next-line
// ── Slash commands ────────────────────────────────────────────────────────
// Intercept /command messages before sending to LLM.
const trimmed = userMessage.trim()
if (trimmed.startsWith('/')) {
const [cmd, ...rest] = trimmed.slice(1).split(/\s+/)
const clearInput = () => {
if (textAreaFnsRef.current) textAreaFnsRef.current.setValue('')
textAreaRef.current?.focus()
}
switch (cmd.toLowerCase()) {
case 'clear':
case 'new':
clearInput()
await chatThreadsService.openNewThread()
await chatThreadsService.focusCurrentChat()
return
case 'settings':
clearInput()
commandService.executeCommand(CORTEXIDE_OPEN_SETTINGS_ACTION_ID)
return
case 'model':
clearInput()
commandService.executeCommand(CORTEXIDE_OPEN_SETTINGS_ACTION_ID)
return
case 'help': {
clearInput()
const skillNames = chatThreadsService.listSkillNames?.() ?? []
const skillsLine = skillNames.length > 0
? ` | skills: ${skillNames.map(n => '/' + n).join(', ')}`
: ''
notificationService.info(
// allow-any-unicode-next-line
'Slash commands: /clear — new thread | /settings — open settings | /model — change model | /help — this message' + skillsLine
)
return
}
default: {
// Phase 6 (Skills): /<skill-name> [args] expands the matching .cortexide/skills SKILL.md
// into a normal chat turn. Built-in commands above take precedence over same-named skills.
const skillExpansion = chatThreadsService.getSkillExpansion(trimmed)
if (skillExpansion !== null) {
userMessage = skillExpansion
break
}
// allow-any-unicode-next-line
// Unknown command — let it fall through as normal text
break
}
}
const clearInput = () => {
if (textAreaFnsRef.current) textAreaFnsRef.current.setValue('')
textAreaRef.current?.focus()
}
// allow-any-unicode-next-line
// ─────────────────────────────────────────────────────────────────────────

const slashResult = await handleSlashCommand({
trimmedMessage: trimmed,
clearInput,
chatThreadsService,
commandService,
notificationService,
settingsCommandId: CORTEXIDE_OPEN_SETTINGS_ACTION_ID,
})
if (slashResult.handled) return
userMessage = slashResult.userMessage

await resolveAtReferencesInMessage({
userMessage,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/

type ChatThreadsServiceLike = {
openNewThread: () => Promise<void>;
focusCurrentChat: () => Promise<void>;
listSkillNames?: () => string[];
getSkillExpansion: (trimmed: string) => string | null;
};

type CommandServiceLike = {
executeCommand: (id: string) => Promise<unknown>;
};

type NotificationServiceLike = {
info: (message: string) => void;
};

export type HandleSlashCommandParams = {
trimmedMessage: string;
clearInput: () => void;
chatThreadsService: ChatThreadsServiceLike;
commandService: CommandServiceLike;
notificationService: NotificationServiceLike;
settingsCommandId: string;
};

export type SlashCommandResult =
| { handled: true }
| { handled: false; userMessage: string };

/** Intercept /command messages before sending to the LLM. */
export const handleSlashCommand = async ({
trimmedMessage,
clearInput,
chatThreadsService,
commandService,
notificationService,
settingsCommandId,
}: HandleSlashCommandParams): Promise<SlashCommandResult> => {
if (!trimmedMessage.startsWith('/')) {
return { handled: false, userMessage: trimmedMessage };
}

const [cmd] = trimmedMessage.slice(1).split(/\s+/);
switch (cmd.toLowerCase()) {
case 'clear':
case 'new':
clearInput();
await chatThreadsService.openNewThread();
await chatThreadsService.focusCurrentChat();
return { handled: true };
case 'settings':
case 'model':
clearInput();
await commandService.executeCommand(settingsCommandId);
return { handled: true };
case 'help': {
clearInput();
const skillNames = chatThreadsService.listSkillNames?.() ?? [];
const skillsLine = skillNames.length > 0
? ` | skills: ${skillNames.map(n => '/' + n).join(', ')}`
: '';
notificationService.info(
// allow-any-unicode-next-line
'Slash commands: /clear — new thread | /settings — open settings | /model — change model | /help — this message' + skillsLine
);
return { handled: true };
}
default: {
const skillExpansion = chatThreadsService.getSkillExpansion(trimmedMessage);
if (skillExpansion !== null) {
return { handled: false, userMessage: skillExpansion };
}
return { handled: false, userMessage: trimmedMessage };
}
}
};
18 changes: 18 additions & 0 deletions src/vs/workbench/contrib/cortexide/common/atReferenceTokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/

/** Extract @reference tokens from chat input (quoted paths first, then bare @word tokens). */
export const extractAtReferenceTokens = (userMessage: string): string[] => {
const tokens: string[] = [];
const quoted = [...userMessage.matchAll(/@"([^"]+)"/g)].map(m => m[1]);
tokens.push(...quoted);
for (const m of userMessage.matchAll(/@([\w\.\-_/]+(?::[\w\d.-]+(?:-\d+)?)?)/g)) {
const t = m[1];
if (t) {
tokens.push(t);
}
}
return tokens;
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/

import { StagingSelectionItem } from '../../../../common/chatThreadServiceTypes.js';
import { StagingSelectionItem } from './chatThreadServiceTypes.js';
import { extractAtReferenceTokens } from './atReferenceTokens.js';

type ServiceAccessor = { get: (id: string) => any };

Expand Down Expand Up @@ -72,13 +73,7 @@ export const resolveAtReferencesInMessage = async ({
});
};

const tokens: string[] = [];
const quoted = [...userMessage.matchAll(/@"([^"]+)"/g)].map(m => m[1]);
tokens.push(...quoted);
for (const m of userMessage.matchAll(/@([\w\.\-_/]+(?::\d+(?:-\d+)?)?)/g)) {
const t = m[1];
if (t) tokens.push(t);
}
const tokens = extractAtReferenceTokens(userMessage);

const unresolvedRefs: string[] = [];

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/

import * as assert from 'assert';
import { suite, test } from 'mocha';
import { extractAtReferenceTokens } from '../../common/atReferenceTokens.js';

suite('atReferenceTokens', () => {

test('extracts bare @word tokens', () => {
assert.deepStrictEqual(
extractAtReferenceTokens('summarize @workspace and @recent files'),
['workspace', 'recent'],
);
});

test('extracts quoted @"path with spaces"', () => {
assert.deepStrictEqual(
extractAtReferenceTokens('check @"src/my file.ts" please'),
['src/my file.ts'],
);
});

test('extracts path-like tokens with line ranges', () => {
assert.deepStrictEqual(
extractAtReferenceTokens('fix @src/app.ts:10-20'),
['src/app.ts:10-20'],
);
});

test('extracts symbol references', () => {
assert.deepStrictEqual(
extractAtReferenceTokens('find @sym:MyClass usages'),
['sym:MyClass'],
);
});

test('returns empty array when no @ tokens', () => {
assert.deepStrictEqual(extractAtReferenceTokens('hello world'), []);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/

import * as assert from 'assert';
import { suite, test } from 'mocha';
import { URI } from '../../../../../base/common/uri.js';
import { resolveAtReferencesInMessage } from '../../common/resolveAtReferences.js';

suite('resolveAtReferences', () => {

test('@workspace adds workspace folder selections', async () => {
const folderUri = URI.file('/tmp/cx-ws-cdp');
const added: { type: string; uri: URI }[] = [];
const warns: string[] = [];

await resolveAtReferencesInMessage({
userMessage: 'summarize @workspace',
threadId: 't1',
existingSelections: [],
chatThreadsService: {
addNewStagingSelection: async (sel) => { added.push(sel as { type: string; uri: URI }); },
},
accessor: {
get: (id: string) => {
if (id === 'IWorkspaceContextService') {
return { getWorkspace: () => ({ folders: [{ uri: folderUri }] }) };
}
if (id === 'IToolsService') return { callTool: { search_pathnames_only: async () => ({ result: { uris: [] } }) } };
if (id === 'IEditorService') return { activeTextEditorControl: null, activeEditor: null };
if (id === 'ILanguageService') return { guessLanguageIdByFilepathOrFirstLine: () => 'plaintext' };
if (id === 'IHistoryService') return { getHistory: () => [] };
throw new Error(`unexpected service ${id}`);
},
},
notificationService: { warn: (m) => warns.push(m) },
});

assert.strictEqual(added.length, 1);
assert.strictEqual(added[0].type, 'Folder');
assert.strictEqual(added[0].uri.toString(), folderUri.toString());
assert.strictEqual(warns.length, 0);
});
});
Loading