Skip to content

Commit e74c935

Browse files
Copilotjoshspicer
andauthored
Implement CodeLens for TODO comments to improve action visibility (#7954)
* Initial plan * Initial plan for implementing CodeLens for TODO comments Co-authored-by: joshspicer <[email protected]> * Implement CodeLens provider for TODO comments Co-authored-by: joshspicer <[email protected]> * Refactor: Extract common TODO detection logic to reduce duplication Co-authored-by: joshspicer <[email protected]> * docs: Update IssueFeatures.md to document CodeLens functionality Co-authored-by: joshspicer <[email protected]> * revert dts changes picked up * polish * polish 2 * polish 3 * Address code review feedback: remove unused parameter and rename function Co-authored-by: joshspicer <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: joshspicer <[email protected]>
1 parent 78f1ff9 commit e74c935

File tree

9 files changed

+226
-62
lines changed

9 files changed

+226
-62
lines changed

documentation/IssueFeatures.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
We've added some experimental GitHub issue features.
22

3-
# Code actions
3+
# Code actions and CodeLens
44

5-
Wherever there is a `TODO` comment in your code, the **Create Issue from Comment** code action will show. This takes your text selection, and creates a GitHub issue with the selection as a permalink in the issue body. It also inserts the issue number after the `TODO`.
5+
Wherever there is a `TODO` comment in your code, two actions are available:
6+
7+
1. **CodeLens**: Clickable actions appear directly above the TODO comment line for quick access
8+
2. **Code actions**: The same actions are available via the lightbulb quick fix menu
9+
10+
Both provide two options:
11+
- **Create Issue from Comment**: Takes your text selection and creates a GitHub issue with the selection as a permalink in the issue body. It also inserts the issue number after the `TODO`.
12+
- **Delegate to coding agent**: Starts a Copilot coding agent session to work on the TODO task (when available)
613

714
![Create Issue from Comment](images/createIssueFromComment.gif)
815

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,11 @@
616616
],
617617
"description": "%githubIssues.createIssueTriggers.description%"
618618
},
619+
"githubPullRequests.codingAgent.codeLens": {
620+
"type": "boolean",
621+
"default": true,
622+
"description": "%githubPullRequests.codingAgent.codeLens.description%"
623+
},
619624
"githubIssues.createInsertFormat": {
620625
"type": "string",
621626
"enum": [

package.nls.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@
105105
"githubIssues.ignoreMilestones.description": "An array of milestones titles to never show issues from.",
106106
"githubIssues.createIssueTriggers.description": "Strings that will cause the 'Create issue from comment' code action to show.",
107107
"githubIssues.createIssueTriggers.items": "String that enables the 'Create issue from comment' code action. Should not contain whitespace.",
108+
"githubPullRequests.codingAgent.codeLens.description": "Show CodeLens actions above TODO comments for delegating to coding agent.",
108109
"githubIssues.createInsertFormat.description": "Controls whether an issue number (ex. #1234) or a full url (ex. https://github.com/owner/name/issues/1234) is inserted when the Create Issue code action is run.",
109110
"githubIssues.issueCompletions.enabled.description": "Controls whether completion suggestions are shown for issues.",
110111
"githubIssues.userCompletions.enabled.description": "Controls whether completion suggestions are shown for users.",

src/common/settingKeys.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,5 @@ export const COLOR_THEME = 'colorTheme';
9797
export const CODING_AGENT = `${PR_SETTINGS_NAMESPACE}.codingAgent`;
9898
export const CODING_AGENT_ENABLED = 'enabled';
9999
export const CODING_AGENT_AUTO_COMMIT_AND_PUSH = 'autoCommitAndPush';
100-
export const CODING_AGENT_PROMPT_FOR_CONFIRMATION = 'promptForConfirmation';
100+
export const CODING_AGENT_PROMPT_FOR_CONFIRMATION = 'promptForConfirmation';
101+
export const SHOW_CODE_LENS = 'codeLens';

src/common/utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,3 +1009,9 @@ export function escapeRegExp(string: string) {
10091009
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
10101010
}
10111011

1012+
export function truncate(value: string, maxLength: number, suffix = '...'): string {
1013+
if (value.length <= maxLength) {
1014+
return value;
1015+
}
1016+
return `${value.substr(0, maxLength)}${suffix}`;
1017+
}

src/github/copilotRemoteAgent.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,8 @@ export class CopilotRemoteAgentManager extends Disposable {
261261
status: CopilotPRStatus;
262262
}[]> | undefined;
263263

264+
private _isAssignable: boolean | undefined;
265+
264266
constructor(
265267
private credentialStore: CredentialStore,
266268
public repositoriesManager: RepositoriesManager,
@@ -348,9 +350,18 @@ export class CopilotRemoteAgentManager extends Disposable {
348350
}
349351

350352
async isAssignable(): Promise<boolean> {
353+
const setCachedResult = (b: boolean) => {
354+
this._isAssignable = b;
355+
return b;
356+
};
357+
358+
if (this._isAssignable !== undefined) {
359+
return this._isAssignable;
360+
}
361+
351362
const repoInfo = await this.repoInfo();
352363
if (!repoInfo) {
353-
return false;
364+
return setCachedResult(false);
354365
}
355366

356367
const { fm } = repoInfo;
@@ -361,14 +372,12 @@ export class CopilotRemoteAgentManager extends Disposable {
361372
const allAssignableUsers = fm.getAllAssignableUsers();
362373

363374
if (!allAssignableUsers) {
364-
return false;
375+
return setCachedResult(false);
365376
}
366-
367-
// Check if any of the copilot logins are in the assignable users
368-
return allAssignableUsers.some(user => COPILOT_LOGINS.includes(user.login));
377+
return setCachedResult(allAssignableUsers.some(user => COPILOT_LOGINS.includes(user.login)));
369378
} catch (error) {
370379
// If there's an error fetching assignable users, assume not assignable
371-
return false;
380+
return setCachedResult(false);
372381
}
373382
}
374383

@@ -398,6 +407,7 @@ export class CopilotRemoteAgentManager extends Disposable {
398407

399408
private async updateAssignabilityContext(): Promise<void> {
400409
try {
410+
this._isAssignable = undefined; // Invalidate cache
401411
const available = await this.isAvailable();
402412
commands.setContext('copilotCodingAgentAssignable', available);
403413
} catch (error) {

src/issues/issueFeatureRegistrar.ts

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
pushAndCreatePR,
5858
USER_EXPRESSION,
5959
} from './util';
60+
import { truncate } from '../common/utils';
6061
import { OctokitCommon } from '../github/common';
6162
import { CopilotRemoteAgentManager } from '../github/copilotRemoteAgent';
6263
import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager';
@@ -147,8 +148,8 @@ export class IssueFeatureRegistrar extends Disposable {
147148
'issue.startCodingAgentFromTodo',
148149
(todoInfo?: { document: vscode.TextDocument; lineNumber: number; line: string; insertIndex: number; range: vscode.Range }) => {
149150
/* __GDPR__
150-
"issue.startCodingAgentFromTodo" : {}
151-
*/
151+
"issue.startCodingAgentFromTodo" : {}
152+
*/
152153
this.telemetry.sendTelemetryEvent('issue.startCodingAgentFromTodo');
153154
return this.startCodingAgentFromTodo(todoInfo);
154155
},
@@ -575,8 +576,12 @@ export class IssueFeatureRegistrar extends Disposable {
575576
this._register(
576577
vscode.languages.registerHoverProvider('*', new UserHoverProvider(this.manager, this.telemetry)),
577578
);
579+
const todoProvider = new IssueTodoProvider(this.context, this.copilotRemoteAgentManager);
580+
this._register(
581+
vscode.languages.registerCodeActionsProvider('*', todoProvider, { providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] }),
582+
);
578583
this._register(
579-
vscode.languages.registerCodeActionsProvider('*', new IssueTodoProvider(this.context, this.copilotRemoteAgentManager), { providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] }),
584+
vscode.languages.registerCodeLensProvider('*', todoProvider),
580585
);
581586
});
582587
}
@@ -1488,28 +1493,27 @@ ${options?.body ?? ''}\n
14881493
}
14891494

14901495
const { document, line, insertIndex } = todoInfo;
1491-
1492-
// Extract the TODO text after the trigger word
14931496
const todoText = line.substring(insertIndex).trim();
1494-
14951497
if (!todoText) {
14961498
vscode.window.showWarningMessage(vscode.l10n.t('No task description found in TODO comment'));
14971499
return;
14981500
}
14991501

1500-
// Create a prompt for the coding agent
15011502
const relativePath = vscode.workspace.asRelativePath(document.uri);
15021503
const prompt = vscode.l10n.t('Work on TODO: {0} (from {1})', todoText, relativePath);
1503-
1504-
// Start the coding agent session
1505-
try {
1506-
await this.copilotRemoteAgentManager.commandImpl({
1507-
userPrompt: prompt,
1508-
source: 'todo'
1509-
});
1510-
} catch (error) {
1511-
vscode.window.showErrorMessage(vscode.l10n.t('Failed to start coding agent session: {0}', error.message));
1512-
}
1504+
return vscode.window.withProgress({
1505+
location: vscode.ProgressLocation.Notification,
1506+
title: vscode.l10n.t('Delegating \'{0}\' to coding agent', truncate(todoText, 20))
1507+
}, async (_progress) => {
1508+
try {
1509+
await this.copilotRemoteAgentManager.commandImpl({
1510+
userPrompt: prompt,
1511+
source: 'todo'
1512+
});
1513+
} catch (error) {
1514+
vscode.window.showErrorMessage(vscode.l10n.t('Failed to start coding agent session: {0}', error.message));
1515+
}
1516+
});
15131517
}
15141518

