diff --git a/.changeset/vscode-code-lens-actions.md b/.changeset/vscode-code-lens-actions.md new file mode 100644 index 000000000..c79ac7af7 --- /dev/null +++ b/.changeset/vscode-code-lens-actions.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": minor +--- + +Added editor code lenses to the VS Code extension: every function, method, constructor and class now carries `Explain Code` and `Generate Tests` links, and clicking one reveals the chat view and sends that symbol - instruction, `file:startLine-endLine` and the source, fenced with the document language - as a prompt. Symbols come from the language server, so no per-language parsing is involved, and the lenses can be turned off with `nanocoder.codeLens`. Long symbols are capped before being inlined, so a lens click on a large class cannot spend a whole context window on one turn. Also fixes a pre-existing hang where sending a message while a tool approval was still pending left the composer spinning forever. Closes #750. diff --git a/plugins/vscode/.vscodeignore b/plugins/vscode/.vscodeignore index 031130ab2..1605242de 100644 --- a/plugins/vscode/.vscodeignore +++ b/plugins/vscode/.vscodeignore @@ -1,6 +1,7 @@ .vscode/** .vscode-test/** src/** +test-stubs/** node_modules/** .gitignore tsconfig.json diff --git a/plugins/vscode/README.md b/plugins/vscode/README.md index 655abc0a5..76cd89623 100644 --- a/plugins/vscode/README.md +++ b/plugins/vscode/README.md @@ -14,6 +14,7 @@ The extension provides a native sidebar chat powered by the Agent Client Protoco - **Task Checklist**: The AI's task list renders as a live checklist card with per-task status and progress - **Cancellation**: Stop ends the whole turn - the current tool aborts and queued tools are skipped - **Diff Previews**: Click a file-edit card to open the change in VS Code's diff viewer +- **Editor Code Lenses**: `Explain Code` and `Generate Tests` links above every function, method, constructor and class - clicking one opens the chat and sends that symbol as context - **Legacy Companion Mode** (opt-in): Pairs with a terminal CLI session over WebSocket for diff previews and editor context ## Installation @@ -126,6 +127,7 @@ Configure the extension in VS Code settings (`Ctrl+,` / `Cmd+,`): | `nanocoder.mode` | `auto-accept` | Operating mode for the assistant | | `nanocoder.model` | (empty) | Model for Nanocoder sessions (set via the model dropdown) | | `nanocoder.showDiffPreview` | `true` | Show diff preview before applying file changes | +| `nanocoder.codeLens` | `true` | Show Explain Code / Generate Tests lenses above functions and classes | | `nanocoder.autoConnect` | `false` | Auto-connect the legacy WebSocket companion on startup | | `nanocoder.autoStartCli` | `false` | Auto-start the CLI for companion mode if not running | | `nanocoder.serverPort` | `51820` | WebSocket port for the legacy companion mode | diff --git a/plugins/vscode/media/chat-panel.js b/plugins/vscode/media/chat-panel.js index d25379c6a..eb3c0b56d 100644 --- a/plugins/vscode/media/chat-panel.js +++ b/plugins/vscode/media/chat-panel.js @@ -818,13 +818,6 @@ const imagesToSubmit = pendingImages.length > 0 ? [...pendingImages] : undefined; - // Send message to extension host - vscode.postMessage({ - type: 'submitMessage', - text: text, - images: imagesToSubmit - }); - // Clear input. Close the mention first — its token offsets point into // text that is about to disappear. closeMention(); @@ -837,8 +830,23 @@ attachedPaths = []; renderChips(); + dispatchPrompt(text, imagesToSubmit); + } + + // Send `text` to the agent as a turn of its own. Split out of + // submitMessage so an editor-driven prompt can bypass the composer: going + // through it would overwrite a draft the user is typing and sweep up chips + // and images they staged for a different question. + function dispatchPrompt(text, images) { + // Send message to extension host + vscode.postMessage({ + type: 'submitMessage', + text: text, + images: images + }); + // Optimistically append user message - appendMessage(text, 'user', imagesToSubmit); + appendMessage(text, 'user', images); pendingUserMessageText = text; if (!isProcessing) { @@ -1474,6 +1482,11 @@ sessionsData = message.sessions || []; renderSessions(); // Always update so list is ready when history opens break; + case 'runPrompt': + if (isHistoryView) showChatView(); + dispatchPrompt(message.text); + chatInput.focus(); + break; case 'copyLastCodeBlock': copyLastCodeBlock(); break; diff --git a/plugins/vscode/package.json b/plugins/vscode/package.json index a86abdd57..b5aafafba 100644 --- a/plugins/vscode/package.json +++ b/plugins/vscode/package.json @@ -91,6 +91,16 @@ "command": "nanocoder.copyLastCodeBlock", "title": "Copy Last Code Block", "category": "Nanocoder" + }, + { + "command": "nanocoder.explainCode", + "title": "Explain Code", + "category": "Nanocoder" + }, + { + "command": "nanocoder.generateTests", + "title": "Generate Tests", + "category": "Nanocoder" } ], "keybindings": [ @@ -113,6 +123,16 @@ "when": "view == nanocoder.chatView", "group": "navigation@2" } + ], + "commandPalette": [ + { + "command": "nanocoder.explainCode", + "when": "false" + }, + { + "command": "nanocoder.generateTests", + "when": "false" + } ] }, "configuration": { @@ -158,6 +178,12 @@ "type": "string", "scope": "resource", "description": "Working directory for the Nanocoder CLI. Defaults to the current VS Code workspace root." + }, + "nanocoder.codeLens": { + "type": "boolean", + "default": true, + "scope": "resource", + "description": "Show Explain Code / Generate Tests actions above functions and classes in the editor." } } } diff --git a/plugins/vscode/src/chat-webview-provider.ts b/plugins/vscode/src/chat-webview-provider.ts index 5c28224a0..09c9eeb32 100644 --- a/plugins/vscode/src/chat-webview-provider.ts +++ b/plugins/vscode/src/chat-webview-provider.ts @@ -22,11 +22,23 @@ const MENTION_ALWAYS_EXCLUDE = [ '**/coverage/**', ]; -export class ChatWebviewProvider implements vscode.WebviewViewProvider { +/** + * How long an editor-driven prompt waits for the webview shell and the ACP + * session before it is dropped. Without a bound, a prompt queued while the CLI + * is down would fire whenever the connection eventually came up - long after + * the user moved on from the code they clicked. + */ +const PENDING_PROMPT_TIMEOUT_MS = 30_000; + +export class ChatWebviewProvider + implements vscode.WebviewViewProvider, vscode.Disposable { public static readonly viewType = 'nanocoder.chatView'; private _view?: vscode.WebviewView; private _isWebviewReady = false; + /** Code lens prompt waiting on the webview shell and the ACP session. */ + private _pendingPrompt: string | null = null; + private _pendingPromptTimer: ReturnType | null = null; constructor( private readonly _extensionUri: vscode.Uri, @@ -91,6 +103,58 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { } } + /** + * Reveal the chat view and run `text` as a prompt. An editor code lens can + * fire long before the sidebar has ever been opened, so the prompt is held + * until the shell reports ready and a session exists. + */ + public async sendPrompt(text: string) { + this._queuePendingPrompt(text); + await vscode.commands.executeCommand(`${ChatWebviewProvider.viewType}.focus`); + await this._initializeSessionIfReady(); + } + + private _queuePendingPrompt(text: string) { + this._clearPendingPrompt(); + this._pendingPrompt = text; + this._pendingPromptTimer = setTimeout(() => { + this._pendingPromptTimer = null; + this._pendingPrompt = null; + vscode.window.showWarningMessage( + 'Nanocoder: the agent did not start in time, so your editor request was not sent. Try again once the chat view is connected.', + ); + }, PENDING_PROMPT_TIMEOUT_MS); + } + + private _clearPendingPrompt() { + if (this._pendingPromptTimer) { + clearTimeout(this._pendingPromptTimer); + this._pendingPromptTimer = null; + } + this._pendingPrompt = null; + } + + /** + * Hand a queued prompt to the webview. Cleared before posting so a failed + * delivery can't be retried into a half-loaded shell. + */ + private _flushPendingPrompt() { + const text = this._pendingPrompt; + if (text === null) { + return; + } + this._clearPendingPrompt(); + this.postMessage({type: 'runPrompt', text}); + } + + /** + * Registered with the extension's subscriptions so a deactivate cannot + * leave the pending-prompt timer running against a disposed view. + */ + public dispose() { + this._clearPendingPrompt(); + } + public requestCopyLastCodeBlock() { if (!this._view) { vscode.window.showInformationMessage('Nanocoder: open the Nanocoder chat view first.'); @@ -111,6 +175,23 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { _token: vscode.CancellationToken, ) { this._view = webviewView; + // A re-resolve means a brand new shell that has not run its script yet. + // Leaving the flag set from the previous one would let a queued prompt + // post into a webview with no message listener attached, dropping it. + this._isWebviewReady = false; + webviewView.onDidDispose(() => { + // A disposal can land after a newer view has already been resolved + // (VS Code tears the old one down late). Without this guard that + // stale event would null out the live view and drop its state. + if (this._view !== webviewView) { + return; + } + this._view = undefined; + this._isWebviewReady = false; + // A queued prompt is deliberately kept: a disposal is usually a + // re-reveal in progress, and the next resolve is what delivers it. + // The timeout is what bounds the wait if no view comes back. + }); webviewView.webview.options = { enableScripts: true, @@ -273,6 +354,7 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { this._outputChannel.appendLine(`[Extension] Session initialized automatically: ${sessionId}`); // Broadcast session list to populate History tab await this._broadcastSessions(); + this._flushPendingPrompt(); } } catch (error) { this._outputChannel.appendLine(`Failed to initialize session on ready: ${error}`); @@ -423,6 +505,10 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { try { if (this._acpClient.hasPendingPermissions()) { vscode.window.showWarningMessage('Nanocoder: Please approve or deny the pending tool before sending a new message.'); + // The webview has already drawn the user bubble and flipped to + // the loading state, and no turn is going to start - so end the + // turn here or the composer spins until the user hits Escape. + this.postMessage({type: 'acpUpdate', update: {sessionUpdate: 'prompt_response'}}); return; } diff --git a/plugins/vscode/src/code-lens-provider.spec.ts b/plugins/vscode/src/code-lens-provider.spec.ts new file mode 100644 index 000000000..1283f0b01 --- /dev/null +++ b/plugins/vscode/src/code-lens-provider.spec.ts @@ -0,0 +1,232 @@ +import test from 'ava'; +import * as vscode from 'vscode'; +import { + buildCodeLensPrompt, + MAX_LENS_SOURCE_LINES, + NanocoderCodeLensProvider, + sendCodeLensPrompt, + truncateLensSource, +} from './code-lens-provider'; + +const {__test} = vscode as unknown as {__test: any}; + +const NEVER_CANCELLED = { + isCancellationRequested: false, + onCancellationRequested: () => ({dispose: () => {}}), +} as unknown as vscode.CancellationToken; + +const DOCUMENT = { + uri: vscode.Uri.file('/repo/src/thing.ts'), +} as unknown as vscode.TextDocument; + +function symbol( + name: string, + kind: vscode.SymbolKind, + children: any[] = [], +): any { + return { + name, + kind, + range: new vscode.Range(0, 0, 10, 0), + selectionRange: new vscode.Range(0, 6, 0, 6 + name.length), + children, + }; +} + +test.beforeEach(() => { + __test.reset(); +}); + +test('provideCodeLenses - keeps lensable kinds and walks nested children', async t => { + __test.executeCommand = async () => [ + symbol('Widget', vscode.SymbolKind.Class, [ + symbol('constructor', vscode.SymbolKind.Constructor), + symbol('render', vscode.SymbolKind.Method), + // Fields and properties are deliberately skipped - a lens on every + // one of them would bury the editor. + symbol('count', vscode.SymbolKind.Property), + symbol('label', vscode.SymbolKind.Field), + ]), + symbol('helper', vscode.SymbolKind.Function, [ + // Nested one level deeper than any top-level symbol, so it only + // shows up if the walk actually recurses. + symbol('inner', vscode.SymbolKind.Function), + ]), + symbol('total', vscode.SymbolKind.Variable), + ]; + + const lenses = await new NanocoderCodeLensProvider().provideCodeLenses( + DOCUMENT, + NEVER_CANCELLED, + ); + + // Class, Constructor, Method, Function, nested Function - two lenses each. + t.is(lenses.length, 10); + t.deepEqual( + [...new Set(lenses.map(lens => lens.command?.title))], + ['Explain Code', 'Generate Tests'], + ); + t.deepEqual( + [...new Set(lenses.map(lens => lens.command?.command))], + ['nanocoder.explainCode', 'nanocoder.generateTests'], + ); + // The lens is anchored on the name, but the command carries the whole body. + t.is((lenses[0].command?.arguments?.[1] as vscode.Range).end.line, 10); +}); + +test('provideCodeLenses - skips legacy symbols with no selectionRange', async t => { + const legacy = symbol('legacy', vscode.SymbolKind.Function); + legacy.selectionRange = undefined; + __test.executeCommand = async () => [legacy]; + + const lenses = await new NanocoderCodeLensProvider().provideCodeLenses( + DOCUMENT, + NEVER_CANCELLED, + ); + + t.deepEqual(lenses, []); +}); + +test('provideCodeLenses - short-circuits when nanocoder.codeLens is false', async t => { + let symbolProviderCalls = 0; + __test.configuration = (_section: string, key: string, fallback: unknown) => + key === 'codeLens' ? false : fallback; + __test.executeCommand = async () => { + symbolProviderCalls++; + return [symbol('helper', vscode.SymbolKind.Function)]; + }; + + const lenses = await new NanocoderCodeLensProvider().provideCodeLenses( + DOCUMENT, + NEVER_CANCELLED, + ); + + t.deepEqual(lenses, []); + // Bailing before the symbol request is the point: the language server is + // not asked to do work whose result is thrown away. + t.is(symbolProviderCalls, 0); +}); + +test('buildCodeLensPrompt - instruction, locator, then fenced source', t => { + const prompt = buildCodeLensPrompt({ + instruction: 'Explain what this code does.', + relativePath: 'src/thing.ts', + startLine: 12, + endLine: 14, + languageId: 'typescript', + source: 'function add(a, b) {\n\treturn a + b;\n}', + }); + + t.is( + prompt, + [ + 'Explain what this code does.', + '', + 'src/thing.ts:12-14', + '```typescript', + 'function add(a, b) {', + '\treturn a + b;', + '}', + '```', + ].join('\n'), + ); +}); + +test('buildCodeLensPrompt - caps a long symbol and points at the file', t => { + const source = Array.from( + {length: MAX_LENS_SOURCE_LINES + 40}, + (_unused, i) => `\tline ${i};`, + ).join('\n'); + + const prompt = buildCodeLensPrompt({ + instruction: 'Write unit tests for this code.', + relativePath: 'src/huge.ts', + startLine: 1, + endLine: MAX_LENS_SOURCE_LINES + 40, + languageId: 'typescript', + source, + }); + + t.true(prompt.includes('\tline 0;')); + t.false(prompt.includes(`\tline ${MAX_LENS_SOURCE_LINES};`)); + t.true(prompt.endsWith('(truncated - 40 more lines; read src/huge.ts for the rest)')); + // The locator survives truncation, so the agent can still find the rest. + t.true(prompt.includes(`src/huge.ts:1-${MAX_LENS_SOURCE_LINES + 40}`)); +}); + +test('truncateLensSource - leaves a short symbol untouched', t => { + const source = 'const a = 1;\nconst b = 2;'; + t.deepEqual(truncateLensSource(source), { + text: source, + omittedLines: 0, + truncated: false, + }); +}); + +test('truncateLensSource - cuts a single line that busts the char cap', t => { + const result = truncateLensSource('x'.repeat(500), 200, 100); + t.is(result.text.length, 100); + t.is(result.omittedLines, 0); + t.true(result.truncated); +}); + +test('truncateLensSource - char cap can bind before the line cap', t => { + const result = truncateLensSource('abcd\nabcd\nabcd\nabcd', 200, 12); + t.is(result.text, 'abcd\nabcd'); + t.is(result.omittedLines, 2); + t.true(result.truncated); +}); + +test('sendCodeLensPrompt - hands the built prompt to the chat view', async t => { + __test.asRelativePath = () => 'src/thing.ts'; + __test.openTextDocument = async () => ({ + languageId: 'typescript', + getText: () => 'const answer = 42;', + }); + + const sent: string[] = []; + const chatProvider = { + sendPrompt: async (text: string) => { + sent.push(text); + }, + } as any; + + await sendCodeLensPrompt( + chatProvider, + 'Explain what this code does.', + vscode.Uri.file('/repo/src/thing.ts'), + new vscode.Range(11, 0, 13, 1), + ); + + t.deepEqual(sent, [ + [ + 'Explain what this code does.', + '', + // The range is 0-based; the locator the agent sees is not. + 'src/thing.ts:12-14', + '```typescript', + 'const answer = 42;', + '```', + ].join('\n'), + ]); +}); + +test('sendCodeLensPrompt - explains itself when invoked without lens arguments', async t => { + let sendPromptCalls = 0; + const chatProvider = { + sendPrompt: async () => { + sendPromptCalls++; + }, + } as any; + + // A keybinding or another extension can reach the command directly, with + // nothing to describe. + await sendCodeLensPrompt(chatProvider, 'Explain what this code does.'); + + t.is(sendPromptCalls, 0); + t.deepEqual( + __test.shownMessages.map((m: any) => m.kind), + ['info'], + ); + t.regex(__test.shownMessages[0].message, /Explain Code \/ Generate Tests/); +}); diff --git a/plugins/vscode/src/code-lens-provider.ts b/plugins/vscode/src/code-lens-provider.ts new file mode 100644 index 000000000..0a31acbbb --- /dev/null +++ b/plugins/vscode/src/code-lens-provider.ts @@ -0,0 +1,200 @@ +import * as vscode from 'vscode'; +import type { ChatWebviewProvider } from './chat-webview-provider'; + +/** + * Symbols worth a lens. Anything finer-grained (properties, variables) would + * bury the editor in links. Constructors are included because they read as + * ordinary methods to the user, and a class lens covers the whole body rather + * than the constructor on its own. + */ +export const LENS_SYMBOL_KINDS: ReadonlySet = new Set([ + vscode.SymbolKind.Function, + vscode.SymbolKind.Method, + vscode.SymbolKind.Constructor, + vscode.SymbolKind.Class, +]); + +/** + * Caps on the source inlined into a lens prompt. `Generate Tests` on a + * thousand-line class would otherwise paste the entire body into the + * conversation and spend a local model's whole context on one turn. The head of + * a symbol carries the signature and the shape, so a truncated body plus the + * `file:start-end` locator still leaves the agent enough to work from - it can + * read the file for the remainder. + */ +export const MAX_LENS_SOURCE_LINES = 200; +export const MAX_LENS_SOURCE_CHARS = 8_000; + +export interface TruncatedSource { + text: string; + /** Whole lines dropped from the end. Zero when a lone long line was cut. */ + omittedLines: number; + truncated: boolean; +} + +/** + * Clip `source` to the line and character caps, whichever binds first. At least + * one line is always kept so the symbol's signature survives. + */ +export function truncateLensSource( + source: string, + maxLines: number = MAX_LENS_SOURCE_LINES, + maxChars: number = MAX_LENS_SOURCE_CHARS, +): TruncatedSource { + const lines = source.split('\n'); + + let keptLines = 0; + let chars = 0; + for (const line of lines) { + if (keptLines >= maxLines) break; + const next = chars + line.length + (keptLines > 0 ? 1 : 0); + if (keptLines > 0 && next > maxChars) break; + chars = next; + keptLines++; + } + + if (keptLines === lines.length && chars <= maxChars) { + return { text: source, omittedLines: 0, truncated: false }; + } + + // A single line over the character cap - minified or generated code - has no + // line boundary to fall back to, so it is cut mid-line. + const text = lines.slice(0, keptLines).join('\n').slice(0, maxChars); + return { text, omittedLines: lines.length - keptLines, truncated: true }; +} + +export interface CodeLensPromptInput { + instruction: string; + /** Workspace-relative path of the clicked symbol's file. */ + relativePath: string; + /** 1-based, inclusive. */ + startLine: number; + /** 1-based, inclusive. */ + endLine: number; + languageId: string; + source: string; +} + +/** + * Build the prompt a lens click sends. The symbol source is inlined rather than + * attached as a file: the agent should see the one function the user clicked, + * not everything around it. The locator is always present, so a truncated body + * still points at the rest. + */ +export function buildCodeLensPrompt(input: CodeLensPromptInput): string { + const { text, omittedLines, truncated } = truncateLensSource(input.source); + const location = `${input.relativePath}:${input.startLine}-${input.endLine}`; + + const parts = [ + input.instruction, + '', + location, + '```' + input.languageId, + text, + '```', + ]; + + if (truncated) { + parts.push( + omittedLines > 0 + ? `(truncated - ${omittedLines} more lines; read ${input.relativePath} for the rest)` + : `(truncated; read ${input.relativePath} for the rest)`, + ); + } + + return parts.join('\n'); +} + +export class NanocoderCodeLensProvider + implements vscode.CodeLensProvider, vscode.Disposable { + private readonly _onDidChangeCodeLenses = new vscode.EventEmitter(); + public readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event; + + public refresh(): void { + this._onDidChangeCodeLenses.fire(); + } + + public dispose(): void { + this._onDidChangeCodeLenses.dispose(); + } + + public async provideCodeLenses( + document: vscode.TextDocument, + token: vscode.CancellationToken, + ): Promise { + // Scoped to the document so a folder-level override wins in a + // multi-root workspace. + const config = vscode.workspace.getConfiguration('nanocoder', document.uri); + if (!config.get('codeLens', true)) { + return []; + } + + // The language server already knows where the functions are, so nothing + // here has to parse a single line of source. + const symbols = await vscode.commands.executeCommand( + 'vscode.executeDocumentSymbolProvider', + document.uri, + ); + if (token.isCancellationRequested || !Array.isArray(symbols)) { + return []; + } + + const lenses: vscode.CodeLens[] = []; + const walk = (nodes: vscode.DocumentSymbol[]) => { + for (const symbol of nodes) { + // selectionRange is absent on the legacy SymbolInformation shape + // some providers still return; skip those rather than throw. + if (LENS_SYMBOL_KINDS.has(symbol.kind) && symbol.selectionRange) { + // Anchored on the name so the lens sits on the declaration + // line instead of above a preceding doc comment, while the + // command still receives the symbol's whole body. + const args = [document.uri, symbol.range]; + lenses.push( + new vscode.CodeLens(symbol.selectionRange, { + title: 'Explain Code', + command: 'nanocoder.explainCode', + arguments: args, + }), + new vscode.CodeLens(symbol.selectionRange, { + title: 'Generate Tests', + command: 'nanocoder.generateTests', + arguments: args, + }), + ); + } + walk(symbol.children ?? []); + } + }; + walk(symbols); + + return lenses; + } +} + +export async function sendCodeLensPrompt( + chatProvider: ChatWebviewProvider, + instruction: string, + uri?: vscode.Uri, + range?: vscode.Range, +): Promise { + // The commands are hidden from the palette, but a keybinding or another + // extension can still invoke them with no lens arguments. + if (!uri || !range) { + vscode.window.showInformationMessage( + 'Nanocoder: use the Explain Code / Generate Tests links above a function to run this.', + ); + return; + } + + const document = await vscode.workspace.openTextDocument(uri); + await chatProvider.sendPrompt( + buildCodeLensPrompt({ + instruction, + relativePath: vscode.workspace.asRelativePath(uri), + startLine: range.start.line + 1, + endLine: range.end.line + 1, + languageId: document.languageId, + source: document.getText(range), + }), + ); +} diff --git a/plugins/vscode/src/extension.ts b/plugins/vscode/src/extension.ts index 9371c1521..b0910bedd 100644 --- a/plugins/vscode/src/extension.ts +++ b/plugins/vscode/src/extension.ts @@ -13,6 +13,10 @@ import {AcpStateManager, ACPStatus} from './acp-state'; import {NanocoderAcpClient} from './acp-client'; import {AcpProcessManager} from './acp-process-manager'; import {ChatWebviewProvider} from './chat-webview-provider'; +import { + NanocoderCodeLensProvider, + sendCodeLensPrompt, +} from './code-lens-provider'; const DEFAULT_PORT = 51820; const ACTIVE_EDITOR_DEBOUNCE_MS = 150; @@ -55,6 +59,7 @@ export function activate(context: vscode.ExtensionContext) { // Register Webview Provider const chatProvider = new ChatWebviewProvider(context.extensionUri, outputChannel, acpClient, diffManager); context.subscriptions.push( + chatProvider, vscode.window.registerWebviewViewProvider(ChatWebviewProvider.viewType, chatProvider, { // Preserve DOM when user switches to Explorer/SCM/etc. and back. // Without this VS Code destroys the webview on hide, wiping the transcript. @@ -112,6 +117,25 @@ export function activate(context: vscode.ExtensionContext) { }), ); + // Inline "Explain Code" / "Generate Tests" links above every function and + // class, so a symbol can be handed to the agent without leaving the editor. + const codeLensProvider = new NanocoderCodeLensProvider(); + context.subscriptions.push( + codeLensProvider, + vscode.languages.registerCodeLensProvider({scheme: 'file'}, codeLensProvider), + vscode.workspace.onDidChangeConfiguration(event => { + if (event.affectsConfiguration('nanocoder.codeLens')) { + codeLensProvider.refresh(); + } + }), + vscode.commands.registerCommand('nanocoder.explainCode', (uri?: vscode.Uri, range?: vscode.Range) => + sendCodeLensPrompt(chatProvider, 'Explain what this code does.', uri, range), + ), + vscode.commands.registerCommand('nanocoder.generateTests', (uri?: vscode.Uri, range?: vscode.Range) => + sendCodeLensPrompt(chatProvider, 'Write unit tests for this code.', uri, range), + ), + ); + // Push active editor state to the CLI so the input box can show an // "In " pill and auto-attach a selection as context on submit. context.subscriptions.push( diff --git a/plugins/vscode/src/webview-protocol.ts b/plugins/vscode/src/webview-protocol.ts index 723cdcd03..6dcdd06ef 100644 --- a/plugins/vscode/src/webview-protocol.ts +++ b/plugins/vscode/src/webview-protocol.ts @@ -82,6 +82,12 @@ export interface ExtensionMessageCopyLastCodeBlock { type: 'copyLastCodeBlock'; } +/** Prompt built by an editor code lens; the composer submits it verbatim. */ +export interface ExtensionMessageRunPrompt { + type: 'runPrompt'; + text: string; +} + export interface ExtensionMessageCopyResult { type: 'copyResult'; ok: boolean; @@ -150,6 +156,7 @@ export type ExtensionToWebviewMessage = | ExtensionMessagePathInfoResolved | ExtensionMessageCopyLastCodeBlock | ExtensionMessageCopyResult + | ExtensionMessageRunPrompt | ExtensionMessageMentionCompletions; diff --git a/plugins/vscode/test-stubs/vscode.ts b/plugins/vscode/test-stubs/vscode.ts new file mode 100644 index 000000000..7b256b822 --- /dev/null +++ b/plugins/vscode/test-stubs/vscode.ts @@ -0,0 +1,288 @@ +/** + * Runtime stand-in for the `vscode` module. + * + * The real module is injected by the extension host and cannot be resolved from + * plain Node, so nothing under `plugins/vscode/src` that imports it was + * loadable by AVA. The root tsconfig maps the bare `vscode` specifier here (see + * its `paths` entry) so the extension's units can be exercised without an + * extension host. + * + * Only the surface the extension actually touches is implemented. Anything a + * test needs to steer is overridable through `__test`. + */ + +export class Position { + constructor( + public readonly line: number, + public readonly character: number, + ) {} +} + +export class Range { + public readonly start: Position; + public readonly end: Position; + + constructor(start: Position, end: Position); + constructor( + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number, + ); + constructor( + a: Position | number, + b?: Position | number, + c?: number, + d?: number, + ) { + if (typeof a === 'number') { + this.start = new Position(a, b as number); + this.end = new Position(c as number, d as number); + } else { + this.start = a; + this.end = b as Position; + } + } + + get isEmpty(): boolean { + return ( + this.start.line === this.end.line && + this.start.character === this.end.character + ); + } +} + +export class Selection extends Range {} + +export interface Command { + title: string; + command: string; + arguments?: unknown[]; +} + +export class CodeLens { + constructor( + public readonly range: Range, + public readonly command?: Command, + ) {} + + get isResolved(): boolean { + return this.command !== undefined; + } +} + +export class Disposable { + constructor(private readonly _callOnDispose: () => void) {} + dispose(): void { + this._callOnDispose(); + } +} + +export class EventEmitter { + private readonly _listeners = new Set<(e: T) => unknown>(); + + // A bound property, not a method: consumers hand `emitter.event` out + // directly as their public `onDidX`. + public readonly event = (listener: (e: T) => unknown) => { + this._listeners.add(listener); + return new Disposable(() => this._listeners.delete(listener)); + }; + + fire(data: T): void { + for (const listener of [...this._listeners]) listener(data); + } + + dispose(): void { + this._listeners.clear(); + } +} + +export class Uri { + private constructor( + public readonly scheme: string, + public readonly fsPath: string, + ) {} + + static file(fsPath: string): Uri { + return new Uri('file', fsPath); + } + + static joinPath(base: Uri, ...parts: string[]): Uri { + return new Uri(base.scheme, [base.fsPath, ...parts].join('/')); + } + + get path(): string { + return this.fsPath; + } + + with(_change: Record): Uri { + return this; + } + + toString(): string { + return `${this.scheme}://${this.fsPath}`; + } +} + +export enum SymbolKind { + File = 0, + Module = 1, + Namespace = 2, + Package = 3, + Class = 4, + Method = 5, + Property = 6, + Field = 7, + Constructor = 8, + Enum = 9, + Interface = 10, + Function = 11, + Variable = 12, + Constant = 13, + String = 14, + Number = 15, + Boolean = 16, + Array = 17, + Object = 18, + Key = 19, + Null = 20, + EnumMember = 21, + Struct = 22, + Event = 23, + Operator = 24, + TypeParameter = 25, +} + +export enum ConfigurationTarget { + Global = 1, + Workspace = 2, + WorkspaceFolder = 3, +} + +export enum StatusBarAlignment { + Left = 1, + Right = 2, +} + +export enum DiagnosticSeverity { + Error = 0, + Warning = 1, + Information = 2, + Hint = 3, +} + +export enum ViewColumn { + Active = -1, + One = 1, +} + +/** + * Test-controlled behaviour. Every hook falls back to an inert default, so a + * test only overrides what it cares about; `reset()` restores all of them. + */ +export const __test = { + /** Backs `workspace.getConfiguration(section, scope).get(key, default)`. */ + configuration: (_section: string, _key: string, fallback: unknown): unknown => + fallback, + /** Backs `commands.executeCommand`. */ + executeCommand: async (_command: string, ..._args: unknown[]): Promise => + undefined, + /** Backs `workspace.openTextDocument`. */ + openTextDocument: async (_uri: Uri): Promise => ({ + languageId: 'plaintext', + getText: () => '', + }), + /** Backs `workspace.asRelativePath`. */ + asRelativePath: (target: Uri | string): string => + typeof target === 'string' ? target : target.fsPath, + /** Messages surfaced through `window.show*Message`, newest last. */ + shownMessages: [] as {kind: 'info' | 'warning' | 'error'; message: string}[], + + reset(): void { + __test.configuration = (_s, _k, fallback) => fallback; + __test.executeCommand = async () => undefined; + __test.openTextDocument = async () => ({ + languageId: 'plaintext', + getText: () => '', + }); + __test.asRelativePath = target => + typeof target === 'string' ? target : target.fsPath; + __test.shownMessages = []; + }, +}; + +const noopDisposable = new Disposable(() => {}); + +export const workspace = { + workspaceFolders: undefined as {uri: Uri}[] | undefined, + textDocuments: [] as unknown[], + getConfiguration(section: string, _scope?: unknown) { + return { + get: (key: string, fallback?: T): T => + __test.configuration(section, key, fallback) as T, + update: async () => undefined, + }; + }, + openTextDocument: (uri: Uri) => __test.openTextDocument(uri), + asRelativePath: (target: Uri | string) => __test.asRelativePath(target), + onDidChangeConfiguration: () => noopDisposable, + onDidChangeTextDocument: () => noopDisposable, +}; + +export const commands = { + executeCommand: (command: string, ...args: unknown[]) => + __test.executeCommand(command, ...args), + registerCommand: () => noopDisposable, +}; + +export const window = { + activeTextEditor: undefined as unknown, + showInformationMessage: async (message: string) => { + __test.shownMessages.push({kind: 'info', message}); + return undefined; + }, + showWarningMessage: async (message: string) => { + __test.shownMessages.push({kind: 'warning', message}); + return undefined; + }, + showErrorMessage: async (message: string) => { + __test.shownMessages.push({kind: 'error', message}); + return undefined; + }, + createOutputChannel: (_name: string) => ({ + appendLine: () => {}, + append: () => {}, + show: () => {}, + dispose: () => {}, + }), + createStatusBarItem: () => ({ + text: '', + tooltip: '', + command: '', + show: () => {}, + hide: () => {}, + dispose: () => {}, + }), + createTerminal: () => ({sendText: () => {}, show: () => {}, dispose: () => {}}), + showTextDocument: async () => undefined, + showOpenDialog: async () => undefined, + registerWebviewViewProvider: () => noopDisposable, + onDidChangeActiveTextEditor: () => noopDisposable, + onDidChangeTextEditorSelection: () => noopDisposable, +}; + +export const languages = { + registerCodeLensProvider: () => noopDisposable, + getDiagnostics: () => [] as unknown[], +}; + +export const extensions = { + getExtension: (_id: string) => undefined as unknown, +}; + +export const env = { + clipboard: { + writeText: async (_text: string) => undefined, + readText: async () => '', + }, +}; diff --git a/source/vscode/chat-panel-harness.ts b/source/vscode/chat-panel-harness.ts index b4a43989b..0efe69a1b 100644 --- a/source/vscode/chat-panel-harness.ts +++ b/source/vscode/chat-panel-harness.ts @@ -14,6 +14,16 @@ const PANEL_SOURCE = readFileSync( 'utf8', ); +// chat-panel.html loads mention-utils.js ahead of chat-panel.js, which reads +// its exports off globalThis at IIFE time. Boot it in the same order here or +// the panel throws before any of it is reachable. +const MENTION_UTILS_SOURCE = readFileSync( + fileURLToPath( + new URL('../../plugins/vscode/media/mention-utils.js', import.meta.url), + ), + 'utf8', +); + const SHELL_IDS = [ 'add-image-btn', 'attach-btn', @@ -237,6 +247,7 @@ export function createPanel(options: {marked?: boolean} = {}) { } createContext(sandbox); + runInContext(MENTION_UTILS_SOURCE, sandbox); runInContext(PANEL_SOURCE, sandbox); const container = findById(root, 'messages-container') as StubElement; diff --git a/tsconfig.json b/tsconfig.json index e248637a1..454e4003c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,13 @@ "types": ["node"], "baseUrl": ".", "paths": { - "@/*": ["source/*"] + "@/*": ["source/*"], + // The real `vscode` module only exists inside the extension host, so + // AVA (which loads specs through tsx, and tsx honours these paths) + // resolves it to a stub. Test-only: the extension is bundled from + // plugins/vscode/tsconfig.json with `--external:vscode`, and nothing + // under source/ imports it. + "vscode": ["plugins/vscode/test-stubs/vscode.ts"] } }, "include": ["source/**/*"],