From 525a46031fdb856634c642c46324a0cc97701119 Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Thu, 1 Oct 2026 11:51:04 -0400 Subject: [PATCH] feat(tools): add dialog tools to answer modal dialogs blocking Visual Studio Adds dialog_list and dialog_respond. A modal dialog such as "The source code is different from the original version" or "Hot Reload changes not supported" holds the Visual Studio UI thread, so the tool call that raised it never returns and every other tool that needs that thread waits behind it. An agent had no way to see the dialog or answer it. Neither tool touches the UI thread. Discovery is Win32 window enumeration, which reads window state without sending messages and so cannot hang. A window is a blocking dialog when it is visible and enabled and its owner has been disabled, which is how Win32 and WPF both implement modality, or when it is an unowned native dialog. Out-of-process windows owned by a VS window are included. Reading a dialog and clicking a button use UI Automation, which covers message boxes, task dialogs and WPF dialogs alike. Those calls run on a dedicated MTA thread under a timeout, so a dialog that stops responding is still listed by title rather than hanging the tool. Buttons are matched by name, ignoring case, access key markers and trailing ellipses; ambiguous, missing and disabled buttons are refused with the list of real buttons. StreamJsonRpc dispatches requests concurrently, so these calls are served while the call that raised the dialog is still waiting on it. UI Automation lives in the extension rather than the server because the server is a self-contained single-file publish and would otherwise have to carry the whole Windows Desktop runtime. --- CLAUDE.md | 7 + README.md | 12 + .../Program.cs | 3 +- .../RpcClient.cs | 6 +- .../Tools/DialogTools.cs | 48 ++ .../Models/DialogModels.cs | 76 ++ .../RpcContracts.cs | 4 + .../CodingWithCalvin.MCPServer.Tests.csproj | 10 + .../DialogAutomationTests.cs | 576 ++++++++++++++ .../CodingWithCalvin.MCPServer.csproj | 6 + .../Services/DialogAutomation.cs | 700 ++++++++++++++++++ .../Services/IVisualStudioService.cs | 4 + .../Services/RpcServer.cs | 4 + .../Services/VisualStudioService.cs | 43 ++ 14 files changed, 1497 insertions(+), 2 deletions(-) create mode 100644 src/CodingWithCalvin.MCPServer.Server/Tools/DialogTools.cs create mode 100644 src/CodingWithCalvin.MCPServer.Shared/Models/DialogModels.cs create mode 100644 src/CodingWithCalvin.MCPServer.Tests/DialogAutomationTests.cs create mode 100644 src/CodingWithCalvin.MCPServer/Services/DialogAutomation.cs diff --git a/CLAUDE.md b/CLAUDE.md index 4a12b2a..852f804 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,6 +127,7 @@ dotnet build src/CodingWithCalvin.VSMCP/CodingWithCalvin.VSMCP.csproj - `Services/TestExplorerInterop.cs` - Reflection bridge to Test Explorer (see the file's remarks for why it cannot be a compile-time reference) - `Services/TerminalInterop.cs` - Reflection bridge to the integrated terminal, over the brokered service container - `Services/CoverageInterop.cs` - Reflection bridge to the code coverage file reader +- `Services/DialogAutomation.cs` - Finds and answers modal dialogs via Win32 and UI Automation, without ever touching the UI thread the dialog is blocking - `Server/Tools/*.cs` - MCP tool definitions ## MCP Tools Available @@ -234,6 +235,12 @@ Terminal output is not captured. The Visual Studio terminal is a raw PTY with no - `window_activate` - Activate (focus) a window by caption - `window_list` - List all open windows with caption, kind, visibility, and GUID +### Dialog Tools +- `dialog_list` - List the modal dialogs Visual Studio is waiting on, with their message and buttons +- `dialog_respond` - Answer a dialog by clicking one of its buttons + +A modal dialog holds the VS UI thread, so every other tool call that needs it waits until the dialog is answered. The dialog tools must never switch to the UI thread: they run on the thread pool and stay responsive while the blocked call is still waiting. + ## Technology Stack - .NET Framework 4.8 (VSIX) diff --git a/README.md b/README.md index 680d223..34aa36a 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,18 @@ | `window_activate` | Activate (focus) a window by caption | | `window_list` | List all open windows with caption, kind, visibility, and GUID | +### 💬 Dialog Tools + +| Tool | Description | +|------|-------------| +| `dialog_list` | List the modal dialogs Visual Studio is waiting on, with their message and buttons | +| `dialog_respond` | Answer a dialog by clicking one of its buttons | + +> 💡 **Stuck on a dialog?** A modal dialog, like "The source code is different from the original +> version" or "Hot Reload changes not supported", blocks Visual Studio until someone answers it. +> If a tool call hangs or times out, `dialog_list` shows what Visual Studio is waiting on and +> `dialog_respond` answers it. Both work while the blocked call is still waiting. + ## 🛠️ Installation ### Visual Studio Marketplace diff --git a/src/CodingWithCalvin.MCPServer.Server/Program.cs b/src/CodingWithCalvin.MCPServer.Server/Program.cs index 34c7840..2db7f75 100644 --- a/src/CodingWithCalvin.MCPServer.Server/Program.cs +++ b/src/CodingWithCalvin.MCPServer.Server/Program.cs @@ -104,7 +104,8 @@ static async Task RunServerAsync(string pipeName, string host, int port, string .WithTools() .WithTools() .WithTools() - .WithTools(); + .WithTools() + .WithTools(); var app = builder.Build(); diff --git a/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs b/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs index 794ab37..7bc48a1 100644 --- a/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs +++ b/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs @@ -65,7 +65,7 @@ public Task> GetAvailableToolsAsync() } var tools = new List(); - var toolTypes = new[] { typeof(Tools.SolutionTools), typeof(Tools.DocumentTools), typeof(Tools.BuildTools), typeof(Tools.NavigationTools), typeof(Tools.DebuggerTools), typeof(Tools.DiagnosticsTools), typeof(Tools.WindowTools), typeof(Tools.TestTools), typeof(Tools.TerminalTools), typeof(Tools.CoverageTools) }; + var toolTypes = new[] { typeof(Tools.SolutionTools), typeof(Tools.DocumentTools), typeof(Tools.BuildTools), typeof(Tools.NavigationTools), typeof(Tools.DebuggerTools), typeof(Tools.DiagnosticsTools), typeof(Tools.WindowTools), typeof(Tools.TestTools), typeof(Tools.TerminalTools), typeof(Tools.CoverageTools), typeof(Tools.DialogTools) }; foreach (var toolType in toolTypes) { @@ -202,4 +202,8 @@ public Task CreateTerminalAsync(string? name, string? workingDir public Task ActivateWindowAsync(string caption) => Proxy.ActivateWindowAsync(caption); public Task ShowToolWindowAsync(string name) => Proxy.ShowToolWindowAsync(name); public Task HideToolWindowAsync(string caption) => Proxy.HideToolWindowAsync(caption); + + public Task GetDialogsAsync() => Proxy.GetDialogsAsync(); + public Task RespondToDialogAsync(string button, string? dialogId) + => Proxy.RespondToDialogAsync(button, dialogId); } diff --git a/src/CodingWithCalvin.MCPServer.Server/Tools/DialogTools.cs b/src/CodingWithCalvin.MCPServer.Server/Tools/DialogTools.cs new file mode 100644 index 0000000..79e2f22 --- /dev/null +++ b/src/CodingWithCalvin.MCPServer.Server/Tools/DialogTools.cs @@ -0,0 +1,48 @@ +using System.ComponentModel; +using System.Text.Json; +using System.Threading.Tasks; +using ModelContextProtocol.Server; + +namespace CodingWithCalvin.MCPServer.Server.Tools; + +[McpServerToolType] +public class DialogTools +{ + private readonly RpcClient _rpcClient; + private readonly JsonSerializerOptions _jsonOptions; + + public DialogTools(RpcClient rpcClient) + { + _rpcClient = rpcClient; + _jsonOptions = new JsonSerializerOptions { WriteIndented = true }; + } + + [McpServerTool(Name = "dialog_list", ReadOnly = true)] + [Description("List the modal dialogs Visual Studio is waiting on, such as message boxes and prompts raised by the debugger, Hot Reload or a build. A modal dialog blocks Visual Studio until someone answers it, so if another tool call hangs or times out, call this to check for one. Works while another tool call is still blocked. Returns each dialog's id, title, message and buttons; answer it with dialog_respond.")] + public async Task ListDialogsAsync() + { + var result = await _rpcClient.GetDialogsAsync(); + + if (result.Dialogs.Count == 0) + { + return result.Message ?? "No dialogs are open. Visual Studio is not waiting on a dialog."; + } + + return JsonSerializer.Serialize(result.Dialogs, _jsonOptions); + } + + [McpServerTool(Name = "dialog_respond", Destructive = true)] + [Description("Answer a modal dialog by clicking one of its buttons, exactly as a user would. Read the dialog with dialog_list first: the answer takes effect immediately and can discard changes, stop debugging or start a rebuild. Returns whether the dialog closed and the dialogs still open, because an answer can raise a follow-up prompt.")] + public async Task RespondToDialogAsync( + [Description("Name of the button to click, as listed by dialog_list, for example \"Yes\", \"No\" or \"Cancel\". Case-insensitive.")] string button, + [Description("Id of the dialog to answer, from dialog_list. Only needed when more than one dialog is open.")] string? dialogId = null) + { + if (string.IsNullOrWhiteSpace(button)) + { + return "A button name is required. Call dialog_list to see the dialog's buttons."; + } + + var result = await _rpcClient.RespondToDialogAsync(button, dialogId); + return JsonSerializer.Serialize(result, _jsonOptions); + } +} diff --git a/src/CodingWithCalvin.MCPServer.Shared/Models/DialogModels.cs b/src/CodingWithCalvin.MCPServer.Shared/Models/DialogModels.cs new file mode 100644 index 0000000..6f1c7d2 --- /dev/null +++ b/src/CodingWithCalvin.MCPServer.Shared/Models/DialogModels.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; + +namespace CodingWithCalvin.MCPServer.Shared.Models; + +/// +/// A modal dialog that Visual Studio is waiting on. +/// +public class DialogInfo +{ + /// + /// Window handle of the dialog in hexadecimal, used to pick a dialog when more than one is open. + /// + public string Id { get; set; } = string.Empty; + + public string Title { get; set; } = string.Empty; + + /// + /// The dialog's text, usually the question being asked or the error being reported. + /// + public string Message { get; set; } = string.Empty; + + public List Buttons { get; set; } = new(); + + /// + /// Populated when the dialog's contents could not be read, for example because it stopped + /// responding. The dialog is still listed so the caller knows something is blocking. + /// + public string? Note { get; set; } +} + +public class DialogButtonInfo +{ + public string Name { get; set; } = string.Empty; + public bool IsEnabled { get; set; } +} + +/// +/// The modal dialogs currently open in Visual Studio. +/// +public class DialogListResult +{ + public List Dialogs { get; set; } = new(); + + /// + /// Populated when the dialogs could not be enumerated at all, which is distinct from there + /// being none open. + /// + public string? Message { get; set; } +} + +/// +/// Outcome of answering a modal dialog by clicking one of its buttons. +/// +public class DialogResponseResult +{ + public bool Clicked { get; set; } + + public string? DialogId { get; set; } + + /// + /// Name of the button that was clicked, as the dialog displays it. + /// + public string? Button { get; set; } + + /// + /// True when the dialog had closed by the time this result was produced. + /// + public bool DialogClosed { get; set; } + + public string? Message { get; set; } + + /// + /// Dialogs still open afterwards. Answering one dialog can raise a follow-up prompt. + /// + public List OpenDialogs { get; set; } = new(); +} diff --git a/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs b/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs index 99ff6a6..6849140 100644 --- a/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs +++ b/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs @@ -105,6 +105,10 @@ Task DebugAddBreakpointAsync( Task ActivateWindowAsync(string caption); Task ShowToolWindowAsync(string name); Task HideToolWindowAsync(string caption); + + // Dialog tools + Task GetDialogsAsync(); + Task RespondToDialogAsync(string button, string? dialogId); } /// diff --git a/src/CodingWithCalvin.MCPServer.Tests/CodingWithCalvin.MCPServer.Tests.csproj b/src/CodingWithCalvin.MCPServer.Tests/CodingWithCalvin.MCPServer.Tests.csproj index f6576a9..5dc6098 100644 --- a/src/CodingWithCalvin.MCPServer.Tests/CodingWithCalvin.MCPServer.Tests.csproj +++ b/src/CodingWithCalvin.MCPServer.Tests/CodingWithCalvin.MCPServer.Tests.csproj @@ -26,4 +26,14 @@ + + + + + + + + + + diff --git a/src/CodingWithCalvin.MCPServer.Tests/DialogAutomationTests.cs b/src/CodingWithCalvin.MCPServer.Tests/DialogAutomationTests.cs new file mode 100644 index 0000000..2a884d7 --- /dev/null +++ b/src/CodingWithCalvin.MCPServer.Tests/DialogAutomationTests.cs @@ -0,0 +1,576 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using CodingWithCalvin.MCPServer.Services; +using CodingWithCalvin.MCPServer.Shared.Models; +using Xunit; +using Forms = System.Windows.Forms; +using Wpf = System.Windows; +using WpfControls = System.Windows.Controls; + +namespace CodingWithCalvin.MCPServer.Tests; + +/// +/// Covers finding, reading and answering the modal dialogs that block Visual Studio (issue #112). +/// +/// +/// The rule and matching tests run against captured window facts. The rest raise genuine Win32 +/// and WPF modal dialogs on a dedicated UI thread in the test process and drive them through UI +/// Automation from another thread, which is how the tools reach a dialog that is holding the +/// Visual Studio UI thread. +/// +public class DialogAutomationTests +{ + private const int VisualStudioProcessId = 100; + private const int OtherProcessId = 200; + + /// + /// Generous because the first WPF window in a fresh process takes several seconds to start + /// up, and longer on a cold build agent. Only a failing test ever waits this long. + /// + private static readonly TimeSpan AppearTimeout = TimeSpan.FromSeconds(60); + private static readonly TimeSpan ResultTimeout = TimeSpan.FromSeconds(10); + + [Fact] + public void IsBlockingDialog_True_WhenOwnerIsDisabled() + { + Assert.True(DialogAutomation.IsBlockingDialog(OwnedWindow(), VisualStudioProcessId)); + } + + [Fact] + public void IsBlockingDialog_False_ForFloatingToolWindow() + { + // Owned just like a dialog, but nothing has disabled its owner. + var window = OwnedWindow(); + window.IsOwnerEnabled = true; + + Assert.False(DialogAutomation.IsBlockingDialog(window, VisualStudioProcessId)); + } + + [Fact] + public void IsBlockingDialog_False_WhenANestedDialogHasDisabledIt() + { + // Only the innermost dialog of a stack can be answered. + var window = OwnedWindow(); + window.IsEnabled = false; + + Assert.False(DialogAutomation.IsBlockingDialog(window, VisualStudioProcessId)); + } + + [Fact] + public void IsBlockingDialog_False_WhenHidden() + { + var window = OwnedWindow(); + window.IsVisible = false; + + Assert.False(DialogAutomation.IsBlockingDialog(window, VisualStudioProcessId)); + } + + [Fact] + public void IsBlockingDialog_False_ForWindowThatCannotActivate() + { + // A tooltip or popup left over from before the dialog opened. + var window = OwnedWindow(); + window.CanActivate = false; + + Assert.False(DialogAutomation.IsBlockingDialog(window, VisualStudioProcessId)); + } + + [Fact] + public void IsBlockingDialog_True_ForUnownedNativeDialog() + { + var window = OwnedWindow(); + window.ClassName = "#32770"; + window.Owner = IntPtr.Zero; + + Assert.True(DialogAutomation.IsBlockingDialog(window, VisualStudioProcessId)); + } + + [Fact] + public void IsBlockingDialog_False_ForUnownedWindow() + { + // The Visual Studio main window. + var window = OwnedWindow(); + window.Owner = IntPtr.Zero; + + Assert.False(DialogAutomation.IsBlockingDialog(window, VisualStudioProcessId)); + } + + [Fact] + public void IsBlockingDialog_False_ForUnownedNativeDialogInAnotherProcess() + { + var window = OwnedWindow(); + window.ClassName = "#32770"; + window.Owner = IntPtr.Zero; + window.ProcessId = OtherProcessId; + + Assert.False(DialogAutomation.IsBlockingDialog(window, VisualStudioProcessId)); + } + + [Fact] + public void IsBlockingDialog_True_ForOutOfProcessDialogOwnedByVisualStudio() + { + var window = OwnedWindow(); + window.ProcessId = OtherProcessId; + + Assert.True(DialogAutomation.IsBlockingDialog(window, VisualStudioProcessId)); + } + + [Fact] + public void IsBlockingDialog_False_ForDialogBelongingToAnotherApplication() + { + var window = OwnedWindow(); + window.ProcessId = OtherProcessId; + window.OwnerProcessId = OtherProcessId; + + Assert.False(DialogAutomation.IsBlockingDialog(window, VisualStudioProcessId)); + } + + [Fact] + public void TrySelectDialog_Fails_WhenNoDialogIsOpen() + { + var selected = DialogAutomation.TrySelectDialog( + new List(), + dialogId: null, + out _, + out var error); + + Assert.False(selected); + Assert.Equal(DialogAutomation.NoDialogMessage, error); + } + + [Fact] + public void TrySelectDialog_PicksTheOnlyDialog_WhenNoIdIsGiven() + { + var dialogs = new[] { Dialog(0x1A2B) }; + + Assert.True(DialogAutomation.TrySelectDialog(dialogs, dialogId: null, out var selected, out _)); + Assert.Same(dialogs[0], selected); + } + + [Fact] + public void TrySelectDialog_RequiresAnId_WhenSeveralDialogsAreOpen() + { + var dialogs = new[] { Dialog(0x1A2B), Dialog(0x3C4D) }; + + Assert.False(DialogAutomation.TrySelectDialog(dialogs, dialogId: " ", out _, out var error)); + Assert.Contains("2 dialogs are open", error); + } + + [Theory] + [InlineData("0x00003C4D")] + [InlineData("0x3c4d")] + [InlineData("3C4D")] + public void TrySelectDialog_FindsDialogById(string dialogId) + { + var dialogs = new[] { Dialog(0x1A2B), Dialog(0x3C4D) }; + + Assert.True(DialogAutomation.TrySelectDialog(dialogs, dialogId, out var selected, out _)); + Assert.Equal(new IntPtr(0x3C4D), selected!.Handle); + } + + [Theory] + [InlineData("0x9999")] + [InlineData("not-an-id")] + [InlineData("0x0")] + public void TrySelectDialog_Fails_ForIdThatMatchesNoOpenDialog(string dialogId) + { + var dialogs = new[] { Dialog(0x1A2B) }; + + Assert.False(DialogAutomation.TrySelectDialog(dialogs, dialogId, out _, out var error)); + Assert.Contains(dialogId, error); + } + + [Fact] + public void FormatId_RoundTripsThroughTryParseId() + { + var handle = new IntPtr(0x3C4D); + var id = DialogAutomation.FormatId(handle); + + Assert.Equal("0x00003C4D", id); + Assert.True(DialogAutomation.TryParseId(id, out var parsed)); + Assert.Equal(handle, parsed); + } + + [Theory] + [InlineData("yes", 0)] + [InlineData("NO", 1)] + [InlineData(" Cancel ", 2)] + [InlineData("&Cancel", 2)] + public void FindButton_MatchesIgnoringCaseWhitespaceAndAccessKey(string requested, int expected) + { + var buttons = Buttons("Yes", "No", "Cancel"); + + Assert.Equal(expected, DialogAutomation.FindButton(buttons, requested, out _)); + } + + [Theory] + [InlineData("Browse...")] + [InlineData("Browse…")] + [InlineData("&Browse...")] + public void FindButton_IgnoresTrailingEllipsis(string caption) + { + Assert.Equal(0, DialogAutomation.FindButton(Buttons(caption), "browse", out _)); + } + + [Fact] + public void FindButton_ListsTheButtons_WhenNoneMatches() + { + var index = DialogAutomation.FindButton(Buttons("Yes", "No"), "Maybe", out var error); + + Assert.Equal(-1, index); + Assert.Contains("Yes, No", error); + } + + [Fact] + public void FindButton_RefusesDisabledButton() + { + var buttons = Buttons("Apply"); + buttons[0].IsEnabled = false; + + Assert.Equal(-1, DialogAutomation.FindButton(buttons, "Apply", out var error)); + Assert.Contains("disabled", error); + } + + [Fact] + public void FindButton_RefusesToGuess_WhenTwoButtonsShareAName() + { + Assert.Equal(-1, DialogAutomation.FindButton(Buttons("OK", "&OK"), "OK", out var error)); + Assert.Contains("ambiguous", error); + } + + [Fact] + public void FindButton_ExplainsADialogWithNoButtons() + { + Assert.Equal(-1, DialogAutomation.FindButton(Buttons(), "OK", out var error)); + Assert.Contains("no buttons", error); + } + + [Fact] + public void ToMatchKey_KeepsLiteralAmpersand() + { + Assert.Equal("Save & Close", DialogAutomation.ToMatchKey("&Save && Close")); + } + + [Fact] + public async Task NoDialogOpen_ListsNothingAndRespondExplainsWhy() + { + var automation = new DialogAutomation(); + + Assert.Empty((await automation.GetDialogsAsync()).Dialogs); + + var response = await automation.RespondAsync("OK", dialogId: null); + + Assert.False(response.Clicked); + Assert.Equal(DialogAutomation.NoDialogMessage, response.Message); + } + + [Fact] + public async Task MessageBox_IsListedAndAnsweredByButtonName() + { + const string Title = "Breakpoint dialog test"; + var automation = new DialogAutomation(); + + using var host = new DialogThread(() => + { + using var owner = new Forms.Form(); + _ = owner.Handle; + + return Forms.MessageBox.Show( + owner, + "The source code is different from the original version.", + Title, + Forms.MessageBoxButtons.YesNoCancel, + Forms.MessageBoxIcon.Warning); + }); + + var dialog = await WaitForDialogAsync(automation, host, Title); + + Assert.Null(dialog.Note); + Assert.Equal("The source code is different from the original version.", dialog.Message); + Assert.Equal(new[] { "Yes", "No", "Cancel" }, dialog.Buttons.Select(b => b.Name).ToArray()); + Assert.All(dialog.Buttons, button => Assert.True(button.IsEnabled)); + + var unknown = await automation.RespondAsync("Maybe", dialog.Id); + + Assert.False(unknown.Clicked); + Assert.Contains("Yes, No, Cancel", unknown.Message); + Assert.Contains(unknown.OpenDialogs, open => open.Id == dialog.Id); + + var response = await automation.RespondAsync("no", dialog.Id); + + Assert.True(response.Clicked, response.Message); + Assert.Equal("No", response.Button); + Assert.True(response.DialogClosed, response.Message); + Assert.DoesNotContain(response.OpenDialogs, open => open.Id == dialog.Id); + Assert.Equal(Forms.DialogResult.No, host.WaitForResult()); + } + + [Fact] + public async Task WpfDialog_IsListedAndAnsweredByButtonName() + { + const string Title = "Hot Reload dialog test"; + var automation = new DialogAutomation(); + + using var host = new DialogThread(() => + { + var owner = new Wpf.Window + { + Left = -10000, + Top = -10000, + Width = 100, + Height = 100, + ShowInTaskbar = false, + ShowActivated = false, + }; + owner.Show(); + + var dialog = new Wpf.Window + { + Owner = owner, + Title = Title, + SizeToContent = Wpf.SizeToContent.WidthAndHeight, + ShowInTaskbar = false, + WindowStartupLocation = Wpf.WindowStartupLocation.CenterOwner, + }; + + var edit = new WpfControls.Button { Content = "_Edit" }; + edit.Click += (_, _) => dialog.DialogResult = false; + + var rebuild = new WpfControls.Button { Content = "_Rebuild and Apply Changes" }; + rebuild.Click += (_, _) => dialog.DialogResult = true; + + var panel = new WpfControls.StackPanel(); + panel.Children.Add(new WpfControls.TextBlock { Text = "Hot Reload changes not supported." }); + panel.Children.Add(edit); + panel.Children.Add(rebuild); + dialog.Content = panel; + + try + { + return dialog.ShowDialog(); + } + finally + { + owner.Close(); + } + }); + + var listed = await WaitForDialogAsync(automation, host, Title); + + // A WPF button also exposes its caption as a Text child; it must not leak into the message. + Assert.Equal("Hot Reload changes not supported.", listed.Message); + Assert.Equal( + new[] { "Edit", "Rebuild and Apply Changes" }, + listed.Buttons.Select(b => b.Name).ToArray()); + + var response = await automation.RespondAsync("rebuild and apply changes", dialogId: null); + + Assert.True(response.Clicked, response.Message); + Assert.True(response.DialogClosed, response.Message); + Assert.Equal(true, host.WaitForResult()); + } + + /// + /// A dialog whose thread is blocked without pumping messages answers nothing sent to it. It + /// must still be listed, by title, and listing must not hang along with it. + /// + [Fact] + public async Task UnresponsiveDialog_IsStillListed_WithoutHanging() + { + const string Title = "Unresponsive dialog test"; + var automation = new DialogAutomation(); + using var release = new ManualResetEvent(false); + + using var host = new DialogThread(() => + { + using var owner = new Forms.Form(); + using var dialog = new Forms.Form + { + Text = Title, + ShowInTaskbar = false, + StartPosition = Forms.FormStartPosition.Manual, + Location = new System.Drawing.Point(-10000, -10000), + }; + + _ = owner.Handle; + dialog.Show(owner); + owner.Enabled = false; + + NativeMethods.WaitForSingleObject(release.SafeWaitHandle.DangerousGetHandle(), NativeMethods.Infinite); + return null; + }); + + try + { + var stopwatch = Stopwatch.StartNew(); + + var listed = await WaitForDialogAsync(automation, host, Title); + + Assert.Equal(Title, listed.Title); + Assert.True( + stopwatch.Elapsed < AppearTimeout + DialogAutomation.ReadTimeout, + $"Listing an unresponsive dialog took {stopwatch.Elapsed}."); + } + finally + { + release.Set(); + } + } + + private static async Task WaitForDialogAsync(DialogAutomation automation, DialogThread host, string title) + { + var deadline = DateTime.UtcNow + AppearTimeout; + + while (true) + { + host.ThrowIfEnded(); + + var dialogs = (await automation.GetDialogsAsync()).Dialogs; + var dialog = dialogs.FirstOrDefault(d => d.Title == title); + if (dialog != null) + { + return dialog; + } + + Assert.True( + DateTime.UtcNow < deadline, + $"No dialog titled \"{title}\" appeared. Listed: [{string.Join(", ", dialogs.Select(d => $"\"{d.Title}\" ({d.Note})"))}]"); + await Task.Delay(100); + } + } + + private static DialogAutomation.TopLevelWindow OwnedWindow() => new() + { + Handle = new IntPtr(0x1A2B), + ProcessId = VisualStudioProcessId, + ClassName = "HwndWrapper[DefaultDomain;;]", + IsVisible = true, + IsEnabled = true, + Owner = new IntPtr(0x5E6F), + OwnerProcessId = VisualStudioProcessId, + IsOwnerEnabled = false, + }; + + private static DialogAutomation.TopLevelWindow Dialog(int handle) + { + var window = OwnedWindow(); + window.Handle = new IntPtr(handle); + return window; + } + + private static List Buttons(params string[] names) => + names.Select(name => new DialogButtonInfo { Name = name, IsEnabled = true }).ToList(); + + /// + /// Runs a dialog on its own STA thread, standing in for the Visual Studio UI thread, and + /// makes sure it is gone afterwards even when an assertion fails before it is answered. + /// + private sealed class DialogThread : IDisposable + { + private readonly Thread _thread; + private readonly TaskCompletionSource _result = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private uint _nativeThreadId; + + public DialogThread(Func show) + { + _thread = new Thread(() => + { + _nativeThreadId = NativeMethods.GetCurrentThreadId(); + + try + { + _result.SetResult(show()); + } + catch (Exception ex) + { + _result.SetException(ex); + } + }) + { + IsBackground = true, + }; + + _thread.SetApartmentState(ApartmentState.STA); + _thread.Start(); + } + + /// + /// Fails the test straight away when the dialog thread finished without ever showing a + /// dialog, rather than waiting out the timeout with no explanation. + /// + public void ThrowIfEnded() + { + if (_result.Task.IsCompleted) + { + throw new InvalidOperationException( + "The dialog thread ended before its dialog was answered.", + _result.Task.Exception?.GetBaseException()); + } + } + + /// + /// Blocks for the dialog's result. Safe because the dialog runs on its own thread and + /// never needs the caller's. + /// + public object? WaitForResult() + { + Assert.True(_result.Task.Wait(ResultTimeout), "The dialog never returned a result."); + return _result.Task.Result; + } + + public void Dispose() + { + if (!_result.Task.IsCompleted && _nativeThreadId != 0) + { + // WM_CLOSE answers a message box with Cancel and closes a WPF window. Disabled + // windows are owners waiting on a dialog and close with it. + NativeMethods.EnumThreadWindows( + _nativeThreadId, + (handle, _) => + { + if (NativeMethods.IsWindowEnabled(handle)) + { + NativeMethods.PostMessage(handle, NativeMethods.WM_CLOSE, IntPtr.Zero, IntPtr.Zero); + } + + return true; + }, + IntPtr.Zero); + } + + _thread.Join(ResultTimeout); + } + } + + private static class NativeMethods + { + public const uint WM_CLOSE = 0x0010; + public const uint Infinite = 0xFFFFFFFF; + + public delegate bool EnumThreadWindowsProc(IntPtr hWnd, IntPtr lParam); + + [DllImport("kernel32.dll")] + public static extern uint GetCurrentThreadId(); + + [DllImport("kernel32.dll")] + public static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool EnumThreadWindows(uint dwThreadId, EnumThreadWindowsProc lpfn, IntPtr lParam); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool IsWindowEnabled(IntPtr hWnd); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + } +} diff --git a/src/CodingWithCalvin.MCPServer/CodingWithCalvin.MCPServer.csproj b/src/CodingWithCalvin.MCPServer/CodingWithCalvin.MCPServer.csproj index 8516506..59e2eac 100644 --- a/src/CodingWithCalvin.MCPServer/CodingWithCalvin.MCPServer.csproj +++ b/src/CodingWithCalvin.MCPServer/CodingWithCalvin.MCPServer.csproj @@ -25,6 +25,12 @@ + + + + + + diff --git a/src/CodingWithCalvin.MCPServer/Services/DialogAutomation.cs b/src/CodingWithCalvin.MCPServer/Services/DialogAutomation.cs new file mode 100644 index 0000000..6ab9d15 --- /dev/null +++ b/src/CodingWithCalvin.MCPServer/Services/DialogAutomation.cs @@ -0,0 +1,700 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Automation; +using CodingWithCalvin.MCPServer.Shared.Models; + +namespace CodingWithCalvin.MCPServer.Services; + +/// +/// Finds the modal dialogs Visual Studio is waiting on, reads them, and clicks their buttons. +/// +/// +/// +/// A modal dialog holds the Visual Studio UI thread inside its own message loop, and every other +/// tool reaches Visual Studio through that thread. Nothing here may switch to it: these calls +/// arrive on the thread pool while the call that raised the dialog is still waiting, and have to +/// complete on their own. Every await therefore uses ConfigureAwait(false). +/// +/// +/// Discovery uses Win32 window enumeration, which reads window state without sending messages +/// and so cannot hang on a busy UI thread. Reading a dialog and clicking its buttons use UI +/// Automation, which handles native message boxes, task dialogs and WPF dialogs alike. That does +/// send messages to the dialog, so it runs on a dedicated thread under a timeout; a dialog that +/// stops responding is still listed, just without its contents. +/// +/// +/// A window counts as a blocking dialog when it is visible and enabled and its owner has been +/// disabled, which is how both Win32 and WPF implement modality, or when it is a native dialog +/// with no owner. Windows belonging to other processes are included when a Visual Studio window +/// owns them, because some prompts are raised out of process. +/// +/// +internal sealed class DialogAutomation +{ + internal static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(5); + internal static readonly TimeSpan ClickTimeout = TimeSpan.FromSeconds(5); + internal static readonly TimeSpan CloseTimeout = TimeSpan.FromSeconds(3); + + private static readonly TimeSpan ClosePollInterval = TimeSpan.FromMilliseconds(100); + + private const string NativeDialogClassName = "#32770"; + private const int MaxMessageLength = 4000; + private const int MaxElementsVisited = 500; + private const int MaxTreeDepth = 12; + + internal const string NoDialogMessage = "No dialog is open in Visual Studio."; + + /// + /// Control types whose descendants are never part of the dialog's message. Their own labels + /// would otherwise be read as text: a WPF button exposes its caption as a Text child. + /// + private static readonly HashSet SkippedSubtrees = new() + { + ControlType.TitleBar, + ControlType.MenuBar, + ControlType.ScrollBar, + ControlType.CheckBox, + ControlType.RadioButton, + ControlType.Hyperlink, + ControlType.ComboBox, + }; + + private readonly int _processId; + + internal DialogAutomation() + : this((int)NativeMethods.GetCurrentProcessId()) + { + } + + internal DialogAutomation(int processId) + { + _processId = processId; + } + + internal async Task GetDialogsAsync() + { + var result = new DialogListResult(); + + foreach (var window in FindBlockingDialogs()) + { + var dialog = await DescribeAsync(window).ConfigureAwait(false); + if (dialog != null) + { + result.Dialogs.Add(dialog); + } + } + + return result; + } + + internal async Task RespondAsync(string button, string? dialogId) + { + var result = new DialogResponseResult { Button = button }; + + if (!TrySelectDialog(FindBlockingDialogs(), dialogId, out var window, out var error)) + { + result.Message = error; + result.OpenDialogs = (await GetDialogsAsync().ConfigureAwait(false)).Dialogs; + return result; + } + + result.DialogId = FormatId(window!.Handle); + + DialogContent? content; + + try + { + var title = ReadTitle(window.Handle); + var read = await RunWithTimeoutAsync(() => ReadContent(window.Handle, title), ReadTimeout) + .ConfigureAwait(false); + + if (!read.Completed) + { + result.Message = "The dialog did not respond in time, so its buttons could not be read."; + return result; + } + + content = read.Result; + } + catch (ElementNotAvailableException) + { + result.DialogClosed = !IsOpen(window.Handle); + result.Message = result.DialogClosed + ? "The dialog closed before it could be answered." + : "The dialog changed while it was being read. Call dialog_list and try again."; + return result; + } + + var index = FindButton(content.Buttons, button, out error); + if (index < 0) + { + result.Message = error; + result.OpenDialogs = (await GetDialogsAsync().ConfigureAwait(false)).Dialogs; + return result; + } + + result.Button = content.Buttons[index].Name; + + try + { + var element = content.Elements[index]; + var click = await RunWithTimeoutAsync(() => Invoke(element), ClickTimeout).ConfigureAwait(false); + + if (click.Completed && !click.Result) + { + result.Message = $"The \"{result.Button}\" button cannot be clicked through UI Automation."; + return result; + } + + result.Clicked = true; + + if (!click.Completed) + { + // Invoke delivers the click synchronously, so it only fails to return while the + // button's handler is still running - almost always because it opened another + // modal dialog of its own. + result.Message = "The click was delivered but Visual Studio is still handling it. It may have opened another dialog."; + } + } + catch (ElementNotEnabledException) + { + result.Message = $"The \"{result.Button}\" button is disabled."; + return result; + } + catch (ElementNotAvailableException) + { + // The button, and usually the dialog with it, went away between reading and clicking. + result.DialogClosed = !IsOpen(window.Handle); + result.Message = "The button was no longer available to click."; + result.OpenDialogs = (await GetDialogsAsync().ConfigureAwait(false)).Dialogs; + return result; + } + + result.DialogClosed = await WaitForCloseAsync(window.Handle).ConfigureAwait(false); + + if (!result.DialogClosed && result.Message == null) + { + result.Message = "The button was clicked but the dialog is still open."; + } + + result.OpenDialogs = (await GetDialogsAsync().ConfigureAwait(false)).Dialogs; + return result; + } + + /// + /// The Win32 facts that decide whether a top-level window is a blocking dialog. Captured + /// separately from the decision so the rules can be tested without real windows. + /// + internal sealed class TopLevelWindow + { + public IntPtr Handle { get; set; } + public int ProcessId { get; set; } + public string ClassName { get; set; } = string.Empty; + public bool IsVisible { get; set; } + public bool IsEnabled { get; set; } + + /// + /// False for windows that never take focus, such as tooltips and popups. + /// + public bool CanActivate { get; set; } = true; + + public IntPtr Owner { get; set; } + public int OwnerProcessId { get; set; } + public bool IsOwnerEnabled { get; set; } + } + + internal static bool IsBlockingDialog(TopLevelWindow window, int processId) + { + if (!window.IsVisible || !window.IsEnabled || !window.CanActivate) + { + return false; + } + + if (window.Owner == IntPtr.Zero) + { + // With no owner there is nothing to disable, but a native dialog box still runs a + // modal loop. MessageBox called with a null owner is the usual case. + return window.ProcessId == processId + && string.Equals(window.ClassName, NativeDialogClassName, StringComparison.Ordinal); + } + + // A floating tool window also has an owner, but that owner stays enabled. + return !window.IsOwnerEnabled + && (window.ProcessId == processId || window.OwnerProcessId == processId); + } + + internal static bool TrySelectDialog( + IReadOnlyList dialogs, + string? dialogId, + out TopLevelWindow? selected, + out string? error) + { + selected = null; + error = null; + + if (dialogs.Count == 0) + { + error = NoDialogMessage; + return false; + } + + if (string.IsNullOrWhiteSpace(dialogId)) + { + if (dialogs.Count > 1) + { + error = $"{dialogs.Count} dialogs are open, so a dialog id is required. Call dialog_list to see them."; + return false; + } + + selected = dialogs[0]; + return true; + } + + if (TryParseId(dialogId!, out var handle)) + { + selected = dialogs.FirstOrDefault(dialog => dialog.Handle == handle); + } + + if (selected == null) + { + error = $"No open dialog has the id {dialogId}. Call dialog_list to see the open dialogs."; + return false; + } + + return true; + } + + /// + /// Finds the button the caller named, returning its index or -1 with an explanation. + /// + internal static int FindButton(IReadOnlyList buttons, string requested, out string? error) + { + error = null; + var key = ToMatchKey(requested); + + var matches = Enumerable.Range(0, buttons.Count) + .Where(i => string.Equals(ToMatchKey(buttons[i].Name), key, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (matches.Count == 0) + { + error = buttons.Count == 0 + ? "The dialog has no buttons that can be clicked." + : $"The dialog has no button named \"{requested}\". Its buttons are: {string.Join(", ", buttons.Select(b => b.Name))}."; + return -1; + } + + if (matches.Count > 1) + { + error = $"The dialog has {matches.Count} buttons named \"{requested}\", so the one to click is ambiguous."; + return -1; + } + + var index = matches[0]; + if (!buttons[index].IsEnabled) + { + error = $"The \"{buttons[index].Name}\" button is disabled."; + return -1; + } + + return index; + } + + /// + /// Reduces a button caption to the form used for matching: no access key marker, no trailing + /// ellipsis and no surrounding whitespace. + /// + internal static string ToMatchKey(string name) + { + var builder = new StringBuilder(name.Length); + + for (var i = 0; i < name.Length; i++) + { + if (name[i] == '&') + { + // "&&" is a literal ampersand; a single one only marks the access key. + if (i + 1 < name.Length && name[i + 1] == '&') + { + builder.Append('&'); + i++; + } + + continue; + } + + builder.Append(name[i]); + } + + var key = builder.ToString().Trim(); + + if (key.EndsWith("...", StringComparison.Ordinal)) + { + key = key.Substring(0, key.Length - 3); + } + else if (key.EndsWith("…", StringComparison.Ordinal)) + { + key = key.Substring(0, key.Length - 1); + } + + return key.TrimEnd(); + } + + internal static string FormatId(IntPtr handle) => + "0x" + handle.ToInt64().ToString("X8", CultureInfo.InvariantCulture); + + internal static bool TryParseId(string id, out IntPtr handle) + { + handle = IntPtr.Zero; + var digits = id.Trim(); + + if (digits.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + digits = digits.Substring(2); + } + + if (!long.TryParse(digits, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var value) + || value == 0) + { + return false; + } + + handle = new IntPtr(value); + return true; + } + + private List FindBlockingDialogs() + { + var dialogs = new List(); + + NativeMethods.EnumWindowsProc callback = (handle, _) => + { + // Hidden windows are the vast majority and can never be dialogs; skip them before + // reading anything else. + if (NativeMethods.IsWindowVisible(handle)) + { + var window = ReadWindow(handle); + if (IsBlockingDialog(window, _processId)) + { + dialogs.Add(window); + } + } + + return true; + }; + + NativeMethods.EnumWindows(callback, IntPtr.Zero); + GC.KeepAlive(callback); + + return dialogs; + } + + /// + /// Reads a window's state. None of these calls sends a message to the window, so a hung UI + /// thread cannot stall them. + /// + private static TopLevelWindow ReadWindow(IntPtr handle) + { + NativeMethods.GetWindowThreadProcessId(handle, out var processId); + + var className = new StringBuilder(256); + NativeMethods.GetClassName(handle, className, className.Capacity); + + var extendedStyle = NativeMethods.GetWindowLong(handle, NativeMethods.GWL_EXSTYLE); + var owner = NativeMethods.GetWindow(handle, NativeMethods.GW_OWNER); + + var window = new TopLevelWindow + { + Handle = handle, + ProcessId = (int)processId, + ClassName = className.ToString(), + IsVisible = true, + IsEnabled = NativeMethods.IsWindowEnabled(handle), + CanActivate = (extendedStyle & NativeMethods.WS_EX_NOACTIVATE) == 0, + Owner = owner, + }; + + if (owner != IntPtr.Zero) + { + NativeMethods.GetWindowThreadProcessId(owner, out var ownerProcessId); + window.OwnerProcessId = (int)ownerProcessId; + window.IsOwnerEnabled = NativeMethods.IsWindowEnabled(owner); + } + + return window; + } + + /// + /// Describes a dialog, or returns null when it closed before it could be read. + /// + private static async Task DescribeAsync(TopLevelWindow window) + { + var dialog = new DialogInfo { Id = FormatId(window.Handle), Title = ReadTitle(window.Handle) }; + + try + { + var read = await RunWithTimeoutAsync(() => ReadContent(window.Handle, dialog.Title), ReadTimeout) + .ConfigureAwait(false); + + if (read.Completed) + { + dialog.Title = read.Result.Title; + dialog.Message = read.Result.Message; + dialog.Buttons = read.Result.Buttons; + return dialog; + } + + dialog.Note = "The dialog did not respond in time, so only its title could be read."; + } + catch (ElementNotAvailableException) + { + // Raised when any element vanishes mid-read, which is not necessarily the dialog. + if (!IsOpen(window.Handle)) + { + return null; + } + + dialog.Note = "The dialog changed while it was being read. Call dialog_list again."; + } + catch (Exception ex) + { + dialog.Note = $"The dialog's contents could not be read: {ex.Message}"; + } + + return dialog; + } + + /// + /// Reads a window caption from the window manager's own copy. Unlike GetWindowText this + /// sends no WM_GETTEXT, so it answers even for a dialog whose thread has stopped responding - + /// where UI Automation can come back with an empty name instead. + /// + private static string ReadTitle(IntPtr handle) + { + var buffer = new StringBuilder(512); + var length = NativeMethods.InternalGetWindowText(handle, buffer, buffer.Capacity); + return length > 0 ? buffer.ToString() : string.Empty; + } + + private sealed class DialogContent + { + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public List Buttons { get; } = new(); + + /// + /// The automation element behind each entry in , at the same index. + /// + public List Elements { get; } = new(); + } + + /// The caption already read from Win32, preferred over UI Automation's. + private static DialogContent ReadContent(IntPtr handle, string title) + { + var root = AutomationElement.FromHandle(handle); + var content = new DialogContent + { + Title = string.IsNullOrEmpty(title) ? root.Current.Name ?? string.Empty : title, + }; + var walker = TreeWalker.ControlViewWalker; + var lines = new List(); + var visited = 0; + + void AddLine(string? text) + { + text = text?.Trim(); + + // Custom-chrome dialogs draw their caption as ordinary text. + if (string.IsNullOrEmpty(text) || text == content.Title || (lines.Count > 0 && lines[lines.Count - 1] == text)) + { + return; + } + + lines.Add(text!); + } + + void Collect(AutomationElement? element, int depth) + { + for (; element != null && visited < MaxElementsVisited; element = walker.GetNextSibling(element)) + { + visited++; + var current = element.Current; + var controlType = current.ControlType; + + if (controlType == ControlType.Button) + { + var name = current.Name?.Trim(); + if (!string.IsNullOrEmpty(name)) + { + content.Buttons.Add(new DialogButtonInfo { Name = name!, IsEnabled = current.IsEnabled }); + content.Elements.Add(element); + } + + continue; + } + + if (controlType == ControlType.Text) + { + AddLine(current.Name); + continue; + } + + if (controlType == ControlType.Edit || controlType == ControlType.Document) + { + AddLine(ReadOnlyText(element)); + continue; + } + + if (!SkippedSubtrees.Contains(controlType) && depth < MaxTreeDepth) + { + Collect(walker.GetFirstChild(element), depth + 1); + } + } + } + + Collect(walker.GetFirstChild(root), 1); + + var message = string.Join("\n", lines); + content.Message = message.Length > MaxMessageLength + ? message.Substring(0, MaxMessageLength) + "…" + : message; + + return content; + } + + /// + /// Some dialogs show their message in a read-only text box so it can be copied. Editable + /// boxes are input fields, not message text, and are left out. + /// + private static string? ReadOnlyText(AutomationElement element) + { + if (element.TryGetCurrentPattern(ValuePattern.Pattern, out var value)) + { + var current = ((ValuePattern)value).Current; + return current.IsReadOnly ? current.Value : null; + } + + if (element.TryGetCurrentPattern(TextPattern.Pattern, out var text)) + { + return ((TextPattern)text).DocumentRange.GetText(MaxMessageLength); + } + + return null; + } + + private static bool Invoke(AutomationElement element) + { + if (!element.TryGetCurrentPattern(InvokePattern.Pattern, out var pattern)) + { + return false; + } + + ((InvokePattern)pattern).Invoke(); + return true; + } + + private static bool IsOpen(IntPtr handle) => + NativeMethods.IsWindow(handle) && NativeMethods.IsWindowVisible(handle); + + private static async Task WaitForCloseAsync(IntPtr handle) + { + var deadline = DateTime.UtcNow + CloseTimeout; + + while (IsOpen(handle)) + { + if (DateTime.UtcNow >= deadline) + { + return false; + } + + await Task.Delay(ClosePollInterval).ConfigureAwait(false); + } + + return true; + } + + /// + /// Runs on a dedicated background thread and stops waiting after + /// . A call into a window that never answers then costs one parked + /// thread rather than the caller, and the thread pool is never starved by it. + /// + private static async Task<(bool Completed, T Result)> RunWithTimeoutAsync(Func work, TimeSpan timeout) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var thread = new Thread(() => + { + try + { + completion.SetResult(work()); + } + catch (Exception ex) + { + completion.SetException(ex); + } + }) + { + IsBackground = true, + Name = "MCPServer dialog automation", + }; + + // UI Automation clients belong in the multithreaded apartment, where calls into the + // dialog are not funnelled back through a message loop of their own. + thread.SetApartmentState(ApartmentState.MTA); + thread.Start(); + + var finished = await Task.WhenAny(completion.Task, Task.Delay(timeout)).ConfigureAwait(false); + + return finished == completion.Task + ? (true, await completion.Task.ConfigureAwait(false)) + : (false, default!); + } + + private static class NativeMethods + { + public const int GWL_EXSTYLE = -20; + public const int WS_EX_NOACTIVATE = 0x08000000; + public const uint GW_OWNER = 4; + + public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + + [DllImport("kernel32.dll")] + public static extern uint GetCurrentProcessId(); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool IsWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool IsWindowVisible(IntPtr hWnd); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool IsWindowEnabled(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern IntPtr GetWindow(IntPtr hWnd, uint uCmd); + + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount); + + [DllImport("user32.dll", EntryPoint = "GetWindowLongW")] + public static extern int GetWindowLong(IntPtr hWnd, int nIndex); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern int InternalGetWindowText(IntPtr hWnd, StringBuilder pString, int cchMaxCount); + } +} diff --git a/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs b/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs index 2065cf2..efa389e 100644 --- a/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs +++ b/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs @@ -99,5 +99,9 @@ Task DebugAddBreakpointAsync( Task ActivateWindowAsync(string caption); Task ShowToolWindowAsync(string name); Task HideToolWindowAsync(string caption); + + // Dialog tools + Task GetDialogsAsync(); + Task RespondToDialogAsync(string button, string? dialogId); } diff --git a/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs b/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs index d0e84c7..127b736 100644 --- a/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs +++ b/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs @@ -311,5 +311,9 @@ public Task CreateTerminalAsync(string? name, string? workingDir public Task ActivateWindowAsync(string caption) => _vsService.ActivateWindowAsync(caption); public Task ShowToolWindowAsync(string name) => _vsService.ShowToolWindowAsync(name); public Task HideToolWindowAsync(string caption) => _vsService.HideToolWindowAsync(caption); + + public Task GetDialogsAsync() => _vsService.GetDialogsAsync(); + public Task RespondToDialogAsync(string button, string? dialogId) + => _vsService.RespondToDialogAsync(button, dialogId); } diff --git a/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs b/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs index 5f98bc4..7228101 100644 --- a/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs +++ b/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs @@ -3396,6 +3396,49 @@ public async Task CloseAllTerminalsAsync() return await Terminal.CloseAllTerminalsAsync(CancellationToken.None); } + private DialogAutomation? _dialogs; + + private DialogAutomation Dialogs => _dialogs ??= new DialogAutomation(); + + /// + /// Never switches to the UI thread. A modal dialog is what holds that thread, so this has to + /// answer while the call that raised the dialog is still waiting on it. + /// + public async Task GetDialogsAsync() + { + using var activity = VsixTelemetry.Tracer.StartActivity("GetDialogs"); + + try + { + return await Dialogs.GetDialogsAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.RecordException(ex); + return new DialogListResult { Message = $"Could not enumerate dialogs: {ex.Message}" }; + } + } + + /// + /// Never switches to the UI thread, for the same reason as . + /// + public async Task RespondToDialogAsync(string button, string? dialogId) + { + using var activity = VsixTelemetry.Tracer.StartActivity("RespondToDialog"); + + try + { + return await Dialogs.RespondAsync(button, dialogId).ConfigureAwait(false); + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.RecordException(ex); + return new DialogResponseResult { Button = button, DialogId = dialogId, Message = ex.Message }; + } + } + /// /// ExecuteCommand throws when a command is disabled or unknown, which for Test Explorer is /// the normal state before discovery finishes. Probing first keeps that an ordinary result