From 4f08edcdd6863d58e4dac7c4b2bcacbc1534648e Mon Sep 17 00:00:00 2001 From: Tajudeen Date: Thu, 23 Jul 2026 14:21:41 +0100 Subject: [PATCH] refactor(ui): extract slash commands and add @-reference unit tests Move resolveAtReferences to common/, extract handleSlashCommand, add extractAtReferenceTokens with tests, and fix sym: token parsing. Co-authored-by: Cursor --- package.json | 2 +- .../react/src/sidebar-tsx/SidebarChat.tsx | 69 ++++------------ .../composer/handleSlashCommand.ts | 80 +++++++++++++++++++ .../cortexide/common/atReferenceTokens.ts | 18 +++++ .../resolveAtReferences.ts | 11 +-- .../test/common/atReferenceTokens.test.ts | 43 ++++++++++ .../test/common/resolveAtReferences.test.ts | 45 +++++++++++ 7 files changed, 206 insertions(+), 62 deletions(-) create mode 100644 src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/composer/handleSlashCommand.ts create mode 100644 src/vs/workbench/contrib/cortexide/common/atReferenceTokens.ts rename src/vs/workbench/contrib/cortexide/{browser/react/src/sidebar-tsx/composer => common}/resolveAtReferences.ts (94%) create mode 100644 src/vs/workbench/contrib/cortexide/test/common/atReferenceTokens.test.ts create mode 100644 src/vs/workbench/contrib/cortexide/test/common/resolveAtReferences.test.ts diff --git a/package.json b/package.json index b17406d217d..f252422e3df 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/SidebarChat.tsx b/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/SidebarChat.tsx index a32d2168958..0a8f9b825d5 100644 --- a/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/SidebarChat.tsx +++ b/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/SidebarChat.tsx @@ -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'; @@ -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): / [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, diff --git a/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/composer/handleSlashCommand.ts b/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/composer/handleSlashCommand.ts new file mode 100644 index 00000000000..4b079f7872b --- /dev/null +++ b/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/composer/handleSlashCommand.ts @@ -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; + focusCurrentChat: () => Promise; + listSkillNames?: () => string[]; + getSkillExpansion: (trimmed: string) => string | null; +}; + +type CommandServiceLike = { + executeCommand: (id: string) => Promise; +}; + +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 => { + 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 }; + } + } +}; diff --git a/src/vs/workbench/contrib/cortexide/common/atReferenceTokens.ts b/src/vs/workbench/contrib/cortexide/common/atReferenceTokens.ts new file mode 100644 index 00000000000..5c2ff76b179 --- /dev/null +++ b/src/vs/workbench/contrib/cortexide/common/atReferenceTokens.ts @@ -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; +}; diff --git a/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/composer/resolveAtReferences.ts b/src/vs/workbench/contrib/cortexide/common/resolveAtReferences.ts similarity index 94% rename from src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/composer/resolveAtReferences.ts rename to src/vs/workbench/contrib/cortexide/common/resolveAtReferences.ts index fb6247b046e..64a9a364d85 100644 --- a/src/vs/workbench/contrib/cortexide/browser/react/src/sidebar-tsx/composer/resolveAtReferences.ts +++ b/src/vs/workbench/contrib/cortexide/common/resolveAtReferences.ts @@ -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 }; @@ -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[] = []; diff --git a/src/vs/workbench/contrib/cortexide/test/common/atReferenceTokens.test.ts b/src/vs/workbench/contrib/cortexide/test/common/atReferenceTokens.test.ts new file mode 100644 index 00000000000..1d7f1107bc2 --- /dev/null +++ b/src/vs/workbench/contrib/cortexide/test/common/atReferenceTokens.test.ts @@ -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'), []); + }); +}); diff --git a/src/vs/workbench/contrib/cortexide/test/common/resolveAtReferences.test.ts b/src/vs/workbench/contrib/cortexide/test/common/resolveAtReferences.test.ts new file mode 100644 index 00000000000..bb07fd0bd78 --- /dev/null +++ b/src/vs/workbench/contrib/cortexide/test/common/resolveAtReferences.test.ts @@ -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); + }); +});