Skip to content

Commit 3fea6c5

Browse files
authored
Support file linking rendering and fix terminal tool data (#7455)
1 parent 79b88e7 commit 3fea6c5

File tree

4 files changed

+141
-61
lines changed

4 files changed

+141
-61
lines changed

common/sessionParsing.ts

Lines changed: 117 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,65 @@ export interface ParsedToolCallDetails {
5454
invocationMessage: string;
5555
pastTenseMessage?: string;
5656
originMessage?: string;
57-
toolSpecificData?: any;
57+
toolSpecificData?: StrReplaceEditorToolData | BashToolData;
58+
}
59+
60+
export interface StrReplaceEditorToolData {
61+
command: 'view' | 'edit' | string;
62+
filePath?: string;
63+
fileLabel?: string;
64+
parsedContent?: { content: string; fileA: string | undefined; fileB: string | undefined; };
65+
}
66+
67+
export interface BashToolData {
68+
commandLine: {
69+
original: string;
70+
};
71+
language: 'bash';
72+
}
73+
74+
/**
75+
* Parse diff content and extract file information
76+
*/
77+
export function parseDiff(content: string): { content: string; fileA: string | undefined; fileB: string | undefined; } | undefined {
78+
const lines = content.split(/\r?\n/g);
79+
let fileA: string | undefined;
80+
let fileB: string | undefined;
81+
82+
let startDiffLineIndex = -1;
83+
for (let i = 0; i < lines.length; i++) {
84+
const line = lines[i];
85+
if (line.startsWith('diff --git')) {
86+
const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
87+
if (match) {
88+
fileA = match[1];
89+
fileB = match[2];
90+
}
91+
} else if (line.startsWith('@@ ')) {
92+
startDiffLineIndex = i + 1;
93+
break;
94+
}
95+
}
96+
if (startDiffLineIndex < 0) {
97+
return undefined;
98+
}
99+
100+
return {
101+
content: lines.slice(startDiffLineIndex).join('\n'),
102+
fileA: typeof fileA === 'string' ? '/' + fileA : undefined,
103+
fileB: typeof fileB === 'string' ? '/' + fileB : undefined
104+
};
105+
}
106+
107+
108+
109+
/**
110+
* Convert absolute file path to relative file label
111+
* File paths are absolute and look like: `/home/runner/work/repo/repo/<path>`
112+
*/
113+
export function toFileLabel(file: string): string {
114+
const parts = file.split('/');
115+
return parts.slice(6).join('/');
58116
}
59117

60118
/**
@@ -80,16 +138,64 @@ export function parseToolCallDetails(
80138

81139
if (name === 'str_replace_editor') {
82140
if (args.command === 'view') {
83-
return {
84-
toolName: args.path ? `View ${args.path}` : 'View repository',
85-
invocationMessage: `View ${args.path}`,
86-
pastTenseMessage: `View ${args.path}`
87-
};
141+
const parsedContent = parseDiff(content);
142+
if (parsedContent) {
143+
const file = parsedContent.fileA ?? parsedContent.fileB;
144+
const fileLabel = file && toFileLabel(file);
145+
return {
146+
toolName: fileLabel === '' ? 'View repository' : 'View',
147+
invocationMessage: fileLabel ? `View [](${fileLabel})` : 'View repository',
148+
pastTenseMessage: fileLabel ? `View [](${fileLabel})` : 'View repository',
149+
toolSpecificData: fileLabel ? {
150+
command: 'view',
151+
filePath: file,
152+
fileLabel: fileLabel,
153+
parsedContent: parsedContent
154+
} : undefined
155+
};
156+
} else {
157+
const filePath = args.path;
158+
let fileLabel = filePath ? toFileLabel(filePath) : undefined;
159+
160+
if (fileLabel === undefined) {
161+
fileLabel = filePath;
162+
163+
return {
164+
toolName: fileLabel ? `View ${fileLabel}` : 'View repository',
165+
invocationMessage: fileLabel ? `View ${fileLabel}` : 'View repository',
166+
pastTenseMessage: fileLabel ? `View ${fileLabel}` : 'View repository',
167+
};
168+
} else if (fileLabel === '') {
169+
return {
170+
toolName: 'View repository',
171+
invocationMessage: 'View repository',
172+
pastTenseMessage: 'View repository',
173+
};
174+
} else {
175+
return {
176+
toolName: `View`,
177+
invocationMessage: `View ${fileLabel}`,
178+
pastTenseMessage: `View ${fileLabel}`,
179+
toolSpecificData: {
180+
command: 'view',
181+
filePath: filePath,
182+
fileLabel: fileLabel
183+
}
184+
};
185+
}
186+
}
88187
} else {
188+
const filePath = args.path;
189+
const fileLabel = filePath && toFileLabel(filePath);
89190
return {
90191
toolName: 'Edit',
91-
invocationMessage: `Edit: ${args.path}`,
92-
pastTenseMessage: `Edit: ${args.path}`
192+
invocationMessage: fileLabel ? `Edit [](${fileLabel})` : 'Edit',
193+
pastTenseMessage: fileLabel ? `Edit [](${fileLabel})` : 'Edit',
194+
toolSpecificData: fileLabel ? {
195+
command: args.command || 'edit',
196+
filePath: filePath,
197+
fileLabel: fileLabel
198+
} : undefined
93199
};
94200
}
95201
} else if (name === 'think') {
@@ -100,7 +206,7 @@ export function parseToolCallDetails(
100206
} else if (name === 'report_progress') {
101207
const details: ParsedToolCallDetails = {
102208
toolName: 'Progress Update',
103-
invocationMessage: args.prDescription || content
209+
invocationMessage: `\`\`\`\n${args.prDescription}\`\`\`` || content
104210
};
105211
if (args.commitMessage) {
106212
details.originMessage = `Commit: ${args.commitMessage}`;
@@ -116,12 +222,13 @@ export function parseToolCallDetails(
116222

117223
// Use the terminal-specific data for bash commands
118224
if (args.command) {
119-
details.toolSpecificData = {
225+
const bashToolData: BashToolData = {
120226
commandLine: {
121227
original: args.command,
122228
},
123229
language: 'bash'
124230
};
231+
details.toolSpecificData = bashToolData;
125232
}
126233
return details;
127234
} else {

src/github/copilotRemoteAgent.ts

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

6+
import * as nodePath from 'path';
67
import vscode from 'vscode';
78
import { parseSessionLogs, parseToolCallDetails } from '../../common/sessionParsing';
89
import { Repository } from '../api/api';
@@ -1095,7 +1096,7 @@ export class CopilotRemoteAgentManager extends Disposable {
10951096
};
10961097
}
10971098

1098-
private async streamNewLogContent(stream: vscode.ChatResponseStream, newLogContent: string): Promise<{ hasStreamedContent: boolean; hasSetupStepProgress: boolean }> {
1099+
private async streamNewLogContent(pullRequest: PullRequestModel, stream: vscode.ChatResponseStream, newLogContent: string): Promise<{ hasStreamedContent: boolean; hasSetupStepProgress: boolean }> {
10991100
try {
11001101
if (!newLogContent.trim()) {
11011102
return { hasStreamedContent: false, hasSetupStepProgress: false };
@@ -1123,7 +1124,7 @@ export class CopilotRemoteAgentManager extends Disposable {
11231124

11241125
if (delta.content && delta.content.trim()) {
11251126
// Finished setup step - create/update tool part
1126-
const toolPart = this.createToolInvocationPart(toolCall, args.name || delta.content);
1127+
const toolPart = this.createToolInvocationPart(pullRequest, toolCall, args.name || delta.content);
11271128
if (toolPart) {
11281129
stream.push(toolPart);
11291130
hasStreamedContent = true;
@@ -1143,7 +1144,7 @@ export class CopilotRemoteAgentManager extends Disposable {
11431144

11441145
if (delta.tool_calls) {
11451146
for (const toolCall of delta.tool_calls) {
1146-
const toolPart = this.createToolInvocationPart(toolCall, delta.content || '');
1147+
const toolPart = this.createToolInvocationPart(pullRequest, toolCall, delta.content || '');
11471148
if (toolPart) {
11481149
stream.push(toolPart);
11491150
hasStreamedContent = true;
@@ -1242,7 +1243,7 @@ export class CopilotRemoteAgentManager extends Disposable {
12421243
if (sessionInfo.state !== 'in_progress') {
12431244
if (logs.length > lastProcessedLength) {
12441245
const newLogContent = logs.slice(lastProcessedLength);
1245-
const streamResult = await this.streamNewLogContent(stream, newLogContent);
1246+
const streamResult = await this.streamNewLogContent(pullRequest, stream, newLogContent);
12461247
if (streamResult.hasStreamedContent) {
12471248
hasActiveProgress = false;
12481249
}
@@ -1255,7 +1256,7 @@ export class CopilotRemoteAgentManager extends Disposable {
12551256
if (logs.length > lastLogLength) {
12561257
Logger.appendLine(`New logs detected, attempting to stream content`, CopilotRemoteAgentManager.ID);
12571258
const newLogContent = logs.slice(lastProcessedLength);
1258-
const streamResult = await this.streamNewLogContent(stream, newLogContent);
1259+
const streamResult = await this.streamNewLogContent(pullRequest, stream, newLogContent);
12591260
lastProcessedLength = logs.length;
12601261

12611262
if (streamResult.hasStreamedContent) {
@@ -1353,7 +1354,7 @@ export class CopilotRemoteAgentManager extends Disposable {
13531354
return undefined;
13541355
}
13551356

1356-
private createToolInvocationPart(toolCall: any, deltaContent: string = ''): vscode.ChatToolInvocationPart | undefined {
1357+
private createToolInvocationPart(pullRequest: PullRequestModel, toolCall: any, deltaContent: string = ''): vscode.ChatToolInvocationPart | undefined {
13571358
if (!toolCall.function?.name || !toolCall.id) {
13581359
return undefined;
13591360
}
@@ -1375,17 +1376,26 @@ export class CopilotRemoteAgentManager extends Disposable {
13751376
if (toolCall.function.name === 'bash') {
13761377
toolPart.invocationMessage = new vscode.MarkdownString(`\`\`\`bash\n${toolDetails.invocationMessage}\n\`\`\``);
13771378
} else {
1378-
toolPart.invocationMessage = toolDetails.invocationMessage;
1379+
toolPart.invocationMessage = new vscode.MarkdownString(toolDetails.invocationMessage);
13791380
}
13801381

13811382
if (toolDetails.pastTenseMessage) {
1382-
toolPart.pastTenseMessage = toolDetails.pastTenseMessage;
1383+
toolPart.pastTenseMessage = new vscode.MarkdownString(toolDetails.pastTenseMessage);
13831384
}
13841385
if (toolDetails.originMessage) {
1385-
toolPart.originMessage = toolDetails.originMessage;
1386+
toolPart.originMessage = new vscode.MarkdownString(toolDetails.originMessage);
13861387
}
13871388
if (toolDetails.toolSpecificData) {
1388-
toolPart.toolSpecificData = toolDetails.toolSpecificData;
1389+
if ('command' in toolDetails.toolSpecificData) {
1390+
if ((toolDetails.toolSpecificData.command === 'view' || toolDetails.toolSpecificData.command === 'edit') && toolDetails.toolSpecificData.fileLabel) {
1391+
const uri = vscode.Uri.file(nodePath.join(pullRequest.githubRepository.rootUri.fsPath, toolDetails.toolSpecificData.fileLabel));
1392+
toolPart.invocationMessage = new vscode.MarkdownString(`${toolPart.toolName} [](${uri.toString()})`);
1393+
toolPart.invocationMessage.supportHtml = true;
1394+
toolPart.pastTenseMessage = new vscode.MarkdownString(`${toolPart.toolName} [](${uri.toString()})`);
1395+
}
1396+
} else {
1397+
toolPart.toolSpecificData = toolDetails.toolSpecificData;
1398+
}
13891399
}
13901400
} catch (error) {
13911401
toolPart.toolName = toolCall.function.name || 'unknown';
@@ -1425,7 +1435,7 @@ export class CopilotRemoteAgentManager extends Disposable {
14251435
currentResponseContent = '';
14261436
}
14271437

1428-
const toolPart = this.createToolInvocationPart(toolCall, args.name || delta.content);
1438+
const toolPart = this.createToolInvocationPart(pullRequest, toolCall, args.name || delta.content);
14291439
if (toolPart) {
14301440
responseParts.push(toolPart);
14311441
}
@@ -1446,7 +1456,7 @@ export class CopilotRemoteAgentManager extends Disposable {
14461456
}
14471457

14481458
for (const toolCall of delta.tool_calls) {
1449-
const toolPart = this.createToolInvocationPart(toolCall, delta.content || '');
1459+
const toolPart = this.createToolInvocationPart(pullRequest, toolCall, delta.content || '');
14501460
if (toolPart) {
14511461
responseParts.push(toolPart);
14521462
}

webviews/sessionLogView/sessionView.tsx

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ import * as monaco from 'monaco-editor/esm/vs/editor/editor.main';
99
import * as React from 'react';
1010
import * as ReactDOM from 'react-dom';
1111
import { Temporal } from 'temporal-polyfill';
12-
import { SessionResponseLogChunk } from '../../common/sessionParsing';
12+
import { parseDiff, SessionResponseLogChunk, toFileLabel } from '../../common/sessionParsing';
1313
import { vscode } from '../common/message';
1414
import { CodeView } from './codeView';
1515
import './index.css'; // Create this file for styling
1616
import { PullInfo } from './messages';
17-
import { parseDiff, type SessionInfo, type SessionSetupStepResponse } from './sessionsApi';
17+
import { type SessionInfo, type SessionSetupStepResponse } from './sessionsApi';
1818

1919
interface SessionViewProps {
2020
readonly pullInfo: PullInfo | undefined;
@@ -280,13 +280,6 @@ function getLanguageForResource(filePath: string): string | undefined {
280280
return undefined;
281281
}
282282

283-
284-
function toFileLabel(file: string): string {
285-
// File paths are absolute and look like: `/home/runner/work/repo/repo/<path>`
286-
const parts = file.split('/');
287-
return parts.slice(6).join('/');
288-
}
289-
290283
// Setup Stage Log component
291284
interface SetupStageLogProps {
292285
readonly setupSteps: readonly SessionSetupStepResponse[];

webviews/sessionLogView/sessionsApi.ts

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -29,33 +29,3 @@ export interface SessionInfo {
2929
error: string | null;
3030
}
3131

32-
export function parseDiff(content: string): { content: string; fileA: string | undefined; fileB: string | undefined; } | undefined {
33-
const lines = content.split(/\r?\n/g);
34-
let fileA: string | undefined;
35-
let fileB: string | undefined;
36-
37-
let startDiffLineIndex = -1;
38-
for (let i = 0; i < lines.length; i++) {
39-
const line = lines[i];
40-
if (line.startsWith('diff --git')) {
41-
const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
42-
if (match) {
43-
fileA = match[1];
44-
fileB = match[2];
45-
}
46-
} else if (line.startsWith('@@ ')) {
47-
startDiffLineIndex = i + 1;
48-
break;
49-
}
50-
}
51-
if (startDiffLineIndex < 0) {
52-
return undefined;
53-
}
54-
55-
return {
56-
content: lines.slice(startDiffLineIndex).join('\n'),
57-
fileA: typeof fileA === 'string' ? '/' + fileA : undefined,
58-
fileB: typeof fileB === 'string' ? '/' + fileB : undefined
59-
};
60-
}
61-

0 commit comments

Comments
 (0)