Skip to content

add Explain Code / Generate Tests code lenses - #866

Merged
will-lamerton merged 8 commits into
Nano-Collective:mainfrom
addyCooks:feat/vscode-code-lens-actions
Aug 20, 2026
Merged

add Explain Code / Generate Tests code lenses#866
will-lamerton merged 8 commits into
Nano-Collective:mainfrom
addyCooks:feat/vscode-code-lens-actions

Conversation

@addyCooks

@addyCooks addyCooks commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Closes #750.

Description

Adds inline Explain Code / Generate Tests code lenses above every function, method, constructor and class. Clicking one reveals the chat view and submits that symbol as a prompt instruction, a file:startLine-endLine locator, and the source fenced with the document's language. Previously, asking the agent about a specific function meant switching to the sidebar and pasting the code in.

Symbols come from executeDocumentSymbolProvider, so there is no per-language parsing. Each lens anchors on the symbol's selectionRange (the name) while the command carries symbol.range (the whole body). Lenses can be turned off with the nanocoder.codeLens setting.

Inlined source is capped at 200 lines / 8000 characters, whichever binds first, with a truncation marker, so Generate Tests on a large class cannot spend a whole context window on one turn. The file:startLine-endLine locator is emitted before the fence and survives truncation, so the agent can read the rest itself.

Also fixed here

Three latent bugs in ChatWebviewProvider that a lens click makes much easier to hit:

  • Sending a message while a tool approval was still pending left the composer spinning forever. The webview had already drawn the user bubble and flipped to the loading state, but the early return posted nothing, so no prompt_response ever ended the turn. Pre-existing.
  • _isWebviewReady was not reset on re-resolve, so a queued prompt could post into a fresh shell that had not yet attached its message listener, and be dropped.
  • The pending-prompt timer could outlive the view, and a late onDidDispose from an already-replaced view could null out the live one.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Changeset

  • Added a changeset (pnpm changeset) describing this change for the changelog

Testing

Automated Tests

  • New features include passing tests in .spec.ts/tsx files
  • All existing tests pass (pnpm test:all completes successfully)
  • Tests cover both success and error scenarios

18 specs across two files:

  • code-lens-provider.spec.ts (10) - kind filtering plus the nested-children walk, the codeLens: false short-circuit, legacy symbols with no selectionRange, the prompt string shape, the truncation boundaries (line cap, char cap, and a single line that busts the char cap), and the command invoked with no lens arguments.
  • chat-webview-provider.spec.ts (8) - a prompt rejected for a pending permission ends the turn, a real turn still ends exactly once, dispose() clears the pending timer, a prompt dropped by dispose is not delivered to a later view, a stale view disposal does not clear a newer view, disposing the live view does clear it, a queued prompt survives a re-reveal, and a re-resolved shell is not treated as ready until it says so.

Each fix was checked by reverting it individually and confirming the corresponding spec fails.

Manual Testing

  • Tested with Ollama
  • Tested with OpenRouter
  • Tested with OpenAI-compatible API
  • Tested MCP integration (if applicable)

Checklist

  • Code follows project style guidelines
  • Self-review completed
  • Documentation updated (if needed)
  • No breaking changes (or clearly documented)
  • Appropriate logging added using structured logging (see CONTRIBUTING.md)

Reaching the agent about a specific function meant switching to the sidebar
and pasting the code in. Every function, method and class now carries two
inline links instead.

Clicking one reveals the chat view and submits the symbol as a prompt: the
instruction, a `file:startLine-endLine` locator and the source fenced with
the document's language. The snippet is inlined rather than attached as an
`@[file]` chip so the agent sees the one symbol that was clicked instead of
the whole file.

Symbols come from `vscode.executeDocumentSymbolProvider`, so the lenses
follow whatever language servers the user already has and nothing here
parses source. Lenses anchor on the symbol's selectionRange - the
declaration line - while the command receives the full body range, so a
preceding doc comment doesn't push the links away from the signature.

A lens can be clicked before the sidebar has ever been opened, so the prompt
is held in `_pendingPrompt` and flushed from `_initializeSessionIfReady`,
which already runs both on webview ready and on ACP connect - whichever
lands last.

The two commands are hidden from the palette: they take a uri and a symbol
range, so a bare invocation would have nothing to act on. `nanocoder.codeLens`
turns the lenses off.

Closes Nano-Collective#750.
_isWebviewReady was latched on the first shell and never cleared, so once
the Nanocoder view had been disposed - hidden from its container, or moved
to another one - the next lens click posted runPrompt into a replacement
webview that had not run its script yet. The message went nowhere and the
prompt was cleared, so the click did nothing at all. Reset the flag on
every resolve and drop the view on dispose.

