mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-02 08:15:56 +01:00
chat: add "Copy Final Response" context menu action (#306184)
* feat: add getFinalResponse method to IResponse and implement CopyFinalResponseAction * feat: add context key for response view model in ChatListWidget
This commit is contained in:
@@ -104,6 +104,45 @@ export function registerChatCopyActions() {
|
||||
}
|
||||
});
|
||||
|
||||
registerAction2(class CopyFinalResponseAction extends Action2 {
|
||||
constructor() {
|
||||
super({
|
||||
id: 'workbench.action.chat.copyFinalResponse',
|
||||
title: localize2('interactive.copyFinalResponse.label', "Copy Final Response"),
|
||||
f1: false,
|
||||
category: CHAT_CATEGORY,
|
||||
menu: {
|
||||
id: MenuId.ChatContext,
|
||||
when: ContextKeyExpr.and(ChatContextKeys.isResponse, ChatContextKeys.responseIsFiltered.negate()),
|
||||
group: 'copy',
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async run(accessor: ServicesAccessor, ...args: unknown[]) {
|
||||
const chatWidgetService = accessor.get(IChatWidgetService);
|
||||
const clipboardService = accessor.get(IClipboardService);
|
||||
|
||||
const widget = chatWidgetService.lastFocusedWidget;
|
||||
let item = args[0] as ChatTreeItem | undefined;
|
||||
if (!isChatTreeItem(item)) {
|
||||
item = widget?.getFocus();
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isResponseVM(item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = item.response.getFinalResponse();
|
||||
if (text) {
|
||||
await clipboardService.writeText(text);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
registerAction2(class CopyKatexMathSourceAction extends Action2 {
|
||||
constructor() {
|
||||
super({
|
||||
|
||||
@@ -497,6 +497,7 @@ export class ChatListWidget extends Disposable {
|
||||
const isKatexElement = target.closest(`.${katexContainerClassName}`) !== null;
|
||||
|
||||
const scopedContextKeyService = this.contextKeyService.createOverlay([
|
||||
[ChatContextKeys.isResponse.key, isResponseVM(selected)],
|
||||
[ChatContextKeys.responseIsFiltered.key, isResponseVM(selected) && !!selected.errorDetails?.responseIsFiltered],
|
||||
[ChatContextKeys.isKatexMathElement.key, isKatexElement]
|
||||
]);
|
||||
|
||||
@@ -234,6 +234,7 @@ export type IChatProgressRenderableResponseContent = Exclude<IChatProgressRespon
|
||||
export interface IResponse {
|
||||
readonly value: ReadonlyArray<IChatProgressResponseContent>;
|
||||
getMarkdown(): string;
|
||||
getFinalResponse(): string;
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
@@ -471,6 +472,58 @@ class AbstractResponse implements IResponse {
|
||||
return this._markdownContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* The trailing contiguous markdown/inline-reference content of the response,
|
||||
* skipping any trailing tool calls or empty markdown parts.
|
||||
*/
|
||||
getFinalResponse(): string {
|
||||
const parts = this._responseParts;
|
||||
// Walk backwards to find where the last contiguous markdown block starts.
|
||||
// Phase 1: skip trailing non-markdown parts and empty markdown.
|
||||
let i = parts.length - 1;
|
||||
while (i >= 0) {
|
||||
const part = parts[i];
|
||||
if (part.kind === 'markdownContent' || part.kind === 'markdownVuln') {
|
||||
if (part.content.value.length > 0) {
|
||||
break;
|
||||
}
|
||||
} else if (part.kind === 'inlineReference') {
|
||||
break;
|
||||
}
|
||||
i--;
|
||||
}
|
||||
|
||||
if (i < 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Phase 2: collect contiguous markdown/inline-reference parts going backwards.
|
||||
const end = i;
|
||||
while (i >= 0) {
|
||||
const part = parts[i];
|
||||
if (part.kind === 'markdownContent' || part.kind === 'markdownVuln' || part.kind === 'inlineReference') {
|
||||
i--;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const start = i + 1;
|
||||
|
||||
// Combine the collected parts.
|
||||
const segments: string[] = [];
|
||||
for (let j = start; j <= end; j++) {
|
||||
const part = parts[j];
|
||||
if (part.kind === 'inlineReference') {
|
||||
segments.push(this.inlineRefToRepr(part));
|
||||
} else if (part.kind === 'markdownContent' || part.kind === 'markdownVuln') {
|
||||
if (part.content.value.length > 0) {
|
||||
segments.push(part.content.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return segments.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached representations so they are recomputed on next access.
|
||||
*/
|
||||
|
||||
@@ -77,7 +77,7 @@ function makeExecutingState(): IChatToolInvocation.State {
|
||||
/** Creates a minimal mock that satisfies the response chain: lastRequest.response.response.value */
|
||||
function mockModelWithResponse(model: MockChatModel, parts: IChatProgressResponseContent[]): void {
|
||||
const response: Partial<IChatResponseModel> = {
|
||||
response: { value: parts, getMarkdown: () => '', toString: () => '' } satisfies IResponse,
|
||||
response: { value: parts, getMarkdown: () => '', getFinalResponse: () => '', toString: () => '' } satisfies IResponse,
|
||||
};
|
||||
const request: Partial<IChatRequestModel> = {
|
||||
response: response as IChatResponseModel,
|
||||
|
||||
@@ -73,6 +73,7 @@ function createMockChatModel(options: {
|
||||
response: {
|
||||
value: [],
|
||||
getMarkdown: () => '',
|
||||
getFinalResponse: () => '',
|
||||
toString: () => options.customTitle ? '' : 'Test response content'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -612,6 +612,81 @@ suite('Response', () => {
|
||||
assert.deepStrictEqual(response.value[0].toolSpecificData, toolSpecificData);
|
||||
assert.strictEqual(IChatToolInvocation.isComplete(response.value[0]), true);
|
||||
});
|
||||
|
||||
test('getFinalResponse returns last contiguous markdown after tool call', () => {
|
||||
const response = store.add(new Response([]));
|
||||
response.updateContent({ content: new MarkdownString('Early text'), kind: 'markdownContent' });
|
||||
response.updateContent({
|
||||
kind: 'externalToolInvocationUpdate',
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'some_tool',
|
||||
isComplete: true,
|
||||
invocationMessage: 'Ran tool',
|
||||
});
|
||||
response.updateContent({ content: new MarkdownString('Final text'), kind: 'markdownContent' });
|
||||
|
||||
assert.strictEqual(response.getFinalResponse(), 'Final text');
|
||||
});
|
||||
|
||||
test('getFinalResponse skips trailing empty markdown and tool calls', () => {
|
||||
const response = store.add(new Response([]));
|
||||
response.updateContent({ content: new MarkdownString('Before tool'), kind: 'markdownContent' });
|
||||
response.updateContent({
|
||||
kind: 'externalToolInvocationUpdate',
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'some_tool',
|
||||
isComplete: true,
|
||||
invocationMessage: 'Ran tool',
|
||||
});
|
||||
response.updateContent({ content: new MarkdownString('The answer is 42.'), kind: 'markdownContent' });
|
||||
response.updateContent({
|
||||
kind: 'externalToolInvocationUpdate',
|
||||
toolCallId: 'tool-2',
|
||||
toolName: 'some_tool',
|
||||
isComplete: true,
|
||||
invocationMessage: 'Ran another tool',
|
||||
});
|
||||
response.updateContent({ content: new MarkdownString(''), kind: 'markdownContent' });
|
||||
|
||||
assert.strictEqual(response.getFinalResponse(), 'The answer is 42.');
|
||||
});
|
||||
|
||||
test('getFinalResponse includes inline references in final block', () => {
|
||||
const response = store.add(new Response([]));
|
||||
response.updateContent({
|
||||
kind: 'externalToolInvocationUpdate',
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'some_tool',
|
||||
isComplete: true,
|
||||
invocationMessage: 'Ran tool',
|
||||
});
|
||||
response.updateContent({ content: new MarkdownString('See '), kind: 'markdownContent' });
|
||||
response.updateContent({ inlineReference: URI.parse('https://example.com/'), kind: 'inlineReference' });
|
||||
response.updateContent({ content: new MarkdownString(' for details.'), kind: 'markdownContent' });
|
||||
|
||||
assert.strictEqual(response.getFinalResponse(), 'See https://example.com/ for details.');
|
||||
});
|
||||
|
||||
test('getFinalResponse returns empty string when no markdown', () => {
|
||||
const response = store.add(new Response([]));
|
||||
response.updateContent({
|
||||
kind: 'externalToolInvocationUpdate',
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'some_tool',
|
||||
isComplete: true,
|
||||
invocationMessage: 'Ran tool',
|
||||
});
|
||||
|
||||
assert.strictEqual(response.getFinalResponse(), '');
|
||||
});
|
||||
|
||||
test('getFinalResponse returns all markdown when there are no tool calls', () => {
|
||||
const response = store.add(new Response([]));
|
||||
response.updateContent({ content: new MarkdownString('Hello '), kind: 'markdownContent' });
|
||||
response.updateContent({ content: new MarkdownString('World'), kind: 'markdownContent' });
|
||||
|
||||
assert.strictEqual(response.getFinalResponse(), 'Hello World');
|
||||
});
|
||||
});
|
||||
|
||||
suite('normalizeSerializableChatData', () => {
|
||||
|
||||
Reference in New Issue
Block a user