Skip to content

Commit 57a4b4e

Browse files
authored
Implement claude code slash commands (#1181)
1 parent 34294b4 commit 57a4b4e

File tree

6 files changed

+228
-12
lines changed

6 files changed

+228
-12
lines changed

.github/copilot-instructions.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,8 @@ function f(x: number, y: string): void { }
257257
- Do not introduce new `types` or `values` to the global namespace
258258
- Use proper types. Do not use `any` unless absolutely necessary.
259259
- Use `readonly` whenever possible.
260+
- Avoid casts in TypeScript unless absolutely necessary. If you get type errors after your changes, look up the types of the variables involved and set up a proper system of types and interfaces instead of adding type casts.
261+
- Do not use `any` or `unknown` as the type for variables, parameters, or return values unless absolutely necessary. If they need type annotations, they should have proper types or interfaces defined.
260262

261263
## Key APIs and Integrations
262264

package.json

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3999,7 +3999,29 @@
39993999
"capabilities": {
40004000
"supportsFileAttachments": true,
40014001
"supportsToolAttachments": false
4002-
}
4002+
},
4003+
"commands": [
4004+
{
4005+
"name": "init",
4006+
"description": "Initialize a new CLAUDE.md file with codebase documentation"
4007+
},
4008+
{
4009+
"name": "compact",
4010+
"description": "Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]"
4011+
},
4012+
{
4013+
"name": "pr-comments",
4014+
"description": "Get comments from a GitHub pull request"
4015+
},
4016+
{
4017+
"name": "review",
4018+
"description": "Review a pull request"
4019+
},
4020+
{
4021+
"name": "security-review",
4022+
"description": "Complete a security review of the pending changes on the current branch"
4023+
}
4024+
]
40034025
}
40044026
],
40054027
"debuggers": [
@@ -4211,4 +4233,4 @@
42114233
"string_decoder": "npm:[email protected]",
42124234
"node-gyp": "npm:[email protected]"
42134235
}
4214-
}
4236+
}

src/extension/agents/claude/node/claudeCodeSessionService.ts

Lines changed: 110 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,16 @@ import { FileType } from '../../../../platform/filesystem/common/fileTypes';
1212
import { ILogService } from '../../../../platform/log/common/logService';
1313
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
1414
import { createServiceIdentifier } from '../../../../util/common/services';
15+
import { CancellationError } from '../../../../util/vs/base/common/errors';
1516
import { ResourceMap, ResourceSet } from '../../../../util/vs/base/common/map';
1617
import { isEqualOrParent } from '../../../../util/vs/base/common/resources';
1718
import { URI } from '../../../../util/vs/base/common/uri';
18-
import { CancellationError } from '../../../../util/vs/base/common/errors';
1919

