Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/vscode-code-lens-actions.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions plugins/vscode/.vscodeignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.vscode/**
.vscode-test/**
src/**
test-stubs/**
node_modules/**
.gitignore
tsconfig.json
Expand Down
2 changes: 2 additions & 0 deletions plugins/vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
29 changes: 21 additions & 8 deletions plugins/vscode/media/chat-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
26 changes: 26 additions & 0 deletions plugins/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -113,6 +123,16 @@
"when": "view == nanocoder.chatView",
"group": "navigation@2"
}
],
"commandPalette": [
{
"command": "nanocoder.explainCode",
"when": "false"
},
{
"command": "nanocoder.generateTests",
"when": "false"
}
]
},
"configuration": {
Expand Down Expand Up @@ -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."
}
}
}
Expand Down
88 changes: 87 additions & 1 deletion plugins/vscode/src/chat-webview-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout> | null = null;

constructor(
private readonly _extensionUri: vscode.Uri,
Expand Down Expand Up @@ -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.');
Expand All @@ -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,
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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;
}

Expand Down
Loading
Loading