The queued prompt is also bounded now. It used to sit indefinitely when the
CLI was down and then fire from onConnectionReady whenever the agent
happened to come up, answering about code the user had long moved past,
with no feedback in the meantime. It expires after 30s with a warning
instead, and the timer is disarmed once the prompt is handed over.
runPrompt drove the composer: it overwrote chat-input and called
submitMessage, which then folded in attachedPaths and pendingImages. So
clicking Explain Code discarded whatever the user was typing and sent any
file chip or pasted image they had staged for a different question, then
cleared them.

Split the send tail out of submitMessage as dispatchPrompt and route the
editor prompt straight through it, leaving the draft and the staged
context untouched.
explainCode/generateTests are hidden from the palette but a keybinding or
another extension can still invoke them bare. uri and range were assumed
present, so openTextDocument(undefined) opened an untitled document and
range.start then threw; guard and point the user at the lenses instead.

Also dispose the onDidChangeCodeLenses emitter with the extension rather
than leaking it, and resolve nanocoder.codeLens against the document so a
folder-level override wins in a multi-root workspace - it is declared
scope: resource to match.
@will-lamerton

Copy link
Copy Markdown
Member

Hey @addyCooks - nice work, this is a clean implementation. Using executeDocumentSymbolProvider instead of per-language parsing is the right call, and anchoring lenses on selectionRange while passing symbol.range as the payload is a good detail. The _isWebviewReady reset in resolveWebviewView is a real latent bug fix on its own.

A few things before merge:

  1. Stuck spinner on rejected prompts. _handlePrompt early-returns when hasPendingPermissions() is true, but the webview has already appended the user bubble and called setProcessing(true). Nothing posts prompt_response, so the loader spins until the user hits Escape. Pre-existing, but clicking a lens while an approval sits unattended in the sidebar makes it much easier to hit. One-liner in that guard: this.postMessage({type: 'acpUpdate', update: {sessionUpdate: 'prompt_response'}}).

  2. Tests. plugins/vscode/src/ already has AVA specs (acp-client.spec.ts, acp-process-manager.spec.ts), but NanocoderCodeLensProvider and sendCodeLensPrompt are unexported locals in extension.ts so they can't be reached from one. Moving them to src/code-lens-provider.ts matches the rest of src/ and makes three cheap tests possible: kind filtering plus the nested children walk, the codeLens: false short-circuit, and the prompt string shape.

  3. Unbounded inlined source. document.getText(range) has no cap, so Generate Tests on a large class inlines the whole thing. Worth a line/char cap with a truncation marker, or falling back to just the file:start-end locator past a threshold.

Smaller stuff: _pendingPromptTimer is never cleared on dispose, so the timeout warning can fire after the view is gone; and onDidDispose should guard with if (this._view === webviewView) before nulling. Constructor is also missing from LENS_SYMBOL_KINDS while every sibling method gets a lens.

Ends the turn when a prompt is rejected for a pending permission: the
webview had already drawn the user bubble and flipped to the loading
state, so with nothing posting prompt_response the composer spun until
the user hit Escape. Pre-existing, but a lens click while an approval
sat unattended made it easy to hit.

Moves NanocoderCodeLensProvider and sendCodeLensPrompt out of
extension.ts into code-lens-provider.ts so they can be tested. The
sibling specs cited as precedent never actually ran - the root AVA glob
only matched source/, and `vscode` is not resolvable outside the
extension host - so this also adds a runtime stub behind a test-only
tsconfig paths entry and widens the glob to plugins/*/src. That revives
acp-client.spec.ts and acp-process-manager.spec.ts as a side effect. The
stub is kept out of the packaged .vsix and the bundle still builds with
--external:vscode.

Caps the source inlined into a lens prompt at 200 lines / 8000 chars,
whichever binds first, with a truncation marker. Generate Tests on a
large class would otherwise paste the whole body into the conversation.
The file:start-end locator survives truncation, so the agent can still
read the rest.

ChatWebviewProvider now implements Disposable and is registered with the
extension's subscriptions, so the pending-prompt timer cannot outlive
it. The clear deliberately does not happen in onDidDispose: a view
disposal is usually a re-reveal in progress, and dropping the queued
prompt there would reintroduce the bug 9a9d5bf fixed. onDidDispose also
guards on view identity so a late teardown cannot null out a newer view.

Adds Constructor to LENS_SYMBOL_KINDS - every sibling method already got
a lens.
@addyCooks
addyCooks force-pushed the feat/vscode-code-lens-actions branch from c622ea5 to 95d7941 Compare August 16, 2026 20:40
@addyCooks

Copy link
Copy Markdown
Contributor Author

Thanks @will-lamerton!
All six addressed in 95d7941, plus tests for the three that didn't have any.

One thing worth flagging: the sibling specs cited as precedent were never actually running, the root AVA glob only matched source/, and vscode isn't resolvable outside the extension host. Getting any spec here to run meant widening the glob to plugins//src/**/.spec.ts and adding a vscode stub behind a test-only paths entry. That revives acp-client.spec.ts and acp-process-manager.spec.ts as a side effect. The stub is .vscodeignored and the bundle still builds with --external:vscode. Happy to split that into its own PR if you'd rather keep this one scoped to the lenses.

