Skip to content

Commit 24982fe

Browse files
authored
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
1 parent 94c7bf8 commit 24982fe

6 files changed

Lines changed: 170 additions & 1 deletion

File tree

src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,45 @@ export function registerChatCopyActions() {
104104
}
105105
});
106106

107+
registerAction2(class CopyFinalResponseAction extends Action2 {
108+
constructor() {
109+
super({
110+
id: 'workbench.action.chat.copyFinalResponse',
111+
title: localize2('interactive.copyFinalResponse.label', "Copy Final Response"),
112+
f1: false,
113+
category: CHAT_CATEGORY,
114+
menu: {
115+
id: MenuId.ChatContext,
116+
when: ContextKeyExpr.and(ChatContextKeys.isResponse, ChatContextKeys.responseIsFiltered.negate()),
117+
group: 'copy',
118+
}
119+
});
120+
}
121+
122+
async run(accessor: ServicesAccessor, ...args: unknown[]) {
123+
const chatWidgetService = accessor.get(IChatWidgetService);
124+
const clipboardService = accessor.get(IClipboardService);
125+
126+
const widget = chatWidgetService.lastFocusedWidget;
127+
let item = args[0] as ChatTreeItem | undefined;
128+
if (!isChatTreeItem(item)) {
129+
item = widget?.getFocus();
130+
if (!item) {
131+
return;
132+
}
133+
}
134+
135+
if (!isResponseVM(item)) {
136+
return;
137+
}
138+
139+
const text = item.response.getFinalResponse();
140+
if (text) {
141+
await clipboardService.writeText(text);
142+
}
143+
}
144+
});
145+
107146
registerAction2(class CopyKatexMathSourceAction extends Action2 {
108147
constructor() {
109148
super({

src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,7 @@ export class ChatListWidget extends Disposable {
497497
const isKatexElement = target.closest(`.${katexContainerClassName}`) !== null;
498498

499499
const scopedContextKeyService = this.contextKeyService.createOverlay([
500+
[ChatContextKeys.isResponse.key, isResponseVM(selected)],
500501
[ChatContextKeys.responseIsFiltered.key, isResponseVM(selected) && !!selected.errorDetails?.responseIsFiltered],
501502
[ChatContextKeys.isKatexMathElement.key, isKatexElement]
502503
]);

src/vs/workbench/contrib/chat/common/model/chatModel.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ export type IChatProgressRenderableResponseContent = Exclude<IChatProgressRespon
234234
export interface IResponse {
235235
readonly value: ReadonlyArray<IChatProgressResponseContent>;
236236
getMarkdown(): string;
237+
getFinalResponse(): string;
237238
toString(): string;
238239
}
239240

@@ -471,6 +472,58 @@ class AbstractResponse implements IResponse {
471472
return this._markdownContent;
472473
}
473474

475+
/**
476+
* The trailing contiguous markdown/inline-reference content of the response,
477+
* skipping any trailing tool calls or empty markdown parts.
478+
*/
479+
getFinalResponse(): string {
480+
const parts = this._responseParts;
481+
// Walk backwards to find where the last contiguous markdown block starts.
482+
// Phase 1: skip trailing non-markdown parts and empty markdown.
483+
let i = parts.length - 1;
484+
while (i >= 0) {
485+
const part = parts[i];
486+
if (part.kind === 'markdownContent' || part.kind === 'markdownVuln') {
487+
if (part.content.value.length > 0) {
488+
break;
489+
}
490+
} else if (part.kind === 'inlineReference') {
491+
break;
492+
}
493+
i--;
494+
}
495+
496+
if (i < 0) {
497+
return '';
498+
}
499+
500+
// Phase 2: collect contiguous markdown/inline-reference parts going backwards.
501+
const end = i;
502+
while (i >= 0) {
503+
const part = parts[i];
504+
if (part.kind === 'markdownContent' || part.kind === 'markdownVuln' || part.kind === 'inlineReference') {
505+
i--;
506+
} else {
507+
break;
508+
}
509+
}
510+
const start = i + 1;
511+
512+
// Combine the collected parts.
513+
const segments: string[] = [];
514+
for (let j = start; j <= end; j++) {
515+
const part = parts[j];
516+
if (part.kind === 'inlineReference') {
517+
segments.push(this.inlineRefToRepr(part));
518+
} else if (part.kind === 'markdownContent' || part.kind === 'markdownVuln') {
519+
if (part.content.value.length > 0) {
520+
segments.push(part.content.value);
521+
}
522+
}
523+
}
524+
return segments.join('');
525+
}
526+
474527
/**
475528
* Invalidate cached representations so they are recomputed on next access.
476529
*/

src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionApprovalModel.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ function makeExecutingState(): IChatToolInvocation.State {
7777
/** Creates a minimal mock that satisfies the response chain: lastRequest.response.response.value */
7878
function mockModelWithResponse(model: MockChatModel, parts: IChatProgressResponseContent[]): void {
7979
const response: Partial<IChatResponseModel> = {
80-
response: { value: parts, getMarkdown: () => '', toString: () => '' } satisfies IResponse,
80+
response: { value: parts, getMarkdown: () => '', getFinalResponse: () => '', toString: () => '' } satisfies IResponse,
8181
};
8282
const request: Partial<IChatRequestModel> = {
8383
response: response as IChatResponseModel,

src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsController.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ function createMockChatModel(options: {
7373
response: {
7474
value: [],
7575
getMarkdown: () => '',
76+
getFinalResponse: () => '',
7677
toString: () => options.customTitle ? '' : 'Test response content'
7778
}
7879
};

src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,81 @@ suite('Response', () => {
612612
assert.deepStrictEqual(response.value[0].toolSpecificData, toolSpecificData);
613613
assert.strictEqual(IChatToolInvocation.isComplete(response.value[0]), true);
614614
});
615+
616+
test('getFinalResponse returns last contiguous markdown after tool call', () => {
617+
const response = store.add(new Response([]));
618+
response.updateContent({ content: new MarkdownString('Early text'), kind: 'markdownContent' });
619+
response.updateContent({
620+
kind: 'externalToolInvocationUpdate',
621+
toolCallId: 'tool-1',
622+
toolName: 'some_tool',
623+
isComplete: true,
624+
invocationMessage: 'Ran tool',
625+
});
626+
response.updateContent({ content: new MarkdownString('Final text'), kind: 'markdownContent' });
627+
628+
assert.strictEqual(response.getFinalResponse(), 'Final text');
629+
});
630+
631+
test('getFinalResponse skips trailing empty markdown and tool calls', () => {
632+
const response = store.add(new Response([]));
633+
response.updateContent({ content: new MarkdownString('Before tool'), kind: 'markdownContent' });
634+
response.updateContent({
635+
kind: 'externalToolInvocationUpdate',
636+
toolCallId: 'tool-1',
637+
toolName: 'some_tool',
638+
isComplete: true,
639+
invocationMessage: 'Ran tool',
640+
});
641+
response.updateContent({ content: new MarkdownString('The answer is 42.'), kind: 'markdownContent' });
642+
response.updateContent({
643+
kind: 'externalToolInvocationUpdate',
644+
toolCallId: 'tool-2',
645+
toolName: 'some_tool',
646+
isComplete: true,
647+
invocationMessage: 'Ran another tool',
648+
});
649+
response.updateContent({ content: new MarkdownString(''), kind: 'markdownContent' });
650+
651+
assert.strictEqual(response.getFinalResponse(), 'The answer is 42.');
652+
});
653+
654+
test('getFinalResponse includes inline references in final block', () => {
655+
const response = store.add(new Response([]));
656+
response.updateContent({
657+
kind: 'externalToolInvocationUpdate',
658+
toolCallId: 'tool-1',
659+
toolName: 'some_tool',
660+
isComplete: true,
661+
invocationMessage: 'Ran tool',
662+
});
663+
response.updateContent({ content: new MarkdownString('See '), kind: 'markdownContent' });
664+
response.updateContent({ inlineReference: URI.parse('https://example.com/'), kind: 'inlineReference' });
665+
response.updateContent({ content: new MarkdownString(' for details.'), kind: 'markdownContent' });
666+
667+
assert.strictEqual(response.getFinalResponse(), 'See https://example.com/ for details.');
668+
});
669+
670+
test('getFinalResponse returns empty string when no markdown', () => {
671+
const response = store.add(new Response([]));
672+
response.updateContent({
673+
kind: 'externalToolInvocationUpdate',
674+
toolCallId: 'tool-1',
675+
toolName: 'some_tool',
676+
isComplete: true,
677+
invocationMessage: 'Ran tool',
678+
});
679+
680+
assert.strictEqual(response.getFinalResponse(), '');
681+
});
682+
683+
test('getFinalResponse returns all markdown when there are no tool calls', () => {
684+
const response = store.add(new Response([]));
685+
response.updateContent({ content: new MarkdownString('Hello '), kind: 'markdownContent' });
686+
response.updateContent({ content: new MarkdownString('World'), kind: 'markdownContent' });
687+
688+
assert.strictEqual(response.getFinalResponse(), 'Hello World');
689+
});
615690
});
616691

617692
suite('normalizeSerializableChatData', () => {

0 commit comments

Comments
 (0)