15151519
async assignToCodingAgent(issueModel: any) {

src/issues/issueTodoProvider.ts

Lines changed: 80 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55

66
import * as vscode from 'vscode';
77
import { MAX_LINE_LENGTH } from './util';
8-
import { CREATE_ISSUE_TRIGGERS, ISSUES_SETTINGS_NAMESPACE } from '../common/settingKeys';
8+
import { CODING_AGENT, CREATE_ISSUE_TRIGGERS, ISSUES_SETTINGS_NAMESPACE, SHOW_CODE_LENS } from '../common/settingKeys';
99
import { escapeRegExp } from '../common/utils';
1010
import { CopilotRemoteAgentManager } from '../github/copilotRemoteAgent';
1111
import { ISSUE_OR_URL_EXPRESSION } from '../github/utils';
1212

13-
export class IssueTodoProvider implements vscode.CodeActionProvider {
13+
export class IssueTodoProvider implements vscode.CodeActionProvider, vscode.CodeLensProvider {
1414
private expression: RegExp | undefined;
1515

1616
constructor(
@@ -30,6 +30,24 @@ export class IssueTodoProvider implements vscode.CodeActionProvider {
3030
this.expression = triggers.length > 0 ? new RegExp(triggers.map(trigger => escapeRegExp(trigger)).join('|')) : undefined;
3131
}
3232

33+
private findTodoInLine(line: string): { match: RegExpMatchArray; search: number; insertIndex: number } | undefined {
34+
const truncatedLine = line.substring(0, MAX_LINE_LENGTH);
35+
const matches = truncatedLine.match(ISSUE_OR_URL_EXPRESSION);
36+
if (matches) {
37+
return undefined;
38+
}
39+
const match = truncatedLine.match(this.expression!);
40+
const search = match?.index ?? -1;
41+
if (search >= 0 && match) {
42+
const indexOfWhiteSpace = truncatedLine.substring(search).search(/\s/);
43+
const insertIndex =
44+
search +
45+
(indexOfWhiteSpace > 0 ? indexOfWhiteSpace : truncatedLine.match(this.expression!)![0].length);
46+
return { match, search, insertIndex };
47+
}
48+
return undefined;
49+
}
50+
3351
async provideCodeActions(
3452
document: vscode.TextDocument,
3553
range: vscode.Range | vscode.Selection,
@@ -43,48 +61,74 @@ export class IssueTodoProvider implements vscode.CodeActionProvider {
4361
let lineNumber = range.start.line;
4462
do {
4563
const line = document.lineAt(lineNumber).text;
46-
const truncatedLine = line.substring(0, MAX_LINE_LENGTH);
47-
const matches = truncatedLine.match(ISSUE_OR_URL_EXPRESSION);
48-
if (!matches) {
49-
const match = truncatedLine.match(this.expression);
50-
const search = match?.index ?? -1;
51-
if (search >= 0 && match) {
52-
// Create GitHub Issue action
53-
const createIssueAction: vscode.CodeAction = new vscode.CodeAction(
54-
vscode.l10n.t('Create GitHub Issue'),
64+
const todoInfo = this.findTodoInLine(line);
65+
if (todoInfo) {
66+
const { match, search, insertIndex } = todoInfo;
67+
// Create GitHub Issue action
68+
const createIssueAction: vscode.CodeAction = new vscode.CodeAction(
69+
vscode.l10n.t('Create GitHub Issue'),
70+
vscode.CodeActionKind.QuickFix,
71+
);
72+
createIssueAction.ranges = [new vscode.Range(lineNumber, search, lineNumber, search + match[0].length)];
73+
createIssueAction.command = {
74+
title: vscode.l10n.t('Create GitHub Issue'),
75+
command: 'issue.createIssueFromSelection',
76+
arguments: [{ document, lineNumber, line, insertIndex, range }],
77+
};
78+
codeActions.push(createIssueAction);
79+
80+
// Start Coding Agent Session action (if copilot manager is available)
81+
if (this.copilotRemoteAgentManager) {
82+
const startAgentAction: vscode.CodeAction = new vscode.CodeAction(
83+
vscode.l10n.t('Delegate to coding agent'),
5584
vscode.CodeActionKind.QuickFix,
5685
);
57-
createIssueAction.ranges = [new vscode.Range(lineNumber, search, lineNumber, search + match[0].length)];
58-
const indexOfWhiteSpace = truncatedLine.substring(search).search(/\s/);
59-
const insertIndex =
60-
search +
61-
(indexOfWhiteSpace > 0 ? indexOfWhiteSpace : truncatedLine.match(this.expression)![0].length);
62-
createIssueAction.command = {
63-
title: vscode.l10n.t('Create GitHub Issue'),
64-
command: 'issue.createIssueFromSelection',
86+
startAgentAction.ranges = [new vscode.Range(lineNumber, search, lineNumber, search + match[0].length)];
87+
startAgentAction.command = {
88+
title: vscode.l10n.t('Delegate to coding agent'),
89+
command: 'issue.startCodingAgentFromTodo',
6590
arguments: [{ document, lineNumber, line, insertIndex, range }],
6691
};
67-
codeActions.push(createIssueAction);
68-
69-
// Start Coding Agent Session action (if copilot manager is available)
70-
if (this.copilotRemoteAgentManager) {
71-
const startAgentAction: vscode.CodeAction = new vscode.CodeAction(
72-
vscode.l10n.t('Delegate to coding agent'),
73-
vscode.CodeActionKind.QuickFix,
74-
);
75-
startAgentAction.ranges = [new vscode.Range(lineNumber, search, lineNumber, search + match[0].length)];
76-
startAgentAction.command = {
77-
title: vscode.l10n.t('Delegate to coding agent'),
78-
command: 'issue.startCodingAgentFromTodo',
79-
arguments: [{ document, lineNumber, line, insertIndex, range }],
80-
};
81-
codeActions.push(startAgentAction);
82-
}
83-
break;
92+
codeActions.push(startAgentAction);
8493
}
94+
break;
8595
}
8696
lineNumber++;
8797
} while (range.end.line >= lineNumber);
8898
return codeActions;
8999
}
100+
101+
async provideCodeLenses(
102+
document: vscode.TextDocument,
103+
_token: vscode.CancellationToken,
104+
): Promise<vscode.CodeLens[]> {
105+
if (this.expression === undefined) {
106+
return [];
107+
}
108+
109+
// Check if CodeLens is enabled
110+
const isCodeLensEnabled = vscode.workspace.getConfiguration(CODING_AGENT).get(SHOW_CODE_LENS, true);
111+
if (!isCodeLensEnabled) {
112+
return [];
113+
}
114+
115+
const codeLenses: vscode.CodeLens[] = [];
116+
for (let lineNumber = 0; lineNumber < document.lineCount; lineNumber++) {
117+
const line = document.lineAt(lineNumber).text;
118+
const todoInfo = this.findTodoInLine(line);
119+
if (todoInfo) {
120+
const { match, search, insertIndex } = todoInfo;
121+
const range = new vscode.Range(lineNumber, search, lineNumber, search + match[0].length);
122+
if (this.copilotRemoteAgentManager && (await this.copilotRemoteAgentManager.isAvailable())) {
123+
const startAgentCodeLens = new vscode.CodeLens(range, {
124+
title: vscode.l10n.t('Delegate to coding agent'),
125+
command: 'issue.startCodingAgentFromTodo',
126+
arguments: [{ document, lineNumber, line, insertIndex, range }],
127+
});
128+
codeLenses.push(startAgentCodeLens);
129+
}
130+
}
131+
}
132+
return codeLenses;
133+
}
90134
}

0 commit comments

Comments
 (0)