diff --git a/CLAUDE.md b/CLAUDE.md
index 9c9a20a..2b8f1b6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -178,3 +178,5 @@ claude --version # record the known-good version
- `new_file_contents` in `openDiff` is in-memory -> write to a temp file to feed the comparison; write to `new_file_path` only on Accept.
- Debounce `selection_changed` (~100–200ms) or you'll flood the socket.
- **HTTP responses to the PowerShell shim/hooks MUST declare `charset=utf-8`** (and the shim additionally decodes raw response bytes as UTF-8 itself): PS 5.1's `Invoke-WebRequest` decodes charset-less responses as Latin-1, which mojibakes every non-ASCII character in every tool result (bit as `Microsoftâ„¢ Edge`, fixed 1.14.0).
+- **VSCT: a `
` can't be parented directly to another menu's ID** (e.g. `IDM_VS_CTXT_CODEWIN`) - it needs an intermediate `` parented to that ID, with the `` parented to the group (same pattern `ClaudeMenuGroup` uses for the Tools-menu button). Parenting the Menu straight to the menu ID compiles fine and the command still registers (`Commands.Item` finds it, `Commands.Raise` invokes it) but the submenu never renders in the actual context menu - silent, no error anywhere. Caught by literally screenshotting a right-click.
+- **The Exp hive caches the compiled command table.** Editing `.vsct` and redeploying just the DLL isn't enough - new command IDs resolve via `Commands.Item`/`Raise` (proving the package loaded) but won't render in menus until you close devenv and run `devenv /rootsuffix Exp /updateconfiguration` once. Pure C#/XAML logic changes (no `.vsct` edit) don't need this - just close devenv, overwrite the hive's `ClaudeCodeVS.dll`, relaunch.
diff --git a/src/ClaudeCodeVS/Attachments/AttachmentService.cs b/src/ClaudeCodeVS/Attachments/AttachmentService.cs
index d20886a..6f7b7ca 100644
--- a/src/ClaudeCodeVS/Attachments/AttachmentService.cs
+++ b/src/ClaudeCodeVS/Attachments/AttachmentService.cs
@@ -364,7 +364,7 @@ private static string TokSuffix(long? est)
=> est is long t ? $" (≈{(t >= 1000 ? (t / 1000.0).ToString("0.0") + "k" : t.ToString())} tok)" : "";
/// Workspace-relative forward-slash form, or null when the path is outside the workspace.
- private static string? ToWorkspaceRelative(string fullPath)
+ internal static string? ToWorkspaceRelative(string fullPath)
{
var ws = Ui.BridgeStatus.Workspace;
if (string.IsNullOrEmpty(ws)) return null;
diff --git a/src/ClaudeCodeVS/ClaudeCodeVsPackage.cs b/src/ClaudeCodeVS/ClaudeCodeVsPackage.cs
index fe307e5..e05f22e 100644
--- a/src/ClaudeCodeVS/ClaudeCodeVsPackage.cs
+++ b/src/ClaudeCodeVS/ClaudeCodeVsPackage.cs
@@ -3,6 +3,7 @@
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
+using ClaudeCodeVs.Protocol;
using ClaudeCodeVs.Ui;
using Microsoft.VisualStudio;
using Microsoft.VisualStudio.Shell;
@@ -54,6 +55,10 @@ protected override async Task InitializeAsync(CancellationToken cancellationToke
new CommandID(PackageGuids.CommandSet, PackageIds.LaunchClaude)));
mcs.AddCommand(new MenuCommand(OnShowPanel,
new CommandID(PackageGuids.CommandSet, PackageIds.ShowPanel)));
+ mcs.AddCommand(new MenuCommand(OnExplain,
+ new CommandID(PackageGuids.CommandSet, PackageIds.Explain)));
+ mcs.AddCommand(new MenuCommand(OnAddToChat,
+ new CommandID(PackageGuids.CommandSet, PackageIds.AddToChat)));
}
}
@@ -95,6 +100,33 @@ private void OnShowPanel(object sender, EventArgs e)
ErrorHandler.ThrowOnFailure(frame.Show());
}
+ // Editor right-click ("Claude Code" submenu): Explain stages the selection as a text attachment
+ // with an instruction header (same insert-not-submit staging as a pasted prompt); Add to Chat just
+ // @-mentions the file/line-range in place. Both read SelectionService.Current, kept live by the
+ // MEF TextViewListener.
+ private void OnExplain(object sender, EventArgs e)
+ {
+ ThreadHelper.ThrowIfNotOnUIThread();
+ var sel = Editor.SelectionService.Current;
+ if (sel.IsEmpty)
+ {
+ Log.Warn("Explain: select some code first.");
+ return;
+ }
+ var header = sel.FilePath is null
+ ? "Explain this code:"
+ : $"Explain this code from {System.IO.Path.GetFileName(sel.FilePath)} (lines {sel.StartLine + 1}-{sel.EndLineInclusive + 1}):";
+ JoinableTaskFactory.RunAsync(() => Attachments.AttachmentService.StageTextAsync(header + "\n\n" + sel.Text))
+ .FileAndForget("claudecodevs/explain");
+ }
+
+ private void OnAddToChat(object sender, EventArgs e)
+ {
+ ThreadHelper.ThrowIfNotOnUIThread();
+ JoinableTaskFactory.RunAsync(() => Editor.SelectionService.MentionCurrentAsync())
+ .FileAndForget("claudecodevs/addToChat");
+ }
+
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -115,4 +147,6 @@ internal static class PackageIds
// Must match the IDSymbol values in VSCommandTable.vsct.
public const int LaunchClaude = 0x0100;
public const int ShowPanel = 0x0101;
+ public const int Explain = 0x0102;
+ public const int AddToChat = 0x0103;
}
diff --git a/src/ClaudeCodeVS/Editor/SelectionService.cs b/src/ClaudeCodeVS/Editor/SelectionService.cs
index 343ce71..6ad6212 100644
--- a/src/ClaudeCodeVS/Editor/SelectionService.cs
+++ b/src/ClaudeCodeVS/Editor/SelectionService.cs
@@ -43,6 +43,53 @@ public static JToken LatestAsJson()
lock (Gate) return (_lastNonEmpty ?? _current).ToJson();
}
+ /// The live selection snapshot, for the editor context-menu commands (Explain / Add to Chat).
+ public static SelectionInfo Current
+ {
+ get { lock (Gate) return _current; }
+ }
+
+ ///
+ /// Push an at_mentioned for the current selection (file + line range), or the whole file if nothing
+ /// is selected - the "Add to Chat" context-menu command. Insert-not-submit, same as every other
+ /// at_mentioned in this codebase (CLAUDE.md).
+ ///
+ public static async Task MentionCurrentAsync()
+ {
+ var server = _server;
+ if (server is null || !server.HasConnections)
+ {
+ Log.Warn("Add to Chat: Claude isn't connected.");
+ return;
+ }
+
+ var info = Current;
+ if (info.FilePath is null)
+ {
+ Log.Warn("Add to Chat: no active file.");
+ return;
+ }
+
+ var mentionPath = Attachments.AttachmentService.ToWorkspaceRelative(info.FilePath) ?? info.FilePath;
+ var @params = new JObject { ["filePath"] = mentionPath };
+ if (!info.IsEmpty)
+ {
+ @params["lineStart"] = info.StartLine;
+ @params["lineEnd"] = info.EndLineInclusive;
+ }
+
+ try
+ {
+ await server.BroadcastNotificationAsync("at_mentioned", @params, CancellationToken.None);
+ var range = info.IsEmpty ? "" : $" (lines {info.StartLine + 1}-{info.EndLineInclusive + 1})";
+ Log.Info($"Add to Chat: mentioned '{System.IO.Path.GetFileName(info.FilePath)}'{range}.");
+ }
+ catch (Exception e)
+ {
+ Log.Warn($"Add to Chat failed: {e.Message}");
+ }
+ }
+
/// Record a fresh selection from a focused view and (debounced) push selection_changed.
public static void Update(IWpfTextView view)
{
@@ -98,6 +145,14 @@ internal sealed class SelectionInfo
public bool IsEmpty => Text.Length == 0;
+ ///
+ /// The last line the selection actually covers. is LSP-shaped (exclusive), so
+ /// selecting whole lines parks the end at column 0 of the NEXT line - reporting that one verbatim
+ /// would claim one line too many. Only for human-facing ranges and at_mentioned; the
+ /// selection_changed / getCurrentSelection JSON keeps the exclusive LSP coordinates.
+ ///
+ public int EndLineInclusive => EndChar == 0 && EndLine > StartLine ? EndLine - 1 : EndLine;
+
public SelectionInfo(string text, string? filePath, int startLine, int startChar, int endLine, int endChar)
{
Text = text;
diff --git a/src/ClaudeCodeVS/VSCommandTable.vsct b/src/ClaudeCodeVS/VSCommandTable.vsct
index ebb767c..a5a712e 100644
--- a/src/ClaudeCodeVS/VSCommandTable.vsct
+++ b/src/ClaudeCodeVS/VSCommandTable.vsct
@@ -13,8 +13,31 @@
+
+
+
+
+
+
+
+
+
+
@@ -36,6 +59,22 @@
Claude Code
+
+
+
+ IconIsMoniker
+
+ Explain
+
+
+
+
+
+ IconIsMoniker
+
+ Add to Chat
+
+
@@ -43,8 +82,13 @@
+
+
+
+
+