diff --git a/package-lock.json b/package-lock.json index 26753ebc..7f03e9f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1218,7 +1218,6 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -2048,7 +2047,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2095,7 +2093,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2403,7 +2400,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3424,7 +3420,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7510,7 +7505,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7579,8 +7573,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tunnel": { "version": "0.0.6", @@ -7650,7 +7643,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7861,7 +7853,6 @@ "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -7911,7 +7902,6 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", diff --git a/package.json b/package.json index 59a6eb4a..05df5e24 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,18 @@ "title": "ACP: Attach File to Prompt", "icon": "$(attach)" }, + { + "command": "acp.enableEditorContextLink", + "title": "ACP: Enable Editor Context Link", + "icon": "$(pin)", + "when": "!acp.editorContextLinked" + }, + { + "command": "acp.disableEditorContextLink", + "title": "ACP: Disable Editor Context Link", + "icon": "$(pinned)", + "when": "acp.editorContextLinked" + }, { "command": "acp.refreshAgents", "title": "Refresh", @@ -176,6 +188,14 @@ { "command": "acp.forgetSession", "when": "false" + }, + { + "command": "acp.enableEditorContextLink", + "when": "!acp.editorContextLinked" + }, + { + "command": "acp.disableEditorContextLink", + "when": "acp.editorContextLinked" } ], "view/title": [ @@ -203,15 +223,25 @@ "when": "view == acp-chat", "group": "navigation@2" }, + { + "command": "acp.enableEditorContextLink", + "when": "view == acp-chat && !acp.editorContextLinked", + "group": "navigation@3" + }, + { + "command": "acp.disableEditorContextLink", + "when": "view == acp-chat && acp.editorContextLinked", + "group": "navigation@3" + }, { "command": "acp.cancelTurn", "when": "view == acp-chat && acp.turnInProgress", - "group": "navigation@3" + "group": "navigation@4" }, { "command": "acp.disconnectAgent", "when": "view == acp-chat", - "group": "navigation@4" + "group": "navigation@5" }, { "command": "acp.showLog", @@ -397,6 +427,7 @@ "compile": "webpack", "watch": "webpack --watch", "package": "webpack --mode production --devtool hidden-source-map", + "vsx": "vsce package", "compile-tests": "tsc -p . --outDir out", "watch-tests": "tsc -p . -w --outDir out", "lint": "eslint src --max-warnings 0", diff --git a/src/core/SessionManager.ts b/src/core/SessionManager.ts index 207ee9fa..d89c66e7 100644 --- a/src/core/SessionManager.ts +++ b/src/core/SessionManager.ts @@ -449,7 +449,7 @@ export class SessionManager extends EventEmitter { throw new Error(`No connection for agent: ${session.agentId}`); } - log(`sendPrompt: session=${sessionId}, text="${text.substring(0, 50)}..."`); + log(`sendPrompt: session=${sessionId}, textLength=${text.length}`); const prompt: ContentBlock[] = [ { type: 'text', text }, diff --git a/src/extension.ts b/src/extension.ts index 82564dfe..4b9ea3b3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,11 +8,14 @@ import { SessionUpdateHandler } from './handlers/SessionUpdateHandler'; import { SessionTreeProvider } from './ui/SessionTreeProvider'; import { StatusBarManager } from './ui/StatusBarManager'; import { ChatWebviewProvider } from './ui/ChatWebviewProvider'; +import { captureEditorContext, captureOpenEditorPaths, initializeOpenEditorsTracker } from './ui/EditorContext'; import { getAgentNames } from './config/AgentConfig'; import { fetchRegistry } from './config/RegistryClient'; import { log, logError, disposeChannels, getOutputChannel, getTrafficChannel } from './utils/Logger'; import { initTelemetry, sendEvent } from './utils/TelemetryManager'; +const EDITOR_CONTEXT_LINK_STATE_KEY = 'acp.editorContextLinked'; + export function activate(context: vscode.ExtensionContext): void { log('ACP Client extension activating...'); @@ -21,6 +24,7 @@ export function activate(context: vscode.ExtensionContext): void { context.subscriptions.push(telemetryReporter); // --- Core services --- + context.subscriptions.push(...initializeOpenEditorsTracker()); const sessionUpdateHandler = new SessionUpdateHandler(); const agentManager = new AgentManager(); const connectionManager = new ConnectionManager(sessionUpdateHandler); @@ -48,7 +52,17 @@ export function activate(context: vscode.ExtensionContext): void { context.extensionUri, sessionManager, sessionUpdateHandler, + () => captureEditorContext( + vscode.window.activeTextEditor, + captureOpenEditorPaths(vscode.window.tabGroups.all), + ), + ); + const initialEditorContextLinked = context.workspaceState.get( + EDITOR_CONTEXT_LINK_STATE_KEY, + false, ); + chatWebviewProvider.setEditorContextLinked(initialEditorContextLinked); + void vscode.commands.executeCommand('setContext', EDITOR_CONTEXT_LINK_STATE_KEY, initialEditorContextLinked); const chatViewRegistration = vscode.window.registerWebviewViewProvider( ChatWebviewProvider.viewType, chatWebviewProvider, @@ -470,6 +484,24 @@ export function activate(context: vscode.ExtensionContext): void { } }); + const setEditorContextLinked = async (linked: boolean) => { + chatWebviewProvider.setEditorContextLinked(linked); + await context.workspaceState.update(EDITOR_CONTEXT_LINK_STATE_KEY, linked); + await vscode.commands.executeCommand('setContext', EDITOR_CONTEXT_LINK_STATE_KEY, linked); + vscode.window.setStatusBarMessage( + linked ? 'ACP editor context link enabled.' : 'ACP editor context link disabled.', + 2500, + ); + }; + + const enableEditorContextLinkCmd = vscode.commands.registerCommand('acp.enableEditorContextLink', async () => { + await setEditorContextLinked(true); + }); + + const disableEditorContextLinkCmd = vscode.commands.registerCommand('acp.disableEditorContextLink', async () => { + await setEditorContextLinked(false); + }); + // Browse Registry const browseRegistryCmd = vscode.commands.registerCommand('acp.browseRegistry', async () => { sendEvent('registry/browse'); @@ -518,6 +550,8 @@ export function activate(context: vscode.ExtensionContext): void { addAgentCmd, removeAgentCmd, attachFileCmd, + enableEditorContextLinkCmd, + disableEditorContextLinkCmd, browseRegistryCmd, { dispose: () => { diff --git a/src/test/ChatWebviewProvider.test.ts b/src/test/ChatWebviewProvider.test.ts new file mode 100644 index 00000000..3bfe3625 --- /dev/null +++ b/src/test/ChatWebviewProvider.test.ts @@ -0,0 +1,106 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +import { ChatWebviewProvider } from '../ui/ChatWebviewProvider'; +import type { EditorContext } from '../ui/EditorContext'; + +suite('ChatWebviewProvider', () => { + const workspaceRoot = path.join(path.parse(process.cwd()).root, 'workspace'); + const examplePath = path.join(workspaceRoot, 'src', 'example.ts'); + + function createProvider(editorContext: EditorContext | null = null) { + const sentPrompts: string[] = []; + const recordedPrompts: string[] = []; + const messages: any[] = []; + const touchedSessions: string[] = []; + + const sessionManager = { + getActiveSessionId: () => 'session-1', + getActiveAgentName: () => 'agent-1', + recordFirstPrompt: (_sessionId: string, prompt: string) => { + recordedPrompts.push(prompt); + }, + sendPrompt: async (_sessionId: string, prompt: string) => { + sentPrompts.push(prompt); + return { stopReason: 'end_turn' }; + }, + touchHistory: (sessionId: string) => { + touchedSessions.push(sessionId); + }, + }; + const sessionUpdateHandler = { + addListener: () => undefined, + removeListener: () => undefined, + }; + const provider = new ChatWebviewProvider( + vscode.Uri.file(workspaceRoot), + sessionManager as any, + sessionUpdateHandler as any, + () => editorContext, + ); + + (provider as any).view = { + webview: { + postMessage: (message: any) => { + messages.push(message); + return Promise.resolve(true); + }, + }, + }; + + return { provider, sentPrompts, recordedPrompts, messages, touchedSessions }; + } + + const editorContext: EditorContext = { + filePath: examplePath, + cursorLine: 7, + cursorCharacter: 3, + language: 'ts', + selection: { + startLine: 7, + startCharacter: 1, + endLine: 7, + endCharacter: 10, + text: 'const x = 1;', + }, + currentLine: null, + openEditors: [], + }; + + test('sends raw prompt when editor context link is disabled', async () => { + const { provider, sentPrompts, recordedPrompts } = createProvider(editorContext); + + await (provider as any).handleSendPrompt('raw prompt'); + + assert.deepStrictEqual(sentPrompts, ['raw prompt']); + assert.deepStrictEqual(recordedPrompts, ['raw prompt']); + }); + + test('sends enriched prompt to agent and records only raw prompt', async () => { + const { provider, sentPrompts, recordedPrompts, messages } = createProvider(editorContext); + provider.setEditorContextLinked(true); + + await (provider as any).handleSendPrompt('raw prompt'); + + assert.strictEqual(sentPrompts.length, 1); + assert.ok(sentPrompts[0].includes('VS Code context:')); + assert.ok(sentPrompts[0].includes('const x = 1;')); + assert.ok(sentPrompts[0].endsWith('User prompt:\nraw prompt')); + assert.deepStrictEqual(recordedPrompts, ['raw prompt']); + assert.strictEqual(messages.some(message => message.type === 'info'), false); + }); + + test('falls back to raw prompt and posts info when linked context is unavailable', async () => { + const { provider, sentPrompts, recordedPrompts, messages } = createProvider(null); + provider.setEditorContextLinked(true); + + await (provider as any).handleSendPrompt('raw prompt'); + + assert.deepStrictEqual(sentPrompts, ['raw prompt']); + assert.deepStrictEqual(recordedPrompts, ['raw prompt']); + assert.ok(messages.some(message => message.type === 'info')); + assert.ok(messages.some(message => message.type === 'promptStart')); + assert.ok(messages.some(message => message.type === 'promptEnd')); + }); +}); diff --git a/src/test/EditorContext.test.ts b/src/test/EditorContext.test.ts new file mode 100644 index 00000000..375d7512 --- /dev/null +++ b/src/test/EditorContext.test.ts @@ -0,0 +1,456 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +import { + buildEditorContextSection, + buildPromptWithEditorContext, + captureEditorContext, + captureOpenEditorPaths, + formatEditorContextPath, + getFilePathsFromTabInput, + getSafeFenceMarker, + MAX_CONTEXT_LENGTH, + MAX_CONTEXT_PATH_LENGTH, + MAX_OPEN_EDITORS, + normalizeOpenEditorPaths, + truncateText, + type EditorContext, + type OpenEditorFile, +} from '../ui/EditorContext'; + +suite('EditorContext', () => { + const workspaceRoot = path.join(path.parse(process.cwd()).root, 'workspace'); + const workspacePath = (...segments: string[]) => path.join(workspaceRoot, ...segments); + const formatPathForTest = (filePath: string) => filePath; + + test('truncateText strictly respects max length including suffix', () => { + assert.strictEqual(truncateText('abcdef', 0), ''); + assert.strictEqual(truncateText('abcdef', 3), 'abc'); + assert.strictEqual(truncateText('abcdef', 6), 'abcdef'); + + const result = truncateText('a'.repeat(100), 20); + assert.strictEqual(result.length, 20); + assert.ok(result.endsWith('… [truncated]')); + }); + + test('formats selection context with file section', () => { + const examplePath = workspacePath('src', 'example.ts'); + const context: EditorContext = { + filePath: examplePath, + cursorLine: 42, + cursorCharacter: 7, + language: 'ts', + selection: { + startLine: 40, + startCharacter: 1, + endLine: 43, + endCharacter: 12, + text: 'const value = 1;', + }, + currentLine: null, + openEditors: [], + }; + + assert.strictEqual( + buildEditorContextSection(context, formatPathForTest), + [ + 'VS Code context:', + `File: ${examplePath}`, + 'Cursor: 42:7', + 'Selection: 40:1-43:12', + '', + '```ts', + 'const value = 1;', + '```', + ].join('\n'), + ); + }); + + test('formats current line context when there is no selection', () => { + const examplePath = workspacePath('src', 'example.ts'); + const context: EditorContext = { + filePath: examplePath, + cursorLine: 42, + cursorCharacter: 7, + language: 'ts', + selection: null, + currentLine: { + line: 42, + text: 'return value;', + }, + openEditors: [], + }; + + assert.strictEqual( + buildEditorContextSection(context, formatPathForTest), + [ + 'VS Code context:', + `File: ${examplePath}`, + 'Cursor: 42:7', + 'Line: 42', + '', + '```ts', + 'return value;', + '```', + ].join('\n'), + ); + }); + + test('does not prefix a prompt when context is null', () => { + assert.strictEqual(buildPromptWithEditorContext('hello', null), 'hello'); + }); + + test('formats open editors after active editor context', () => { + const packagePath = workspacePath('package.json'); + const barPath = workspacePath('src', 'bar.ts'); + const fooPath = workspacePath('src', 'foo.ts'); + // Files must be sorted by openedAt DESC to simulate normalizeOpenEditorPaths result + const openEditors: OpenEditorFile[] = [ + { path: packagePath, openedAt: 3000 }, + { path: barPath, openedAt: 2000 }, + { path: fooPath, openedAt: 1000 }, + ]; + const context: EditorContext = { + filePath: fooPath, + cursorLine: 42, + cursorCharacter: 7, + language: 'ts', + selection: null, + currentLine: { + line: 42, + text: 'const value = computeTotal(items);', + }, + openEditors, + }; + + assert.strictEqual( + buildEditorContextSection(context, formatPathForTest), + [ + 'VS Code context:', + `File: ${fooPath}`, + 'Cursor: 42:7', + 'Line: 42', + '', + '```ts', + 'const value = computeTotal(items);', + '```', + '', + 'Open editors:', + `- ${packagePath}`, + `- ${barPath}`, + `- ${fooPath}`, + ].join('\n'), + ); + }); + + test('deduplicates open editor paths by normalized path and keeps max openedAt', () => { + const fooPath = workspacePath('src', 'foo.ts'); + const barPath = workspacePath('src', 'bar.ts'); + const bazPath = workspacePath('src', 'baz.ts'); + const packagePath = workspacePath('package.json'); + const upperFooPath = workspacePath('src', 'FOO.ts'); + const input: OpenEditorFile[] = [ + { path: fooPath, openedAt: 1000 }, + { path: barPath, openedAt: 3000 }, + { path: fooPath, openedAt: 1500 }, // duplicate + { path: '', openedAt: 2000 }, + { path: packagePath, openedAt: 4000 }, + { path: barPath, openedAt: 2500 }, // duplicate + { path: `${workspacePath('src')}${path.sep}..${path.sep}src${path.sep}baz.ts`, openedAt: 3500 }, + { path: upperFooPath, openedAt: 500 }, + ]; + + const result = normalizeOpenEditorPaths(input); + + assert.strictEqual(result.length, 5); + assert.strictEqual(result[0].path, packagePath); + assert.strictEqual(result[1].path, bazPath); + assert.strictEqual(result[1].openedAt, 3500); + assert.strictEqual(result[2].path, barPath); + assert.strictEqual(result[2].openedAt, 3000); + assert.strictEqual(result[3].path, fooPath); + assert.strictEqual(result[3].openedAt, 1500); + assert.strictEqual(result[4].path, upperFooPath); + assert.strictEqual(result[4].openedAt, 500); + }); + + test('limits to MAX_OPEN_EDITORS when more files are provided', () => { + const input: OpenEditorFile[] = [ + { path: workspacePath('file1.ts'), openedAt: 1000 }, + { path: workspacePath('file2.ts'), openedAt: 2000 }, + { path: workspacePath('file3.ts'), openedAt: 3000 }, + { path: workspacePath('file4.ts'), openedAt: 4000 }, + { path: workspacePath('file5.ts'), openedAt: 5000 }, + { path: workspacePath('file6.ts'), openedAt: 6000 }, + { path: workspacePath('file7.ts'), openedAt: 7000 }, + ]; + + const result = normalizeOpenEditorPaths(input); + + assert.strictEqual(result.length, MAX_OPEN_EDITORS); + // Should be sorted by openedAt DESC + assert.strictEqual(result[0].path, workspacePath('file7.ts')); + assert.strictEqual(result[1].path, workspacePath('file6.ts')); + assert.strictEqual(result[2].path, workspacePath('file5.ts')); + assert.strictEqual(result[3].path, workspacePath('file4.ts')); + assert.strictEqual(result[4].path, workspacePath('file3.ts')); + // file1.ts and file2.ts should be excluded as oldest + assert.strictEqual(result.find(f => f.path === workspacePath('file1.ts')), undefined); + assert.strictEqual(result.find(f => f.path === workspacePath('file2.ts')), undefined); + }); + + test('reuses initialized timestamps across repeated open editor captures', () => { + const uri = vscode.Uri.file(workspacePath('stable-timestamp.ts')); + const tabGroups = [ + { + tabs: [ + { + input: new vscode.TabInputText(uri), + }, + ], + }, + ]; + + const first = captureOpenEditorPaths(tabGroups as any, () => 1000); + const second = captureOpenEditorPaths(tabGroups as any, () => 2000); + + assert.strictEqual(first.length, 1); + assert.strictEqual(second.length, 1); + assert.strictEqual(first[0].openedAt, 1000); + assert.strictEqual(second[0].openedAt, 1000); + }); + + test('captures file paths from text and diff tabs', () => { + const originalUri = vscode.Uri.file(workspacePath('src', 'before.ts')); + const modifiedUri = vscode.Uri.file(workspacePath('src', 'after.ts')); + const textUri = vscode.Uri.file(workspacePath('src', 'current.ts')); + + assert.deepStrictEqual( + getFilePathsFromTabInput(new vscode.TabInputText(textUri)), + [textUri.fsPath], + ); + assert.deepStrictEqual( + getFilePathsFromTabInput(new vscode.TabInputTextDiff(originalUri, modifiedUri)), + [originalUri.fsPath, modifiedUri.fsPath], + ); + }); + + test('prunes closed tabs from the open editor tracker', () => { + const firstUri = vscode.Uri.file(workspacePath('src', 'first.ts')); + const secondUri = vscode.Uri.file(workspacePath('src', 'second.ts')); + const firstCapture = [ + { + tabs: [ + { input: new vscode.TabInputText(firstUri) }, + { input: new vscode.TabInputText(secondUri) }, + ], + }, + ]; + const secondCapture = [ + { + tabs: [ + { input: new vscode.TabInputText(secondUri) }, + ], + }, + ]; + + assert.deepStrictEqual( + captureOpenEditorPaths(firstCapture as any, () => 1000).map(file => file.path).sort(), + [firstUri.fsPath, secondUri.fsPath].sort(), + ); + + const result = captureOpenEditorPaths(secondCapture as any, () => 2000); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].path, secondUri.fsPath); + assert.strictEqual(result[0].openedAt, 1000); + }); + + test('formats workspace paths as relative and non-workspace paths as basename', () => { + const workspaceFolder = { + uri: vscode.Uri.file(workspaceRoot), + name: 'workspace', + index: 0, + }; + + assert.strictEqual( + formatEditorContextPath(workspacePath('src', 'example.ts'), [workspaceFolder]), + path.join('src', 'example.ts'), + ); + assert.strictEqual( + formatEditorContextPath(path.join(path.parse(process.cwd()).root, 'outside', 'secret.ts'), [workspaceFolder]), + 'secret.ts', + ); + assert.strictEqual( + formatEditorContextPath(workspacePath('src', 'example.ts'), []), + 'example.ts', + ); + }); + + test('truncates formatted paths to MAX_CONTEXT_PATH_LENGTH', () => { + const longFileName = `${'a'.repeat(MAX_CONTEXT_PATH_LENGTH + 100)}.ts`; + const result = formatEditorContextPath(workspacePath(longFileName), []); + + assert.strictEqual(result.length, MAX_CONTEXT_PATH_LENGTH); + assert.ok(result.endsWith('… [truncated]')); + }); + + test('truncates large selections without reading the full selection text', () => { + const selectedText = 'a'.repeat(MAX_CONTEXT_LENGTH + 1000); + const editor = { + document: { + uri: { fsPath: workspacePath('src', 'large-selection.ts') }, + languageId: 'typescript', + lineAt: () => ({ text: selectedText }), + getText: () => { + throw new Error('selection should be read through bounded line extraction'); + }, + }, + selection: { + active: { line: 0, character: 0 }, + isEmpty: false, + start: { line: 0, character: 0 }, + end: { line: 0, character: selectedText.length }, + }, + }; + + const context = captureEditorContext(editor as any); + + assert.ok(context?.selection); + assert.strictEqual(context.selection.text.length, MAX_CONTEXT_LENGTH); + assert.ok(context.selection.text.endsWith('… [truncated]')); + }); + + test('truncates long current lines', () => { + const currentLineText = 'a'.repeat(MAX_CONTEXT_LENGTH + 1000); + const editor = { + document: { + uri: { fsPath: workspacePath('src', 'large-line.ts') }, + languageId: 'typescript', + lineAt: () => ({ text: currentLineText }), + }, + selection: { + active: { line: 0, character: 0 }, + isEmpty: true, + }, + }; + + const context = captureEditorContext(editor as any); + + assert.ok(context?.currentLine); + assert.strictEqual(context.currentLine.text.length, MAX_CONTEXT_LENGTH); + assert.ok(context.currentLine.text.endsWith('… [truncated]')); + }); + + test('uses file extension as markdown language when capturing context', () => { + const editor = { + document: { + uri: { fsPath: workspacePath('src', 'example.ts') }, + languageId: 'typescript', + lineAt: () => ({ text: 'type Example = string;' }), + }, + selection: { + active: { line: 0, character: 0 }, + isEmpty: true, + }, + }; + + const context = captureEditorContext(editor as any); + + assert.ok(context); + assert.strictEqual(context.language, 'ts'); + assert.deepStrictEqual(context.openEditors, []); + assert.ok(buildEditorContextSection(context, formatPathForTest).includes('```ts\n')); + }); + + test('escapes fence markers in context text containing backticks', () => { + const context: EditorContext = { + filePath: workspacePath('src', 'example.ts'), + cursorLine: 10, + cursorCharacter: 0, + language: 'md', + selection: { + startLine: 10, + startCharacter: 0, + endLine: 10, + endCharacter: 15, + text: 'Code with ``` backticks', + }, + currentLine: null, + openEditors: [], + }; + + const result = buildEditorContextSection(context, formatPathForTest); + + // Should use a longer fence (````) when context contains ``` + assert.ok(result.includes('````md')); + assert.ok(result.includes('Code with ``` backticks')); + assert.ok(result.includes('````')); + // Verify the closing fence matches the opening fence length + const lines = result.split('\n'); + const codeBlockStart = lines.findIndex(l => l.startsWith('```') || l.startsWith('````')); + const codeBlockEnd = lines.findIndex((l, i) => i > codeBlockStart && (l === '```' || l === '````')); + + assert.ok(codeBlockStart !== -1, 'Code block start fence found'); + assert.ok(codeBlockEnd !== -1, 'Code block end fence found'); + // Both should have 4 backticks + assert.ok(lines[codeBlockStart].startsWith('````'), 'Opening fence uses 4 backticks'); + assert.strictEqual(lines[codeBlockEnd], '````', 'Closing fence uses 4 backticks'); + }); + + test('getSafeFenceMarker increases fence length when context contains backticks', () => { + // Context with no backticks should use default ``` + assert.strictEqual(getSafeFenceMarker('normal text'), '```'); + + // Context with ``` should use ```` + assert.strictEqual(getSafeFenceMarker('text with ``` code'), '````'); + + // Context with ```` should use ````` + assert.strictEqual(getSafeFenceMarker('text with ```` code'), '`````'); + + // Context with multiple levels should use longest + 1 + assert.strictEqual(getSafeFenceMarker('``` and ````'), '`````'); + + // Empty context should use default ``` + assert.strictEqual(getSafeFenceMarker(''), '```'); + }); + + test('buildEditorContextSection with backticks in context produces valid fenced block', () => { + const context: EditorContext = { + filePath: workspacePath('src', 'example.md'), + cursorLine: 5, + cursorCharacter: 0, + language: 'md', + selection: { + startLine: 5, + startCharacter: 0, + endLine: 5, + endCharacter: 10, + text: 'Some ``` code', + }, + currentLine: null, + openEditors: [], + }; + + const result = buildEditorContextSection(context, formatPathForTest); + const lines = result.split('\n'); + + // Find the fence lines + const openingFenceIndex = lines.findIndex(l => l.startsWith('```') || l.startsWith('````')); + const closingFenceIndex = lines.findIndex((l, i) => i > openingFenceIndex && (l === '```' || l === '````')); + + assert.ok(openingFenceIndex !== -1, 'Opening fence found'); + assert.ok(closingFenceIndex !== -1, 'Closing fence found'); + + const openingFence = lines[openingFenceIndex]; + const closingFence = lines[closingFenceIndex]; + + // Both should be ```` (4 backticks) + assert.strictEqual(openingFence, '````md', 'Opening fence is 4 backticks with language'); + assert.strictEqual(closingFence, '````', 'Closing fence is 4 backticks'); + + // Verify content is preserved + assert.ok(result.includes('Some ``` code')); + }); +}); diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 88478589..4fdb3ea0 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -1,12 +1,10 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; -suite('Extension Test Suite', () => { - vscode.window.showInformationMessage('Start all tests.'); - - test('Extension should be present', () => { - assert.ok(vscode.extensions.getExtension('formulahendry.acp-client')); - }); +suite('Extension Test Suite', () => { + test('Extension should be present', () => { + assert.ok(vscode.extensions.getExtension('formulahendry.acp-client')); + }); test('Should activate extension', async () => { const ext = vscode.extensions.getExtension('formulahendry.acp-client'); diff --git a/src/ui/ChatWebviewProvider.ts b/src/ui/ChatWebviewProvider.ts index f9c58d27..2bece220 100644 --- a/src/ui/ChatWebviewProvider.ts +++ b/src/ui/ChatWebviewProvider.ts @@ -5,6 +5,9 @@ import { SessionUpdateHandler, SessionUpdateListener } from '../handlers/Session import type { SessionNotification } from '@agentclientprotocol/sdk'; import { logError } from '../utils/Logger'; import { sendEvent } from '../utils/TelemetryManager'; +import { buildPromptWithEditorContext, type EditorContext } from './EditorContext'; + +type GetEditorContext = () => EditorContext | null; /** * WebviewViewProvider for the ACP chat sidebar. @@ -16,11 +19,13 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { private view?: vscode.WebviewView; private updateListener: SessionUpdateListener; private _hasChatContent = false; + private editorContextLinked = false; constructor( private readonly extensionUri: vscode.Uri, private readonly sessionManager: SessionManager, private readonly sessionUpdateHandler: SessionUpdateHandler, + private readonly getEditorContext: GetEditorContext = () => null, ) { // Configure marked for safe rendering marked.setOptions({ @@ -169,21 +174,34 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { return; } - sendEvent('chat/messageSent', { - agentName: this.sessionManager.getActiveAgentName() ?? '', - }, { - messageLength: text.length, - }); + const editorContext = this.editorContextLinked ? this.getEditorContext() : null; + const agentText = this.editorContextLinked && editorContext + ? buildPromptWithEditorContext(text, editorContext) + : text; + + if (this.editorContextLinked && !editorContext) { + this.postMessage({ + type: 'info', + message: 'No editor context available — sending prompt without context.', + }); + } + + sendEvent('chat/messageSent', { + agentName: this.sessionManager.getActiveAgentName() ?? '', + }, { + messageLength: agentText.length, + }); - // Record the first prompt for the history store (used as a label + // 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); // Tell webview we're processing this.postMessage({ type: 'promptStart' }); try { - const response = await this.sessionManager.sendPrompt(activeId, text); + const response = await this.sessionManager.sendPrompt(activeId, agentText); // Render the accumulated assistant text as markdown // The webview will have sent us the raw text via promptEnd handling this.postMessage({ @@ -360,6 +378,14 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { return this._hasChatContent; } + setEditorContextLinked(linked: boolean): void { + this.editorContextLinked = linked; + } + + get isEditorContextLinked(): boolean { + return this.editorContextLinked; + } + /** * Generate the HTML content for the webview. */ @@ -586,12 +612,19 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { overflow-y: auto; } - .message.error { - align-self: center; - background: var(--vscode-inputValidation-errorBackground); - color: var(--vscode-inputValidation-errorForeground); - border: 1px solid var(--vscode-inputValidation-errorBorder); - } + .message.error { + align-self: center; + background: var(--vscode-inputValidation-errorBackground); + color: var(--vscode-inputValidation-errorForeground); + border: 1px solid var(--vscode-inputValidation-errorBorder); + } + + .message.info { + align-self: center; + background: var(--vscode-editorInfo-background, var(--vscode-editorWidget-background)); + color: var(--vscode-editorInfo-foreground, var(--vscode-foreground)); + border: 1px solid var(--vscode-editorInfo-border, var(--vscode-panel-border)); + } /* Turn container — groups assistant text + tool calls */ .turn { @@ -2324,11 +2357,15 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { setProcessing(false); break; - case 'error': - addMessage('error', msg.message || 'An error occurred'); - break; - - case 'sessionUpdate': + case 'error': + addMessage('error', msg.message || 'An error occurred'); + break; + + case 'info': + addMessage('info', msg.message || 'Information'); + break; + + case 'sessionUpdate': handleUpdate(msg.update); break; diff --git a/src/ui/EditorContext.ts b/src/ui/EditorContext.ts new file mode 100644 index 00000000..9d2c0fcb --- /dev/null +++ b/src/ui/EditorContext.ts @@ -0,0 +1,302 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; + +export type EditorSelectionContext = { + startLine: number; + startCharacter: number; + endLine: number; + endCharacter: number; + text: string; +}; + +export type EditorLineContext = { + line: number; + text: string; +}; + +export type OpenEditorFile = { + path: string; + openedAt: number; +}; + +export type EditorContext = { + filePath: string; + cursorLine: number; + cursorCharacter: number; + language: string; + selection: EditorSelectionContext | null; + currentLine: EditorLineContext | null; + openEditors: OpenEditorFile[]; +}; + +export type EditorContextPathFormatter = (filePath: string) => string; +export type EditorContextNow = () => number; + +// Global tracker for currently open editor files. +const openEditorOpenedAtByPath = new Map(); + +// Maximum number of open editors to include in context. +export const MAX_OPEN_EDITORS = 5; + +// Maximum context text length to prevent token overflow +export const MAX_CONTEXT_LENGTH = 5000; +export const MAX_CONTEXT_PATH_LENGTH = 240; + +const TRUNCATION_SUFFIX = '… [truncated]'; + +export function truncateText(text: string, maxLength: number): string { + if (maxLength <= 0) { + return ''; + } + if (text.length <= maxLength) { + return text; + } + if (maxLength <= TRUNCATION_SUFFIX.length) { + return text.slice(0, maxLength); + } + const truncatedTextLength = maxLength - TRUNCATION_SUFFIX.length; + return text.slice(0, truncatedTextLength) + TRUNCATION_SUFFIX; +} + +function getBoundedSelectionText( + document: vscode.TextDocument, + selection: vscode.Selection, + maxLength: number, +): string { + let text = ''; + + for (let lineNumber = selection.start.line; lineNumber <= selection.end.line; lineNumber++) { + const lineText = document.lineAt(lineNumber).text; + const startCharacter = lineNumber === selection.start.line ? selection.start.character : 0; + const endCharacter = lineNumber === selection.end.line ? selection.end.character : lineText.length; + const segment = `${lineNumber === selection.start.line ? '' : '\n'}${lineText.slice(startCharacter, endCharacter)}`; + + if (text.length + segment.length > maxLength) { + if (maxLength <= TRUNCATION_SUFFIX.length) { + return (text + segment).slice(0, maxLength); + } + const prefixLength = maxLength - TRUNCATION_SUFFIX.length; + return (text + segment).slice(0, prefixLength) + TRUNCATION_SUFFIX; + } + + text += segment; + } + + return text; +} + +function getOpenEditorCanonicalKey(filePath: string): string { + const normalizedPath = path.normalize(filePath); + // Keep the key simple: do not force lowercase on Windows, even if that can + // duplicate entries for the same file with different casing. + return normalizedPath; +} + +function rememberOpenEditorPath(filePath: string, now: EditorContextNow): number { + const normalizedPath = path.normalize(filePath); + let openedAt = openEditorOpenedAtByPath.get(normalizedPath); + if (openedAt === undefined) { + openedAt = now(); + openEditorOpenedAtByPath.set(normalizedPath, openedAt); + } + return openedAt; +} + +function pruneOpenEditorTracker(files: readonly OpenEditorFile[]): void { + const currentKeys = new Set(files.map(file => getOpenEditorCanonicalKey(file.path))); + + for (const trackedPath of openEditorOpenedAtByPath.keys()) { + if (!currentKeys.has(getOpenEditorCanonicalKey(trackedPath))) { + openEditorOpenedAtByPath.delete(trackedPath); + } + } +} + +// Initialize tracker with VS Code document events. +export function initializeOpenEditorsTracker(now: EditorContextNow = Date.now): vscode.Disposable[] { + const openDocDisposable = vscode.workspace.onDidOpenTextDocument(doc => { + if (doc.uri.scheme === 'file') { + rememberOpenEditorPath(doc.uri.fsPath, now); + } + }); + + const closeDocDisposable = vscode.workspace.onDidCloseTextDocument(doc => { + if (doc.uri.scheme === 'file') { + openEditorOpenedAtByPath.delete(path.normalize(doc.uri.fsPath)); + } + }); + + return [openDocDisposable, closeDocDisposable]; +} + +export function captureEditorContext( + editor: vscode.TextEditor | undefined, + openEditors: OpenEditorFile[] = [], +): EditorContext | null { + if (!editor?.document?.uri) { + return null; + } + + const { document, selection } = editor; + + return { + filePath: document.uri.fsPath, + cursorLine: selection.active.line + 1, + cursorCharacter: selection.active.character + 1, + language: getMarkdownLanguage(document.uri.fsPath, document.languageId), + selection: selection.isEmpty + ? null + : { + startLine: selection.start.line + 1, + startCharacter: selection.start.character + 1, + endLine: selection.end.line + 1, + endCharacter: selection.end.character + 1, + text: getBoundedSelectionText(document, selection, MAX_CONTEXT_LENGTH), + }, + currentLine: selection.isEmpty + ? { + line: selection.active.line + 1, + text: truncateText(document.lineAt(selection.active.line).text, MAX_CONTEXT_LENGTH), + } + : null, + openEditors: normalizeOpenEditorPaths(openEditors), + }; +} + +export function getFilePathsFromTabInput(input: unknown): string[] { + if (input instanceof vscode.TabInputText && input.uri.scheme === 'file') { + return [input.uri.fsPath]; + } + + if (input instanceof vscode.TabInputTextDiff) { + return [input.original, input.modified] + .filter(uri => uri.scheme === 'file') + .map(uri => uri.fsPath); + } + + return []; +} + +export function captureOpenEditorPaths( + tabGroups: readonly vscode.TabGroup[], + now: EditorContextNow = Date.now, +): OpenEditorFile[] { + const files: OpenEditorFile[] = []; + + for (const group of tabGroups) { + for (const tab of group.tabs) { + for (const fsPath of getFilePathsFromTabInput(tab.input)) { + const openedAt = rememberOpenEditorPath(fsPath, now); + files.push({ + path: fsPath, + openedAt, + }); + } + } + } + + pruneOpenEditorTracker(files); + return normalizeOpenEditorPaths(files); +} + +export function normalizeOpenEditorPaths(files: readonly OpenEditorFile[]): OpenEditorFile[] { + const fileMap = new Map(); + + for (const file of files) { + if (!file.path) { + continue; + } + + const normalizedPath = path.normalize(file.path); + const canonicalKey = getOpenEditorCanonicalKey(normalizedPath); + const existing = fileMap.get(canonicalKey); + + if (!existing || file.openedAt > existing.openedAt) { + fileMap.set(canonicalKey, { + ...file, + path: normalizedPath, + }); + } + } + + return Array.from(fileMap.values()) + .sort((a, b) => b.openedAt - a.openedAt) + .slice(0, MAX_OPEN_EDITORS); +} + +export function getSafeFenceMarker(contextText: string): string { + let fence = '```'; + while (contextText.includes(fence)) { + fence += '`'; + } + return fence; +} + +const workspacePathFormatter: EditorContextPathFormatter = filePath => { + return formatEditorContextPath(filePath); +}; + +export function formatEditorContextPath( + filePath: string, + workspaceFolders: readonly vscode.WorkspaceFolder[] | undefined = vscode.workspace.workspaceFolders, +): string { + const normalizedPath = path.normalize(filePath); + const workspaceFolder = workspaceFolders?.find(folder => { + const relativePath = path.relative(path.normalize(folder.uri.fsPath), normalizedPath); + return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); + }); + + const displayPath = workspaceFolder + ? path.relative(path.normalize(workspaceFolder.uri.fsPath), normalizedPath) || path.basename(normalizedPath) + : path.basename(normalizedPath); + + return truncateText(displayPath, MAX_CONTEXT_PATH_LENGTH); +} + +export function buildEditorContextSection( + context: EditorContext | null, + formatPath: EditorContextPathFormatter = workspacePathFormatter, +): string { + if (!context) { + return ''; + } + + const locationLine = context.selection + ? `Selection: ${context.selection.startLine}:${context.selection.startCharacter}-${context.selection.endLine}:${context.selection.endCharacter}` + : `Line: ${context.currentLine?.line ?? context.cursorLine}`; + const contextText = context.selection?.text ?? context.currentLine?.text ?? ''; + const fence = getSafeFenceMarker(contextText); + const displayPath = formatPath(context.filePath); + + const lines = [ + 'VS Code context:', + `File: ${displayPath}`, + `Cursor: ${context.cursorLine}:${context.cursorCharacter}`, + locationLine, + '', + `${fence}${context.language}`, + contextText, + fence, + ]; + + if (context.openEditors.length > 0) { + const relativeEditors = context.openEditors.map(file => formatPath(file.path)); + lines.push('', 'Open editors:', ...relativeEditors.map(filePath => `- ${filePath}`)); + } + + return lines.join('\n'); +} + +export function buildPromptWithEditorContext(prompt: string, context: EditorContext | null): string { + const contextSection = buildEditorContextSection(context); + if (!contextSection) { + return prompt; + } + + return `${contextSection}\n\nUser prompt:\n${prompt}`; +} + +function getMarkdownLanguage(filePath: string, languageId: string): string { + const extension = path.extname(filePath).replace(/^\./, ''); + return extension || languageId || ''; +}