feat: implement Editor Context Link feature to enhance chat prompts w…#42
Open
maurice30120 wants to merge 3 commits into
Open
feat: implement Editor Context Link feature to enhance chat prompts w…#42maurice30120 wants to merge 3 commits into
maurice30120 wants to merge 3 commits into
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds an “Editor Context Link” feature that can optionally prefix ACP chat prompts with the active VS Code editor context (file, cursor/selection/current line, open tabs), controlled via a toggle command with persisted workspace state.
Changes:
- Introduces
EditorContextcapture + prompt/section formatting helpers. - Updates chat prompt sending to conditionally inject editor context and adds a toggle command/state plumbing.
- Adds tests and documentation for the new editor-context behavior.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/ui/EditorContext.ts | New utilities/types to capture editor + open tabs context and format it into a prompt section. |
| src/ui/ChatWebviewProvider.ts | Adds optional editor-context injection into sendPrompt, plus toggleable linked state. |
| src/extension.ts | Wires editor-context provider callback into chat provider; registers toggle command + persists state. |
| package.json | Adds command + view title button for toggling editor context link. |
| docs/editor-context-link.md | Documents UX, prompt format, and implementation details. |
| src/test/extension.test.ts | Cleans up test suite formatting (removes informational message). |
| src/test/EditorContext.test.ts | Adds unit coverage for context formatting/deduplication and language selection. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+101
to
+109
| const lines = [ | ||
| 'VS Code context:', | ||
| `File: ${context.filePath}`, | ||
| `Cursor: ${context.cursorLine}:${context.cursorCharacter}`, | ||
| locationLine, | ||
| '', | ||
| `\`\`\`${context.language}`, | ||
| contextText, | ||
| '```', |
Comment on lines
+75
to
+89
| export function normalizeOpenEditorPaths(paths: readonly string[]): string[] { | ||
| const seen = new Set<string>(); | ||
| const normalized: string[] = []; | ||
|
|
||
| for (const filePath of paths) { | ||
| if (!filePath || seen.has(filePath)) { | ||
| continue; | ||
| } | ||
|
|
||
| seen.add(filePath); | ||
| normalized.push(filePath); | ||
| } | ||
|
|
||
| return normalized; | ||
| } |
Comment on lines
+177
to
+197
| const editorContext = this.editorContextLinked ? this.getEditorContext() : null; | ||
| const finalText = this.editorContextLinked && editorContext | ||
| ? buildPromptWithEditorContext(text, editorContext) | ||
| : text; | ||
|
|
||
| if (this.editorContextLinked && !editorContext) { | ||
| this.postMessage({ | ||
| type: 'error', | ||
| message: 'No active VS Code editor context.', | ||
| }); | ||
| } | ||
|
|
||
| sendEvent('chat/messageSent', { | ||
| agentName: this.sessionManager.getActiveAgentName() ?? '', | ||
| }, { | ||
| messageLength: finalText.length, | ||
| }); | ||
|
|
||
| // Record the first prompt for the history store (used as a label | ||
| // fallback when no title is supplied by the agent). | ||
| this.sessionManager.recordFirstPrompt(activeId, finalText); |
Comment on lines
+210
to
+224
| { | ||
| "command": "acp.attachFile", | ||
| "when": "view == acp-chat", | ||
| "group": "navigation@2" | ||
| }, | ||
| { | ||
| "command": "acp.toggleEditorContextLink", | ||
| "when": "view == acp-chat", | ||
| "group": "navigation@3" | ||
| }, | ||
| { | ||
| "command": "acp.cancelTurn", | ||
| "when": "view == acp-chat && acp.turnInProgress", | ||
| "group": "navigation@4" | ||
| }, |
824ed1d to
b71c692
Compare
b71c692 to
2ffda7a
Compare
Comment on lines
+177
to
+187
| const editorContext = this.editorContextLinked ? this.getEditorContext() : null; | ||
| const finalText = this.editorContextLinked && editorContext | ||
| ? buildPromptWithEditorContext(text, editorContext) | ||
| : text; | ||
|
|
||
| if (this.editorContextLinked && !editorContext) { | ||
| this.postMessage({ | ||
| type: 'error', | ||
| message: 'No active VS Code editor context.', | ||
| }); | ||
| } |
Comment on lines
+195
to
+197
| // Record the first prompt for the history store (used as a label | ||
| // fallback when no title is supplied by the agent). | ||
| this.sessionManager.recordFirstPrompt(activeId, finalText); |
Comment on lines
+101
to
+109
| const lines = [ | ||
| 'VS Code context:', | ||
| `File: ${context.filePath}`, | ||
| `Cursor: ${context.cursorLine}:${context.cursorCharacter}`, | ||
| locationLine, | ||
| '', | ||
| `\`\`\`${context.language}`, | ||
| contextText, | ||
| '```', |
Comment on lines
+75
to
+86
| export function normalizeOpenEditorPaths(paths: readonly string[]): string[] { | ||
| const seen = new Set<string>(); | ||
| const normalized: string[] = []; | ||
|
|
||
| for (const filePath of paths) { | ||
| if (!filePath || seen.has(filePath)) { | ||
| continue; | ||
| } | ||
|
|
||
| seen.add(filePath); | ||
| normalized.push(filePath); | ||
| } |
97b6acf to
a47e49f
Compare
Comment on lines
+39
to
+51
| export function initializeRecentFilesTracker() { | ||
| vscode.workspace.onDidOpenTextDocument(doc => { | ||
| if (doc.uri.scheme === 'file') { | ||
| recentFilesTracker.set(doc.uri.fsPath, Date.now()); | ||
| } | ||
| }); | ||
|
|
||
| vscode.workspace.onDidCloseTextDocument(doc => { | ||
| if (doc.uri.scheme === 'file') { | ||
| recentFilesTracker.delete(doc.uri.fsPath); | ||
| } | ||
| }); | ||
| } |
Comment on lines
+182
to
+187
| if (this.editorContextLinked && !editorContext) { | ||
| this.postMessage({ | ||
| type: 'info', | ||
| message: 'No editor context available — sending prompt without context.', | ||
| }); | ||
| } |
Comment on lines
+177
to
+180
| const editorContext = this.editorContextLinked ? this.getEditorContext() : null; | ||
| const finalText = this.editorContextLinked && editorContext | ||
| ? buildPromptWithEditorContext(text, editorContext) | ||
| : text; |
dca98f0 to
2f2a5a5
Compare
Comment on lines
+107
to
+130
| export function normalizeRecentEditorPaths(files: readonly TrackedFile[]): TrackedFile[] { | ||
| const seen = new Set<string>(); | ||
| const normalized: TrackedFile[] = []; | ||
|
|
||
| for (const file of files) { | ||
| if (!file.path) { | ||
| continue; | ||
| } | ||
|
|
||
| // Create canonical key: normalize path separators only | ||
| const canonicalKey = path.normalize(file.path); | ||
| if (seen.has(canonicalKey)) { | ||
| continue; | ||
| } | ||
|
|
||
| seen.add(canonicalKey); | ||
| normalized.push(file); | ||
| } | ||
|
|
||
| // Sort by openedAt DESC and limit to MAX_RECENT_FILES | ||
| return normalized | ||
| .sort((a, b) => b.openedAt - a.openedAt) | ||
| .slice(0, MAX_RECENT_FILES); | ||
| } |
Comment on lines
+39
to
+51
| export function initializeRecentFilesTracker() { | ||
| vscode.workspace.onDidOpenTextDocument(doc => { | ||
| if (doc.uri.scheme === 'file') { | ||
| recentFilesTracker.set(doc.uri.fsPath, Date.now()); | ||
| } | ||
| }); | ||
|
|
||
| vscode.workspace.onDidCloseTextDocument(doc => { | ||
| if (doc.uri.scheme === 'file') { | ||
| recentFilesTracker.delete(doc.uri.fsPath); | ||
| } | ||
| }); | ||
| } |
Comment on lines
+26
to
+27
| // --- Core services --- | ||
| initializeRecentFilesTracker(); |
Comment on lines
+1
to
+5
| * text=auto eol=lf | ||
| *.{ts,js,json,md,html,css,yml,yaml,sh} text eol=lf | ||
| *.{png,jpg,jpeg,gif,ico,woff,woff2,ttf,eot,svg} binary | ||
|
|
||
| src/ui/ChatWebviewProvider.ts -text |
Comment on lines
+132
to
+133
| function getSafeFenceMarker(contextText: string): string { | ||
| let fence = '```'; |
Comment on lines
+182
to
+187
| if (this.editorContextLinked && !editorContext) { | ||
| this.postMessage({ | ||
| type: 'info', | ||
| message: 'No editor context available — sending prompt without context.', | ||
| }); | ||
| } |
Comment on lines
+7
to
+8
| (vscode.workspace as any).workspaceFolders = undefined; | ||
| (vscode.workspace as any).asRelativePath = (p: string) => p; |
Comment on lines
+1
to
+3
| * text=auto eol=lf | ||
| *.{ts,js,json,md,html,css,yml,yaml,sh} text eol=lf | ||
| *.{png,jpg,jpeg,gif,ico,woff,woff2,ttf,eot,svg} binary |
Comment on lines
+182
to
+187
| if (this.editorContextLinked && !editorContext) { | ||
| this.postMessage({ | ||
| type: 'info', | ||
| message: 'No editor context available — sending prompt without context.', | ||
| }); | ||
| } |
Comment on lines
+177
to
+180
| const editorContext = this.editorContextLinked ? this.getEditorContext() : null; | ||
| const finalText = this.editorContextLinked && editorContext | ||
| ? buildPromptWithEditorContext(text, editorContext) | ||
| : text; |
Comment on lines
+195
to
+198
| // Record the original user text for the history store (used as a label | ||
| // fallback when no title is supplied by the agent). | ||
| // This avoids persisting sensitive context (file paths, etc.) in history. | ||
| this.sessionManager.recordFirstPrompt(activeId, text); |
| this.postMessage({ type: 'promptStart' }); | ||
|
|
||
| try { | ||
| const response = await this.sessionManager.sendPrompt(activeId, finalText); |
Comment on lines
+1948
to
+1952
| function attachScrollHide(dropdownEl) { | ||
| if (!dropdownEl || dropdownEl._tooltipScrollAttached) return; | ||
| dropdownEl._tooltipScrollAttached = true; | ||
| dropdownEl.addEventListener('scroll', hidePickerTooltip); | ||
| } |
Comment on lines
+134
to
+144
| const canonicalKey = path.normalize(file.path); | ||
| const existing = fileMap.get(canonicalKey); | ||
|
|
||
| if (!existing || file.openedAt > existing.openedAt) { | ||
| fileMap.set(canonicalKey, file); | ||
| } | ||
| } | ||
|
|
||
| return Array.from(fileMap.values()) | ||
| .sort((a, b) => b.openedAt - a.openedAt) | ||
| .slice(0, MAX_RECENT_FILES); |
Comment on lines
+182
to
+187
| if (this.editorContextLinked && !editorContext) { | ||
| this.postMessage({ | ||
| type: 'info', | ||
| message: 'No editor context available — sending prompt without context.', | ||
| }); | ||
| } |
Comment on lines
+32
to
+33
| // Global tracker for recently opened files | ||
| const recentFilesTracker = new Map<string, number>(); |
Comment on lines
+53
to
+65
| // Initialize tracker with VS Code document events | ||
| export function initializeRecentFilesTracker(): vscode.Disposable[] { | ||
| const openDocDisposable = vscode.workspace.onDidOpenTextDocument(doc => { | ||
| if (doc.uri.scheme === 'file') { | ||
| recentFilesTracker.set(doc.uri.fsPath, Date.now()); | ||
| } | ||
| }); | ||
|
|
||
| const closeDocDisposable = vscode.workspace.onDidCloseTextDocument(doc => { | ||
| if (doc.uri.scheme === 'file') { | ||
| recentFilesTracker.delete(doc.uri.fsPath); | ||
| } | ||
| }); |
Comment on lines
+134
to
+135
| const canonicalKey = path.normalize(file.path); | ||
| const existing = fileMap.get(canonicalKey); |
Comment on lines
+27
to
+38
| Object.defineProperty(vscode.workspace, 'workspaceFolders', { | ||
| value: [], | ||
| configurable: true, | ||
| enumerable: true, | ||
| writable: true, | ||
| }); | ||
| Object.defineProperty(vscode.workspace, 'asRelativePath', { | ||
| value: (p: string) => p, | ||
| configurable: true, | ||
| enumerable: true, | ||
| writable: true, | ||
| }); |
Comment on lines
+32
to
+33
| // Global tracker for recently opened files | ||
| const recentFilesTracker = new Map<string, number>(); |
Comment on lines
+104
to
+124
| export function captureOpenEditorPaths(tabGroups: readonly vscode.TabGroup[]): TrackedFile[] { | ||
| const files: TrackedFile[] = []; | ||
|
|
||
| for (const group of tabGroups) { | ||
| for (const tab of group.tabs) { | ||
| if (tab.input instanceof vscode.TabInputText && tab.input.uri.scheme === 'file') { | ||
| const fsPath = tab.input.uri.fsPath; | ||
| const openedAt = recentFilesTracker.get(fsPath) ?? Date.now(); | ||
| if (!recentFilesTracker.has(fsPath)) { | ||
| recentFilesTracker.set(fsPath, openedAt); | ||
| } | ||
| files.push({ | ||
| path: fsPath, | ||
| openedAt | ||
| }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return normalizeRecentEditorPaths(files); | ||
| } |
Comment on lines
+134
to
+139
| const canonicalKey = path.normalize(file.path); | ||
| const existing = fileMap.get(canonicalKey); | ||
|
|
||
| if (!existing || file.openedAt > existing.openedAt) { | ||
| fileMap.set(canonicalKey, file); | ||
| } |
…e context linking
351402c to
986ac07
Compare
Comment on lines
+34
to
+35
| // Global tracker for currently open editor files. | ||
| const openEditorOpenedAtByPath = new Map<string, number>(); |
Comment on lines
+134
to
+145
| export function captureOpenEditorPaths(tabGroups: readonly vscode.TabGroup[]): OpenEditorFile[] { | ||
| const files: OpenEditorFile[] = []; | ||
|
|
||
| for (const group of tabGroups) { | ||
| for (const tab of group.tabs) { | ||
| if (tab.input instanceof vscode.TabInputText && tab.input.uri.scheme === 'file') { | ||
| const fsPath = tab.input.uri.fsPath; | ||
| let openedAt = openEditorOpenedAtByPath.get(fsPath); | ||
| if (openedAt === undefined) { | ||
| openedAt = Date.now(); | ||
| openEditorOpenedAtByPath.set(fsPath, openedAt); | ||
| } |
Comment on lines
+139
to
+140
| if (tab.input instanceof vscode.TabInputText && tab.input.uri.scheme === 'file') { | ||
| const fsPath = tab.input.uri.fsPath; |
Comment on lines
+193
to
+197
| const workspacePathFormatter: EditorContextPathFormatter = filePath => { | ||
| return vscode.workspace.workspaceFolders?.length | ||
| ? vscode.workspace.asRelativePath(filePath) | ||
| : filePath; | ||
| }; |
…info message type
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Editor Context Link
Summary
Editor Context Link lets users include the current VS Code editor context in ACP chat prompts.
When enabled, the extension captures the context at send time and prefixes the user prompt with:
Only paths are listed for open editors. Their contents are not sent.
User Experience
The Chat view title bar shows a
$(link)icon next to the existing$(attach)file attachment icon.The chat input stays unchanged. Users type a normal message; the extension enriches the prompt before sending it to the ACP agent.
Prompt Format
With Selected Text
Without Selected Text
No Active Editor
If linking is enabled but there is no active text editor, the prompt is sent unchanged and the chat shows a non-blocking error:
Implementation
src/ui/EditorContext.tscontains the context logic:captureEditorContext(editor, openEditors)captures the active editor context;captureOpenEditorPaths(tabGroups)extracts local file paths from open VS Code tabs;normalizeOpenEditorPaths(paths)removes duplicates while preserving order;buildEditorContextSection(context)formats the context block;buildPromptWithEditorContext(prompt, context)prefixes the user prompt.src/ui/ChatWebviewProvider.tssends the final prompt. It receives agetEditorContextcallback fromextension.ts, so the webview provider does not directly read VS Code global editor state.src/extension.tsowns the toggle command:acp.toggleEditorContextLink;acp.editorContextLinked;context.workspaceState;view/titleforacp-chat.Tests
src/test/EditorContext.test.tscovers:Open editorssection;.ts, as the Markdown code block language.