2020
type RawStoredSDKMessage = SDKMessage & {
2121
readonly parentUuid: string | null;
2222
readonly sessionId: string;
2323
readonly timestamp: string;
24+
readonly isMeta?: boolean;
2425
}
2526
interface SummaryEntry {
2627
readonly type: 'summary';
@@ -35,8 +36,16 @@ type StoredSDKMessage = SDKMessage & {
3536
readonly timestamp: Date;
3637
}
3738

39+
interface ParsedSessionMessage {
40+
readonly raw: RawStoredSDKMessage;
41+
readonly isMeta: boolean;
42+
}
43+
3844
export const IClaudeCodeSessionService = createServiceIdentifier<IClaudeCodeSessionService>('IClaudeCodeSessionService');
3945

46+
/**
47+
* Service to load and manage Claude Code chat sessions from disk.
48+
*/
4049
export interface IClaudeCodeSessionService {
4150
readonly _serviceBrand: undefined;
4251
getAllSessions(token: CancellationToken): Promise<readonly IClaudeCodeSession[]>;
@@ -290,7 +299,6 @@ export class ClaudeCodeSessionService implements IClaudeCodeSessionService {
290299
}
291300

292301
private async _getMessagesFromSession(fileUri: URI, token: CancellationToken): Promise<{ messages: Map<string, StoredSDKMessage>; summaries: Map<string, SummaryEntry> }> {
293-
const messages = new Map<string, StoredSDKMessage>();
294302
const summaries = new Map<string, SummaryEntry>();
295303
try {
296304
// Read and parse the JSONL file
@@ -303,18 +311,30 @@ export class ClaudeCodeSessionService implements IClaudeCodeSessionService {
303311

304312
// Parse JSONL content line by line
305313
const lines = text.trim().split('\n').filter(line => line.trim());
314+
const rawMessages = new Map<string, ParsedSessionMessage>();
306315

307316
// Parse each line and build message map
308317
for (const line of lines) {
309318
try {
310319
const entry = JSON.parse(line) as ClaudeSessionFileEntry;
311320

312321
if ('uuid' in entry && entry.uuid && 'message' in entry) {
313-
const sdkMessage = this._reviveStoredSDKMessage(entry as RawStoredSDKMessage);
314-
const uuid = sdkMessage.uuid;
315-
if (uuid) {
316-
messages.set(uuid, sdkMessage);
322+
const rawEntry = entry;
323+
const uuid = rawEntry.uuid;
324+
if (!uuid) {
325+
continue;
317326
}
327+
328+
const { isMeta, ...rest } = rawEntry;
329+
const normalizedRaw = {
330+
...rest,
331+
parentUuid: rawEntry.parentUuid ?? null
332+
} as RawStoredSDKMessage;
333+
334+
rawMessages.set(uuid, {
335+
raw: normalizedRaw,
336+
isMeta: Boolean(isMeta)
337+
});
318338
} else if ('summary' in entry && entry.summary && !entry.summary.toLowerCase().startsWith('api error: 401') && !entry.summary.toLowerCase().startsWith('invalid api key')) {
319339
const summaryEntry = entry as SummaryEntry;
320340
const uuid = summaryEntry.leafUuid;
@@ -326,6 +346,8 @@ export class ClaudeCodeSessionService implements IClaudeCodeSessionService {
326346
this._logService.warn(`Failed to parse line in ${fileUri}: ${line} - ${parseError}`);
327347
}
328348
}
349+
350+
const messages = this._reviveStoredMessages(rawMessages);
329351
return { messages, summaries };
330352
} catch (e) {
331353
this._logService.error(e, `[ClaudeChatSessionItemProvider] Failed to load session: ${fileUri}`);
@@ -380,22 +402,100 @@ export class ClaudeCodeSessionService implements IClaudeCodeSessionService {
380402
return text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>\s*/g, '').trim();
381403
}
382404

405+
private _normalizeCommandContent(text: string): string {
406+
const parsed = this._extractCommandContent(text);
407+
if (parsed !== null) {
408+
return parsed;
409+
}
410+
return this._removeCommandTags(text);
411+
}
412+
413+
private _extractCommandContent(text: string): string | null {
414+
const commandMessageMatch = /<command-message>([\s\S]*?)<\/command-message>/i.exec(text);
415+
if (!commandMessageMatch) {
416+
return null;
417+
}
418+
419+
const commandMessage = commandMessageMatch[1]?.trim();
420+
return commandMessage ? `/${commandMessage}` : null;
421+
}
422+
423+
private _removeCommandTags(text: string): string {
424+
return text
425+
.replace(/<command-message>/gi, '')
426+
.replace(/<\/command-message>/gi, '')
427+
.replace(/<command-name>/gi, '')
428+
.replace(/<\/command-name>/gi, '')
429+
.trim();
430+
}
431+
432+
private _reviveStoredMessages(rawMessages: Map<string, ParsedSessionMessage>): Map<string, StoredSDKMessage> {
433+
const messages = new Map<string, StoredSDKMessage>();
434+
435+
for (const [uuid, entry] of rawMessages) {
436+
if (entry.isMeta) {
437+
continue;
438+
}
439+
440+
const parentUuid = this._resolveParentUuid(entry.raw.parentUuid ?? null, rawMessages);
441+
const revived = this._reviveStoredSDKMessage({
442+
...entry.raw,
443+
parentUuid
444+
});
445+
446+
if (uuid) {
447+
messages.set(uuid, revived);
448+
}
449+
}
450+
451+
return messages;
452+
}
453+
454+
private _resolveParentUuid(parentUuid: string | null, rawMessages: Map<string, ParsedSessionMessage>): string | null {
455+
let current = parentUuid;
456+
const visited = new Set<string>();
457+
458+
while (current) {
459+
if (visited.has(current)) {
460+
return current;
461+
}
462+
visited.add(current);
463+
464+
const candidate = rawMessages.get(current);
465+
if (!candidate) {
466+
return current;
467+
}
468+
469+
if (!candidate.isMeta) {
470+
return current;
471+
}
472+
473+
current = candidate.raw.parentUuid ?? null;
474+
}
475+
476+
return current ?? null;
477+
}
478+
383479
/**
384480
* Strip attachments from message content, handling both string and array formats
385481
*/
386482
private _stripAttachmentsFromMessageContent(content: Anthropic.MessageParam['content']): string | Anthropic.ContentBlockParam[] {
387483
if (typeof content === 'string') {
388-
return this._stripAttachments(content);
484+
const withoutAttachments = this._stripAttachments(content);
485+
return this._normalizeCommandContent(withoutAttachments);
389486
} else if (Array.isArray(content)) {
390-
return content.map(block => {
487+
const processedBlocks = content.map(block => {
391488
if (block.type === 'text') {
489+
const textBlock = block;
490+
const cleanedText = this._normalizeCommandContent(this._stripAttachments(textBlock.text));
392491
return {
393492
...block,
394-
text: this._stripAttachments((block as Anthropic.TextBlockParam).text)
493+
text: cleanedText
395494
};
396495
}
397496
return block;
398-
});
497+
}).filter(block => block.type !== 'text' || block.text.trim().length > 0);
498+
return processedBlocks;
399499
}
400500
return content;
401501
}

src/extension/agents/claude/node/test/claudeCodeSessionService.spec.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { URI } from '../../../../../util/vs/base/common/uri';
1919
import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';
2020
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
2121
import { ClaudeCodeSessionService } from '../claudeCodeSessionService';
22+
import { SDKUserMessage } from '@anthropic-ai/claude-code';
2223

2324
function computeFolderSlug(folderUri: URI): string {
2425
return folderUri.path.replace(/\//g, '-');
@@ -122,6 +123,37 @@ describe('ClaudeCodeSessionService', () => {
122123
`);
123124
});
124125

126+
it('filters meta user messages and normalizes command content', async () => {
127+
const fileName = '30530d66-37fb-4f3b-aa5f-d92b6a8afae2.jsonl';
128+
const fixturePath = path.resolve(__dirname, 'fixtures', fileName);
129+
const fileContents = await readFile(fixturePath, 'utf8');
130+
131+
mockFs.mockDirectory(dirUri, [[fileName, FileType.File]]);
132+
mockFs.mockFile(URI.joinPath(dirUri, fileName), fileContents, 1000);
133+
134+
const sessions = await service.getAllSessions(CancellationToken.None);
135+
136+
expect(sessions).toHaveLength(1);
137+
138+
const session = sessions[0];
139+
const metaUuid = 'e7f4ab9f-8e19-4262-a430-18d3e48b0c6c';
140+
141+
expect(session.messages.some(message => message.uuid === metaUuid)).toBe(false);
142+
143+
const commandUuid = 'a867fb32-ba62-4d51-917c-0fe40fa36067';
144+
const commandMessage = session.messages.find((message): message is SDKUserMessage => message.uuid === commandUuid && message.type === 'user');
145+
expect(commandMessage).toBeDefined();
146+
if (!commandMessage) {
147+
return;
148+
}
149+
150+
const commandContent = commandMessage.message.content;
151+
expect(typeof commandContent === 'string' ? commandContent : null).toBe('/init is analyzing your codebase…');
152+
const assistantUuid = '6ed016f4-0df4-4a9f-8c3b-82303b68d29e';
153+
const assistantMessage = session.messages.find(message => message.uuid === assistantUuid);
154+
expect((assistantMessage as { readonly parentUuid?: string | null } | undefined)?.parentUuid).toBe(commandUuid);
155+
});
156+
125157
it('handles empty directory correctly', async () => {
126158
mockFs.mockDirectory(dirUri, []);
127159

0 commit comments

Comments
 (0)