Skip to content

Commit 0444c85

Browse files
authored
edits: integrate claude code edits with our edit session model (#1699)
The extension side of microsoft/vscode#274025 (lockstep w/ main API is not required)
1 parent 018b457 commit 0444c85

File tree

12 files changed

+132
-108
lines changed

12 files changed

+132
-108
lines changed

src/extension/agents/claude/common/claudeTools.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import { PreToolUseHookInput } from '@anthropic-ai/claude-code';
7+
import { URI } from '../../../../util/vs/base/common/uri';
8+
69
export enum ClaudeToolNames {
710
Task = 'Task',
811
Bash = 'Bash',
@@ -39,3 +42,19 @@ export interface ITaskToolInput {
3942
readonly subagent_type: string;
4043
readonly prompt: string;
4144
}
45+
46+
export const claudeEditTools: readonly string[] = [ClaudeToolNames.Edit, ClaudeToolNames.MultiEdit, ClaudeToolNames.Write, ClaudeToolNames.NotebookEdit];
47+
48+
export function getAffectedUrisForEditTool(input: PreToolUseHookInput): URI[] {
49+
switch (input.tool_name) {
50+
case ClaudeToolNames.Edit:
51+
case ClaudeToolNames.MultiEdit:
52+
return [URI.file((input.tool_input as any).file_path)];
53+
case ClaudeToolNames.Write:
54+
return [URI.file((input.tool_input as any).file_path)];
55+
case ClaudeToolNames.NotebookEdit:
56+
return [URI.file((input.tool_input as any).notebook_path)];
57+
default:
58+
return [];
59+
}
60+
}

src/extension/agents/claude/common/toolInvocationFormatter.ts

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ export function createFormattedToolInvocation(
3535
} else if (toolUse.name === ClaudeToolNames.LS) {
3636
formatLSInvocation(invocation, toolUse);
3737
} else if (toolUse.name === ClaudeToolNames.Edit || toolUse.name === ClaudeToolNames.MultiEdit) {
38-
formatEditInvocation(invocation, toolUse);
38+
return; // edit diff is shown
3939
} else if (toolUse.name === ClaudeToolNames.Write) {
40-
formatWriteInvocation(invocation, toolUse);
40+
return; // edit diff is shown
4141
} else if (toolUse.name === ClaudeToolNames.ExitPlanMode) {
4242
formatExitPlanModeInvocation(invocation, toolUse);
4343
} else if (toolUse.name === ClaudeToolNames.Task) {
@@ -84,18 +84,6 @@ function formatLSInvocation(invocation: ChatToolInvocationPart, toolUse: Anthrop
8484
invocation.invocationMessage = new MarkdownString(l10n.t("Read {0}", display));
8585
}
8686

87-
function formatEditInvocation(invocation: ChatToolInvocationPart, toolUse: Anthropic.ToolUseBlock): void {
88-
const filePath: string = (toolUse.input as any)?.file_path ?? '';
89-
const display = filePath ? formatUriForMessage(filePath) : '';
90-
invocation.invocationMessage = new MarkdownString(l10n.t("Edited {0}", display));
91-
}
92-
93-
function formatWriteInvocation(invocation: ChatToolInvocationPart, toolUse: Anthropic.ToolUseBlock): void {
94-
const filePath: string = (toolUse.input as any)?.file_path ?? '';
95-
const display = filePath ? formatUriForMessage(filePath) : '';
96-
invocation.invocationMessage = new MarkdownString(l10n.t("Wrote {0}", display));
97-
}
98-
9987
function formatExitPlanModeInvocation(invocation: ChatToolInvocationPart, toolUse: Anthropic.ToolUseBlock): void {
10088
invocation.invocationMessage = `Here is Claude's plan:\n\n${(toolUse.input as IExitPlanModeInput)?.plan}`;
10189
}

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

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import { Options, Query, SDKAssistantMessage, SDKResultMessage, SDKUserMessage } from '@anthropic-ai/claude-code';
6+
import { HookInput, HookJSONOutput, Options, PreToolUseHookInput, Query, SDKAssistantMessage, SDKResultMessage, SDKUserMessage } from '@anthropic-ai/claude-code';
77
import Anthropic from '@anthropic-ai/sdk';
88
import type * as vscode from 'vscode';
99
import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
@@ -22,7 +22,7 @@ import { ToolName } from '../../../tools/common/toolNames';
2222
import { IToolsService } from '../../../tools/common/toolsService';
2323
import { isFileOkForTool } from '../../../tools/node/toolUtils';
2424
import { ILanguageModelServerConfig, LanguageModelServer } from '../../node/langModelServer';
25-
import { ClaudeToolNames, IExitPlanModeInput, ITodoWriteInput } from '../common/claudeTools';
25+
import { claudeEditTools, ClaudeToolNames, getAffectedUrisForEditTool, IExitPlanModeInput, ITodoWriteInput } from '../common/claudeTools';
2626
import { createFormattedToolInvocation } from '../common/toolInvocationFormatter';
2727
import { IClaudeCodeSdkService } from './claudeCodeSdkService';
2828

@@ -153,6 +153,7 @@ export class ClaudeCodeSession extends Disposable {
153153
private _currentRequest: CurrentRequest | undefined;
154154
private _pendingPrompt: DeferredPromise<QueuedRequest> | undefined;
155155
private _abortController = new AbortController();
156+
private _ongoingEdits = new Map<string | undefined, { complete: () => void; onDidComplete: Thenable<void> }>();
156157

157158
constructor(
158159
private readonly serverConfig: ILanguageModelServerConfig,
@@ -163,7 +164,8 @@ export class ClaudeCodeSession extends Disposable {
163164
@IEnvService private readonly envService: IEnvService,
164165
@IInstantiationService private readonly instantiationService: IInstantiationService,
165166
@IToolsService private readonly toolsService: IToolsService,
166-
@IClaudeCodeSdkService private readonly claudeCodeService: IClaudeCodeSdkService
167+
@IClaudeCodeSdkService private readonly claudeCodeService: IClaudeCodeSdkService,
168+
@ILogService private readonly _log: ILogService,
167169
) {
168170
super();
169171
}
@@ -195,7 +197,7 @@ export class ClaudeCodeSession extends Disposable {
195197
}
196198

197199
if (!this._queryGenerator) {
198-
await this._startSession();
200+
await this._startSession(token);
199201
}
200202

201203
// Add this request to the queue and wait for completion
@@ -232,7 +234,7 @@ export class ClaudeCodeSession extends Disposable {
232234
/**
233235
* Starts a new Claude Code session with the configured options
234236
*/
235-
private async _startSession(): Promise<void> {
237+
private async _startSession(token: vscode.CancellationToken): Promise<void> {
236238
// Build options for the Claude Code SDK
237239
// process.env.DEBUG = '1'; // debug messages from sdk.mjs
238240
const isDebugEnabled = this.configService.getConfig(ConfigKey.Internal.ClaudeCodeDebugEnabled);
@@ -252,6 +254,20 @@ export class ClaudeCodeSession extends Disposable {
252254
PATH: `${this.envService.appRoot}/node_modules/@vscode/ripgrep/bin${pathSep}${process.env.PATH}`
253255
},
254256
resume: this.sessionId,
257+
hooks: {
258+
PreToolUse: [
259+
{
260+
matcher: claudeEditTools.join('|'),
261+
hooks: [(input, toolID) => this._onWillEditTool(input, toolID, token)]
262+
}
263+
],
264+
PostToolUse: [
265+
{
266+
matcher: claudeEditTools.join('|'),
267+
hooks: [(input, toolID) => this._onDidEditTool(input, toolID)]
268+
}
269+
],
270+
},
255271
canUseTool: async (name, input) => {
256272
return this._currentRequest ?
257273
this.canUseTool(name, input, this._currentRequest.toolInvocationToken) :
@@ -270,6 +286,47 @@ export class ClaudeCodeSession extends Disposable {
270286
this._processMessages();
271287
}
272288

289+
private async _onWillEditTool(input: HookInput, toolUseID: string | undefined, token: CancellationToken): Promise<HookJSONOutput> {
290+
let uris: URI[] = [];
291+
try {
292+
uris = getAffectedUrisForEditTool(input as PreToolUseHookInput);
293+
} catch (error) {
294+
this._log.error('Error getting affected URIs for edit tool', error);
295+
}
296+
if (!uris.length || !this._currentRequest || token.isCancellationRequested) {
297+
return {};
298+
}
299+
300+
if (!this._currentRequest!.stream.externalEdit) {
301+
return {}; // back-compat during 1.106 insiders
302+
}
303+
304+
return new Promise(proceedWithEdit => {
305+
const deferred = new DeferredPromise<void>();
306+
const cancelListen = token.onCancellationRequested(() => {
307+
this._ongoingEdits.delete(toolUseID);
308+
deferred.complete();
309+
});
310+
const onDidComplete = this._currentRequest!.stream.externalEdit(uris, async () => {
311+
proceedWithEdit({});
312+
await deferred.p;
313+
cancelListen.dispose();
314+
});
315+
316+
this._ongoingEdits.set(toolUseID, { onDidComplete, complete: () => deferred.complete() });
317+
});
318+
}
319+
320+
private async _onDidEditTool(input: HookInput, toolUseID: string | undefined) {
321+
const ongoingEdit = this._ongoingEdits.get(toolUseID);
322+
if (ongoingEdit) {
323+
this._ongoingEdits.delete(toolUseID);
324+
ongoingEdit.complete();
325+
await ongoingEdit.onDidComplete;
326+
}
327+
return {};
328+
}
329+
273330
private async *_createPromptIterable(): AsyncIterable<SDKUserMessage> {
274331
while (true) {
275332
// Wait for a request to be available

src/extension/chatSessions/vscode-node/test/__snapshots__/claudeChatSessionContentProvider.spec.ts.snap

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,7 @@ exports[`ChatSessionContentProvider > loads real fixture file with tool invocati
7070
"type": "response",
7171
},
7272
{
73-
"parts": [
74-
{
75-
"invocationMessage": "Edited [](file:///Users/roblou/code/vscode-copilot-chat/src/extension/agents/claude/vscode-node/claudeCodeAgent.ts)",
76-
"isError": undefined,
77-
"toolCallId": "toolu_01NXDY5nya4UzHwUxPnhmQDX",
78-
"toolName": "Edit",
79-
"type": "tool",
80-
},
81-
],
73+
"parts": [],
8274
"type": "response",
8375
},
8476
{

src/extension/codeBlocks/node/codeBlockProcessor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export class CodeBlockTrackingChatResponseStream implements ChatResponseStream {
120120
reference2 = this.forward(this._wrapped.reference2.bind(this._wrapped));
121121
codeCitation = this.forward(this._wrapped.codeCitation.bind(this._wrapped));
122122
anchor = this.forward(this._wrapped.anchor.bind(this._wrapped));
123+
externalEdit = this.forward(this._wrapped.externalEdit.bind(this._wrapped));
123124
prepareToolInvocation = this.forward(this._wrapped.prepareToolInvocation.bind(this._wrapped));
124125
}
125126

src/extension/linkify/common/responseStreamWithLinkification.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ export class ResponseStreamWithLinkification implements FinalizableChatResponseS
9393
return this;
9494
}
9595

96+
externalEdit<T>(target: Uri | Uri[], callback: () => Thenable<T>): Thenable<T> {
97+
return this.enqueue(() => this._progress.externalEdit(target, callback), true);
98+
}
99+
96100
push(part: ChatResponsePart): ChatResponseStream {
97101
if (part instanceof ChatResponseMarkdownPart) {
98102
this.appendMarkdown(part.value);
@@ -157,14 +161,14 @@ export class ResponseStreamWithLinkification implements FinalizableChatResponseS
157161

158162
//#endregion
159163

160-
private sequencer: Promise<void> = Promise.resolve();
164+
private sequencer: Promise<unknown> = Promise.resolve();
161165

162-
private enqueue(f: () => any | Promise<any>, flush: boolean) {
166+
private enqueue<T>(f: () => T | Thenable<T>, flush: boolean) {
163167
if (flush) {
164168
this.sequencer = this.sequencer.then(() => this.doFinalize());
165169
}
166170
this.sequencer = this.sequencer.then(f);
167-
return this.sequencer;
171+
return this.sequencer as Promise<T>;
168172
}
169173

170174
private async appendMarkdown(md: MarkdownString): Promise<void> {

src/extension/tools/node/test/__snapshots__/getErrorsResult.spec.tsx.snap

Lines changed: 0 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -73,77 +73,3 @@ error 2
7373
</errors>
7474
"
7575
`;
76-
77-
exports[`GetErrorsTool > diagnostics with max 1`] = `
78-
"Showing first 1 results out of 2
79-
<errors path="/test/workspace/file.ts">
80-
This code at line 1
81-
\`\`\`
82-
line 1
83-
\`\`\`
84-
has the problem reported:
85-
<compileError>
86-
error
87-
</compileError>
88-
89-
</errors>
90-
"
91-
`;
92-
93-
exports[`GetErrorsTool > diagnostics with more complex max 1`] = `
94-
"Showing first 3 results out of 4
95-
<errors path="/test/workspace/file.ts">
96-
This code at line 1
97-
\`\`\`
98-
line 1
99-
\`\`\`
100-
has the problem reported:
101-
<compileError>
102-
error
103-
</compileError>
104-
This code at line 2
105-
\`\`\`
106-
line 2
107-
\`\`\`
108-
has the problem reported:
109-
<compileError>
110-
error 2
111-
</compileError>
112-
113-
</errors>
114-
<errors path="/test/workspace/file2.ts">
115-
This code at line 1
116-
\`\`\`
117-
line 1
118-
\`\`\`
119-
has the problem reported:
120-
<compileError>
121-
error
122-
</compileError>
123-
124-
</errors>
125-
"
126-
`;
127-
128-
exports[`GetErrorsTool > simple diagnostics 1`] = `
129-
"<errors path="/test/workspace/file.ts">
130-
This code at line 1
131-
\`\`\`
132-
line 1
133-
\`\`\`
134-
has the problem reported:
135-
<compileError>
136-
error
137-
</compileError>
138-
This code at line 2
139-
\`\`\`
140-
line 2
141-
\`\`\`
142-
has the problem reported:
143-
<compileError>
144-
error 2
145-
</compileError>
146-
147-
</errors>
148-
"
149-
`;

src/extension/vscode.proposed.chatParticipantAdditions.d.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,14 @@ declare module 'vscode' {
167167
constructor(value: ChatResponseDiffEntry[], title: string, readOnly?: boolean);
168168
}
169169

170-
export type ExtendedChatResponsePart = ChatResponsePart | ChatResponseTextEditPart | ChatResponseNotebookEditPart | ChatResponseConfirmationPart | ChatResponseCodeCitationPart | ChatResponseReferencePart2 | ChatResponseMovePart | ChatResponseExtensionsPart | ChatResponsePullRequestPart | ChatPrepareToolInvocationPart | ChatToolInvocationPart | ChatResponseMultiDiffPart | ChatResponseThinkingProgressPart;
170+
export class ChatResponseExternalEditPart {
171+
uris: Uri[];
172+
callback: () => Thenable<unknown>;
173+
applied: Thenable<void>;
174+
constructor(uris: Uri[], callback: () => Thenable<unknown>);
175+
}
176+
177+
export type ExtendedChatResponsePart = ChatResponsePart | ChatResponseTextEditPart | ChatResponseNotebookEditPart | ChatResponseConfirmationPart | ChatResponseCodeCitationPart | ChatResponseReferencePart2 | ChatResponseMovePart | ChatResponseExtensionsPart | ChatResponsePullRequestPart | ChatPrepareToolInvocationPart | ChatToolInvocationPart | ChatResponseMultiDiffPart | ChatResponseThinkingProgressPart | ChatResponseExternalEditPart;
171178
export class ChatResponseWarningPart {
172179
value: MarkdownString;
173180
constructor(value: string | MarkdownString);
@@ -301,6 +308,14 @@ declare module 'vscode' {
301308

302309
notebookEdit(target: Uri, isDone: true): void;
303310

311+
/**
312+
* Makes an external edit to one or more resources. Changes to the
313+
* resources made within the `callback` and before it resolves will be
314+
* tracked as agent edits. This can be used to track edits made from
315+
* external tools that don't generate simple {@link textEdit textEdits}.
316+
*/
317+
externalEdit<T>(target: Uri | Uri[], callback: () => Thenable<T>): Thenable<T>;
318+
304319
markdownWithVulnerabilities(value: string | MarkdownString, vulnerabilities: ChatVulnerability[]): void;
305320
codeblockUri(uri: Uri, isEdit?: boolean): void;
306321
push(part: ChatResponsePart | ChatResponseTextEditPart | ChatResponseWarningPart | ChatResponseProgressPart2): void;

src/util/common/chatResponseStreamImpl.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import { ChatResponseReferencePartStatusKind } from '@vscode/prompt-tsx';
77
import type { ChatResponseFileTree, ChatResponseStream, ChatVulnerability, Command, ExtendedChatResponsePart, Location, NotebookEdit, Progress, ThinkingDelta, Uri } from 'vscode';
8-
import { ChatPrepareToolInvocationPart, ChatResponseAnchorPart, ChatResponseClearToPreviousToolInvocationReason, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseFileTreePart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseWarningPart, MarkdownString, TextEdit } from '../../vscodeTypes';
8+
import { ChatPrepareToolInvocationPart, ChatResponseAnchorPart, ChatResponseClearToPreviousToolInvocationReason, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseExternalEditPart, ChatResponseFileTreePart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseWarningPart, MarkdownString, TextEdit } from '../../vscodeTypes';
99
import type { ThemeIcon } from '../vs/base/common/themables';
1010

1111

@@ -99,6 +99,12 @@ export class ChatResponseStreamImpl implements FinalizableChatResponseStream {
9999
this._push(new ChatResponseFileTreePart(value, baseUri));
100100
}
101101

102+
externalEdit<T>(target: Uri | Uri[], callback: () => Thenable<T>): Thenable<T> {
103+
const part = new ChatResponseExternalEditPart(target instanceof Array ? target : [target], callback);
104+
this._push(part);
105+
return part.applied as Thenable<T>;
106+
}
107+
102108
progress(value: string, task?: (progress: Progress<ChatResponseWarningPart | ChatResponseReferencePart>) => Thenable<string | void>): void {
103109
if (typeof task === 'undefined') {
104110
this._push(new ChatResponseProgressPart(value));

src/util/common/test/shims/chatTypes.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,20 @@ export class ChatResponseThinkingProgressPart {
5757
}
5858
}
5959

60+
export class ChatResponseExternalEditPart {
61+
applied: Thenable<void>;
62+
didGetApplied!: () => void;
63+
64+
constructor(
65+
public uris: vscode.Uri[],
66+
public callback: () => Thenable<unknown>,
67+
) {
68+
this.applied = new Promise<void>((resolve) => {
69+
this.didGetApplied = resolve;
70+
});
71+
}
72+
}
73+
6074
export class ChatResponseProgressPart2 {
6175
value: string;
6276
task?: (progress: vscode.Progress<vscode.ChatResponseWarningPart>) => Thenable<string | void>;

0 commit comments

Comments
 (0)