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 @@ + + + + + + + + + + + + + Claude Code + Claude Code + + + + + + @@ -43,8 +82,13 @@ + + + + +