Unbounded source. Capped at 200 lines / 8000 chars, whichever binds first, with a truncation marker. The file:start-end locator is emitted before the fence and survives truncation.

Timer on dispose. ChatWebviewProvider implements Disposable and is registered with the extension's subscriptions. I deliberately did not clear it in onDidDispose — a disposal is usually a re-reveal in progress, and dropping the queued prompt there would reintroduce what 9a9d5bf fixed. onDidDispose guards on view identity as you suggested.

Constructor added to LENS_SYMBOL_KINDS.

@will-lamerton

Copy link
Copy Markdown
Member

Looking good to go @addyCooks! Can you fix the conflicts then I think we're good to merge :)

…ns-actions

Resolves three conflicts with main:

- package.json: main's ava globs (`plugins/**/*.spec.ts` + `.tsx`) subsume
  this branch's narrower `plugins/*/src/**/*.spec.ts`, and they pick up the
  new code lens specs. Took main's side.
- webview-protocol.ts: both sides appended to the
  `ExtensionToWebviewMessage` union. Kept both `ExtensionMessageRunPrompt`
  (this branch) and `ExtensionMessageMentionCompletions` (Nano-Collective#842).
- chat-panel.js: this branch moved the send out of `submitMessage()` into
  `dispatchPrompt()` so an editor-driven prompt can bypass the composer,
  while Nano-Collective#842 added a `closeMention()` before the input is cleared. Kept
  `closeMention()` and the `dispatchPrompt()` delegation, dropping the
  inline `postMessage` that `dispatchPrompt` now owns — keeping both would
  have sent every composer message twice.
chat-panel.html loads mention-utils.js ahead of chat-panel.js, and
chat-panel.js destructures globalThis.NanocoderMentionUtils at IIFE time.
The harness only ran chat-panel.js, so every spec that booted a panel died
on `Cannot destructure property 'findMentionQuery' of
'globalThis.NanocoderMentionUtils' as it is undefined` before reaching its
assertions — 28 failures across chat-panel-thoughts and
chat-panel-tool-cards.

The harness (Nano-Collective#847/Nano-Collective#867) and the mention-utils extraction (Nano-Collective#842) landed
independently, so neither PR saw the break; it only appears once both are
on main. Loading the two scripts into the VM in the same order the page
does fixes it.
@addyCooks

Copy link
Copy Markdown
Contributor Author

Thanks @will-lamerton! Conflicts resolved, all 11 checks green.

The only interesting one was chat-panel.js: this branch moved the send into
dispatchPrompt(), while #842 added a closeMention() before the input clears.
Kept both and dropped the now-duplicate inline postMessage otherwise every
composer message would send twice. package.json and webview-protocol.ts were
straightforward unions.

Heads up on one unrelated commit: fix(test): boot mention-utils.js in the chat panel harness. main is currently red on its own 28 chat-panel specs throw
Cannot destructure property 'findMentionQuery' of 'globalThis.NanocoderMentionUtils'.
The harness (#847) never loaded mention-utils.js (#842) into its VM sandbox, so
every spec that boots a panel dies before asserting. Neither PR's CI saw the
combination. 11-line fix.

Happy to split that out if you'd rather keep this PR to code lenses though it
stays red until that lands somewhere.

@will-lamerton
will-lamerton merged commit dfaf009 into Nano-Collective:main Aug 20, 2026
11 checks passed
will-lamerton added a commit to addyCooks/nanocoder that referenced this pull request Aug 20, 2026
Resolves the chat-panel.js conflict in favour of main: Nano-Collective#866's
resolveEditCardState()/isSettled() refactor already handles a cancelled
or denied edit arriving as 'failed', which is what this branch's
updateEditCard change was for. The ToolAggregator predicate keeps the
branch's 'denied' addition.
will-lamerton added a commit that referenced this pull request Aug 20, 2026
#866 landed the constant twice at module top level, which is a
redeclaration error. main has been failing test:types, test:lint,
test:format, the build and every spec that imports the harness since.

The surviving declaration is the mediaUrl() one-liner, matching
PANEL_SOURCE next to it; the deleted block's comment is already covered
by the file docblock.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Inline Editor Code Lenses for AI Actions in VS Code Extension

2 participants