<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Роман Тихоненко</title>
    <description>The latest articles on DEV Community by Роман Тихоненко (@phantasmat2018).</description>
    <link>https://dev.to/phantasmat2018</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3965290%2F43daaa89-64a6-4da4-b6bf-c568de2d4a82.jpg</url>
      <title>DEV Community: Роман Тихоненко</title>
      <link>https://dev.to/phantasmat2018</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/phantasmat2018"/>
    <language>en</language>
    <item>
      <title>Shipping a WPF side-project to winget and the Microsoft Store — and the autostart bug that only happens when packaged</title>
      <dc:creator>Роман Тихоненко</dc:creator>
      <pubDate>Wed, 17 Jun 2026 10:30:05 +0000</pubDate>
      <link>https://dev.to/phantasmat2018/shipping-a-wpf-side-project-to-winget-and-the-microsoft-store-and-the-autostart-bug-that-only-2oap</link>
      <guid>https://dev.to/phantasmat2018/shipping-a-wpf-side-project-to-winget-and-the-microsoft-store-and-the-autostart-bug-that-only-2oap</guid>
      <description>&lt;p&gt;I maintain &lt;strong&gt;CapyBro&lt;/strong&gt;, a small open-source Windows app (.NET 8 / WPF) that rewrites or translates selected text with AI on a global hotkey. This week it picked up two distribution upgrades — and one of them surfaced a bug that's completely invisible until you package the app. Sharing both in case you ship desktop .NET.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. It's on winget now
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight batchfile"&gt;&lt;code&gt;&lt;span class="kd"&gt;winget&lt;/span&gt; &lt;span class="kd"&gt;install&lt;/span&gt; &lt;span class="kd"&gt;RomanTykhonenko&lt;/span&gt;.CapyBro
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PR to &lt;code&gt;microsoft/winget-pkgs&lt;/code&gt; got merged after the usual automated validation plus a human moderator pass (New-Package submissions always get a person to look). A few notes that might save you time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I generated the manifest with &lt;strong&gt;&lt;code&gt;wingetcreate&lt;/code&gt;&lt;/strong&gt; — it handles the schema, the SHA256, and the device-flow auth that opens the PR for you. Far less error-prone than hand-writing the YAML.&lt;/li&gt;
&lt;li&gt;My NSIS installer is &lt;strong&gt;unsigned&lt;/strong&gt;, and it still passed validation cleanly, no warning labels. winget does &lt;strong&gt;not&lt;/strong&gt; require code signing to get in. (SmartScreen on first run is a separate, end-user thing — not a winget gate.)&lt;/li&gt;
&lt;li&gt;Turnaround was a few days. The bot does the heavy lifting; you mostly wait on a moderator.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Updates later are a one-liner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight batchfile"&gt;&lt;code&gt;&lt;span class="kd"&gt;wingetcreate&lt;/span&gt; &lt;span class="kd"&gt;update&lt;/span&gt; &lt;span class="kd"&gt;RomanTykhonenko&lt;/span&gt;.CapyBro &lt;span class="na"&gt;--version &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kd"&gt;X&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="na"&gt;--urls &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kd"&gt;url&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="na"&gt;--submit
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. The autostart bug that only exists when packaged
&lt;/h2&gt;

&lt;p&gt;The Microsoft Store build is MSIX-packaged. The plain &lt;code&gt;.exe&lt;/code&gt; uses the classic autostart trick: write &lt;code&gt;HKCU\Software\Microsoft\Windows\CurrentVersion\Run&lt;/code&gt;. Works fine, shipped that way for ages.&lt;/p&gt;

&lt;p&gt;Inside an MSIX package, that &lt;strong&gt;silently stops working&lt;/strong&gt;. Writes to that Run key get virtualized into the package's private view, and Windows won't honor a virtualized Run entry for login startup. So the user flips "Start on login," the app dutifully writes the key, reports success... and never launches on the next login. No error, nothing in the logs.&lt;/p&gt;

&lt;p&gt;The fix is to detect at runtime whether you're packaged, and switch backends:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Detection:&lt;/strong&gt; P/Invoke &lt;code&gt;GetCurrentPackageFullName&lt;/code&gt;. It returns &lt;code&gt;APPMODEL_ERROR_NO_PACKAGE&lt;/code&gt; when unpackaged, success when packaged. That one boolean picks the implementation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Packaged backend:&lt;/strong&gt; the &lt;code&gt;Windows.ApplicationModel.StartupTask&lt;/code&gt; WinRT API, plus a &lt;code&gt;windows.startupTask&lt;/code&gt; extension declared in the manifest (with &lt;code&gt;uap10:Parameters&lt;/code&gt; to pass the same silent flag the &lt;code&gt;.exe&lt;/code&gt; uses). Now the OS owns the startup entry — the user can even toggle it from &lt;strong&gt;Settings &amp;gt; Startup apps&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unpackaged backend:&lt;/strong&gt; the original Run-key writer, untouched.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Wiring it behind an &lt;code&gt;IStartupTaskBackend&lt;/code&gt; chosen by a runtime check meant the &lt;code&gt;.exe&lt;/code&gt; kept its exact old behavior and only the Store build got the new path — zero risk of regressing the thing that already worked.&lt;/p&gt;

&lt;p&gt;A gotcha inside the gotcha: pulling in the WinRT projection (&lt;code&gt;Windows.ApplicationModel.*&lt;/code&gt;) bumps your TFM to something like &lt;code&gt;net8.0-windows10.0.17763.0&lt;/code&gt; and drags in a chunky SDK projection. I didn't want the lean &lt;code&gt;.exe&lt;/code&gt; carrying that, so the project &lt;strong&gt;multi-targets&lt;/strong&gt;: the plain Win32 build excludes the WinRT files via &lt;code&gt;#if&lt;/code&gt;, and only the packaged target compiles them in.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Native ARM64
&lt;/h2&gt;

&lt;p&gt;While repackaging I added a native &lt;strong&gt;ARM64&lt;/strong&gt; build (&lt;code&gt;win-arm64&lt;/code&gt;, ReadyToRun, cross-compiled on an x64 host) as a second package in the same Store submission. WPF runs natively on ARM64 .NET 8, so Surface / Snapdragon users skip the x64 emulation layer now. Honest caveat: I don't own ARM hardware, so it shipped smoke-tested on the build side but not on a real device — if you're on Windows-on-ARM, I'd genuinely love a sanity check.&lt;/p&gt;




&lt;p&gt;winget for the CLI crowd; a Store build that actually autostarts and runs native on ARM64. It's MIT-licensed if you want to poke at the code: &lt;strong&gt;&lt;a href="https://github.com/phantasmat2018/capy-bro" rel="noopener noreferrer"&gt;https://github.com/phantasmat2018/capy-bro&lt;/a&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;a href="https://capybro.app" rel="noopener noreferrer"&gt;https://capybro.app&lt;/a&gt;&lt;/strong&gt;. Happy to answer anything about the winget or MSIX side in the comments.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>windows</category>
      <category>opensource</category>
      <category>devtools</category>
    </item>
    <item>
      <title>After 4 months solo: shipping a Windows tray AI hotkey on .NET 8 + WPF (and the Win32 paste-back rabbit hole)</title>
      <dc:creator>Роман Тихоненко</dc:creator>
      <pubDate>Tue, 02 Jun 2026 20:18:52 +0000</pubDate>
      <link>https://dev.to/phantasmat2018/after-4-months-solo-shipping-a-windows-tray-ai-hotkey-on-net-8-wpf-and-the-win32-paste-back-50hm</link>
      <guid>https://dev.to/phantasmat2018/after-4-months-solo-shipping-a-windows-tray-ai-hotkey-on-net-8-wpf-and-the-win32-paste-back-50hm</guid>
      <description>&lt;p&gt;I spent 4 months of nights and weekends building &lt;a href="https://capybro.app" rel="noopener noreferrer"&gt;CapyBro&lt;/a&gt; — a Windows tray app that runs AI on any selected text via a global hotkey. Native .NET 8 + WPF (not Electron), MIT-licensed, ~49 MB installer. Two backends: cloud (OpenRouter) or fully local (Ollama). The hardest technical problem turned out to be Win32 paste-back into child controls. This post walks through that rabbit hole + the architecture decisions that paid off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built this
&lt;/h2&gt;

&lt;p&gt;For most of 2025, my AI workflow was this loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Read something in Slack / a doc / a Telegram message
2. Alt+Tab → ChatGPT tab → paste
3. Type my prompt
4. Wait
5. Copy result
6. Alt+Tab back → paste over the original
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I caught myself doing this &lt;strong&gt;30+ times a day&lt;/strong&gt; for trivial things — fixing one comma, translating a paragraph for a client email, rewording a DM so it doesn't sound passive-aggressive.&lt;/p&gt;

&lt;p&gt;Then it hit me: AI is currently trapped in a browser tab. But every other utility on my PC — clipboard manager, screenshot tool, voice typer, password manager, snippet expander — is &lt;strong&gt;one hotkey away&lt;/strong&gt;. Why isn't AI?&lt;/p&gt;

&lt;p&gt;So I built it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CapyBro does
&lt;/h2&gt;

&lt;p&gt;You select text &lt;strong&gt;anywhere&lt;/strong&gt; on Windows. Word, Telegram, VS Code, your browser, Notepad, Discord, an email draft, a YAML file in your terminal — doesn't matter. The OS-level selection is the input.&lt;/p&gt;

&lt;p&gt;You press &lt;strong&gt;Ctrl+Shift+E&lt;/strong&gt;. A small popup appears. You pick a prompt (or type a custom one). AI runs. The result &lt;strong&gt;replaces the original text in the same app&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That's the entire product. The whole magic is in step 3 — &lt;em&gt;replacing text in the source app&lt;/em&gt;. Sounds trivial. Took me three iterations of Win32 plumbing to get right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Win32 paste-back rabbit hole
&lt;/h2&gt;

&lt;p&gt;This is the most undervalued part of the project. I expected ~100 lines of code. Got 130 lines of comments around a 50-line method that does two things: &lt;strong&gt;capture focused-child HWND before showing UI&lt;/strong&gt;, then &lt;strong&gt;restore foreground + focus&lt;/strong&gt; when user clicks Accept.&lt;/p&gt;

&lt;h3&gt;
  
  
  Naive solution (doesn't work)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;SendKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendWait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"^v"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why it fails:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;No control over which window receives the Ctrl+V&lt;/li&gt;
&lt;li&gt;If your modal dialog is still open, Ctrl+V goes THERE&lt;/li&gt;
&lt;li&gt;If foreground changed between Accept and send → paste lands somewhere random&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Better but still broken
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;IntPtr&lt;/span&gt; &lt;span class="n"&gt;originalForeground&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;GetForegroundWindow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nf"&gt;ShowDialog&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// User clicks Accept&lt;/span&gt;
&lt;span class="nf"&gt;SetForegroundWindow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;originalForeground&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;50&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;SendKeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SendWait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"^v"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Works for Notepad. Fails for Notepad++, VS Code, Office. Why?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;SetForegroundWindow&lt;/code&gt; is gated by &lt;a href="https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow" rel="noopener noreferrer"&gt;OS focus rules&lt;/a&gt; — caller must have received last input event, no active foreground lock, target not minimized. Any check fails → it silently returns false.&lt;/li&gt;
&lt;li&gt;The actual editor lives in a child control (e.g. Scintilla inside Notepad++). &lt;code&gt;SetForegroundWindow&lt;/code&gt; activates the top-level frame, but keyboard focus stays elsewhere. &lt;code&gt;SendInput Ctrl+V&lt;/code&gt; then lands on the WindowProc of a non-input frame.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Working solution: AttachThreadInput sandwich
&lt;/h3&gt;

&lt;p&gt;The trick is &lt;strong&gt;AttachThreadInput&lt;/strong&gt; — an API that lets your thread temporarily share input state with another thread. While attached, the OS treats both threads as one for focus/foreground purposes, bypassing the "didn't receive last input event" check.&lt;/p&gt;

&lt;p&gt;Plus: I need to know &lt;strong&gt;which child HWND had keyboard focus&lt;/strong&gt; before my modal stole it. &lt;code&gt;GetGUIThreadInfo.hwndFocus&lt;/code&gt; returns exactly that.&lt;/p&gt;

&lt;p&gt;Here's the production code (extracted from &lt;a href="https://github.com/phantasmat2018/capy-bro/blob/main/src/CapyBro/Platform/ForegroundRestorer.cs" rel="noopener noreferrer"&gt;&lt;code&gt;Platform/ForegroundRestorer.cs&lt;/code&gt;&lt;/a&gt;):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 1: capture, BEFORE showing UI&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IntPtr&lt;/span&gt; &lt;span class="n"&gt;TopLevel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IntPtr&lt;/span&gt; &lt;span class="n"&gt;FocusedChild&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;CaptureForegroundFocus&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;topLevel&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NativeMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetForegroundWindow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topLevel&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;IntPtr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Zero&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IntPtr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Zero&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IntPtr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Zero&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;targetThreadId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NativeMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetWindowThreadProcessId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topLevel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;targetThreadId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topLevel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IntPtr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Zero&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;NativeMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GUITHREADINFO&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;CbSize&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;uint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;Marshal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SizeOf&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;NativeMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GUITHREADINFO&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NativeMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetGUIThreadInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;targetThreadId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;ref&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topLevel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HwndFocus&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topLevel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IntPtr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Zero&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Phase 2: restore, AFTER user clicks Accept&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;RestoreToForeground&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IntPtr&lt;/span&gt; &lt;span class="n"&gt;topLevel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IntPtr&lt;/span&gt; &lt;span class="n"&gt;focusedChild&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topLevel&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;IntPtr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Zero&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;targetThreadId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NativeMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetWindowThreadProcessId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topLevel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;targetThreadId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;NativeMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetForegroundWindow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topLevel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// window died, best-effort&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NativeMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsIconic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topLevel&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;NativeMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ShowWindowAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topLevel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NativeMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SwRestore&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ourThreadId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NativeMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetCurrentThreadId&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ourThreadId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;targetThreadId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;NativeMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetForegroundWindow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topLevel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// AttachThreadInput on same thread is undefined&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;attached&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NativeMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AttachThreadInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ourThreadId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;targetThreadId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;NativeMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BringWindowToTop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topLevel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;fgOk&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NativeMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetForegroundWindow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topLevel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// CRITICAL: SetFocus on the FOCUSED CHILD, not the top-level frame.&lt;/span&gt;
        &lt;span class="c1"&gt;// Without this, SendInput Ctrl+V echoes into the non-input frame.&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;focusTarget&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;focusedChild&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;IntPtr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Zero&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;focusedChild&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;topLevel&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;NativeMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetFocus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;focusTarget&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fgOk&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ALWAYS detach. Forget this, and the user loses keyboard control&lt;/span&gt;
        &lt;span class="c1"&gt;// system-wide for the lifetime of your process.&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;NativeMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AttachThreadInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ourThreadId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;targetThreadId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Pieces that earned their place
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;AttachThreadInput&lt;/code&gt;&lt;/strong&gt; — bypasses focus-stealing protection. Without it, &lt;code&gt;SetForegroundWindow&lt;/code&gt; silently no-ops.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SetFocus&lt;/code&gt; on child HWND&lt;/strong&gt; — Notepad++'s actual edit is a Scintilla control nested inside its frame. &lt;code&gt;SetFocus&lt;/code&gt; on the frame leaves keyboard focus on the wrong WindowProc.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;BringWindowToTop&lt;/code&gt; before &lt;code&gt;SetForegroundWindow&lt;/code&gt;&lt;/strong&gt; — raises z-order even when the foreground call is rejected. Belt-and-braces.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;IsIconic&lt;/code&gt; + &lt;code&gt;ShowWindowAsync(SW_RESTORE)&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;SetForegroundWindow&lt;/code&gt; no-ops on minimized targets. Restore first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;finally&lt;/code&gt; + &lt;code&gt;AttachThreadInput(false)&lt;/code&gt;&lt;/strong&gt; — I forgot this once during development. Lost system-wide keyboard input until I rebooted. Don't be me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SendInput&lt;/code&gt; not &lt;code&gt;SendKeys&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;SendKeys&lt;/code&gt; uses scan codes that break on non-Latin layouts. &lt;code&gt;SendInput&lt;/code&gt; works with virtual-key codes.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Bonus rabbit hole: the clipboard is single-owner
&lt;/h3&gt;

&lt;p&gt;Win32 clipboard is a single-owner resource. Clipboard managers, RDP virtual channels, antivirus, even the OS shell briefly hold it. Without retry, any concurrent open throws &lt;code&gt;CLIPBRD_E_CANT_OPEN&lt;/code&gt; (HRESULT &lt;code&gt;0x800401D0&lt;/code&gt;) and you lose either the AI result or the user's original selection.&lt;/p&gt;

&lt;p&gt;I wrap every clipboard call in an &lt;strong&gt;async&lt;/strong&gt; retry loop (not sync — sync &lt;code&gt;Thread.Sleep&lt;/code&gt; between attempts freezes the WPF UI for up to 500ms):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;RetryAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;Func&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;RetryAttempts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;retryDelay&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromMilliseconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;50&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;RetryAttempts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ThrowIfCancellationRequested&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;action&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;COMException&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HResult&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;unchecked&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="m"&gt;0x800401D0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;RetryAttempts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retryDelay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ConfigureAwait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidOperationException&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Win32 calls themselves are synchronous (the API has no cancellation hook), but the gaps between retries release the dispatcher so WPF can pump messages, repaint, and respond to input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two AI backends, one interface
&lt;/h2&gt;

&lt;p&gt;CapyBro supports OpenRouter (cloud — one API key, ~300 models) and Ollama (local — text never leaves the machine). Both stream responses.&lt;/p&gt;

&lt;p&gt;The pain: OpenRouter speaks &lt;strong&gt;SSE&lt;/strong&gt; (&lt;code&gt;data: {...}\n\n&lt;/code&gt;, terminated by &lt;code&gt;data: [DONE]&lt;/code&gt;), Ollama speaks &lt;strong&gt;NDJSON&lt;/strong&gt; (one JSON object per line, terminated by &lt;code&gt;{"done": true}&lt;/code&gt;). Different error shapes, different rate-limit signaling.&lt;/p&gt;

&lt;p&gt;The abstraction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;ILlmProvider&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;IAsyncEnumerable&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;ImproveStreamAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;// OpenRouter uses; Ollama ignores&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;promptText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;userText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;TimeSpan&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;preserveLanguage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IReadOnlyList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetModelsAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;ct&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;RequiresApiKey&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;RequiresApiKey&lt;/code&gt; is the cute bit — it lets TextProcessor pre-flight the request. If &lt;code&gt;Provider=OpenRouter&lt;/code&gt; and key is empty, show an actionable toast ("set your key in Settings") instead of round-tripping to a 401.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ILlmProviderFactory.Resolve&lt;/code&gt; is a switch that &lt;strong&gt;throws on unknown enum values&lt;/strong&gt;, not a fall-back to OpenRouter. A future 3rd provider added without matching switch arm + DI registration will crash on first user interaction instead of silently routing to the wrong backend. That's intentional — silent fallbacks are how you ship "why does my Anthropic key work everywhere except CapyBro?" bug reports.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ollama edge case: stream truncated vs empty result
&lt;/h3&gt;

&lt;p&gt;A subtle bug I found while testing: Ollama can complete a request &lt;strong&gt;without&lt;/strong&gt; a &lt;code&gt;done:true&lt;/code&gt; frame — connection drop, proxy timeout, antivirus interception. The total content length is 0, but it's not "the model returned nothing" — it's "the network died."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;sawDoneFrame&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;totalContentLength&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;ReadNdjsonFramesAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Token&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;sawDoneFrame&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;totalContentLength&lt;/span&gt; &lt;span class="p"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Delta&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;totalContentLength&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OpenRouterException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;sawDoneFrame&lt;/span&gt;
            &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;_translator&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"api_empty_result"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;// model returned ""&lt;/span&gt;
            &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_translator&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"api_server_error"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// stream interrupted&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two different toasts: "the model didn't produce output for your prompt" vs "check whether &lt;code&gt;ollama serve&lt;/code&gt; is running." Different remediation paths for the user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installer size: 150 MB → 49 MB
&lt;/h2&gt;

&lt;p&gt;Self-contained .NET 8 + WPF publish folder is ~150 MB. Single-file &lt;code&gt;.exe&lt;/code&gt; is tempting but:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Single-file decompresses into memory on every launch → visible cold-start latency&lt;/li&gt;
&lt;li&gt;Self-extracting native libraries unpack to &lt;code&gt;%TEMP%&lt;/code&gt; on first run → first-launch hit&lt;/li&gt;
&lt;li&gt;For a tray app the user opens dozens of times a day, that's noticeable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So: &lt;strong&gt;folder build&lt;/strong&gt; + NSIS LZMA SOLID compression. The csproj:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;PropertyGroup&lt;/span&gt; &lt;span class="na"&gt;Condition=&lt;/span&gt;&lt;span class="s"&gt;"'$(_IsPublishing)' == 'true'"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;SelfContained&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/SelfContained&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;RuntimeIdentifier&amp;gt;&lt;/span&gt;win-x64&lt;span class="nt"&gt;&amp;lt;/RuntimeIdentifier&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;PublishReadyToRun&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/PublishReadyToRun&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;DebugType&amp;gt;&lt;/span&gt;none&lt;span class="nt"&gt;&amp;lt;/DebugType&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/PropertyGroup&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Strip 13 culture-specific satellite assembly folders. --&amp;gt;&lt;/span&gt;
&lt;span class="c"&gt;&amp;lt;!-- Our UI translations live in Translator.cs, not satellite assemblies. --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;SatelliteResourceLanguages&amp;gt;&lt;/span&gt;en&lt;span class="nt"&gt;&amp;lt;/SatelliteResourceLanguages&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NSIS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SetCompressor /SOLID lzma
SetCompressorDictSize 64
File /r "..\publish\win-x64\*.*"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LZMA SOLID archives everything as one stream rather than per-file. Repeated bytes across files compress much better. ~49 MB installer, ~150 MB unpacked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I didn't do: &lt;code&gt;PublishTrimmed&lt;/code&gt;.&lt;/strong&gt; WPF heavily uses reflection for XAML binding + resource lookup. Trimmer eagerly removes "unused" types, then runtime XAML lookup explodes with &lt;code&gt;Type not found&lt;/code&gt;. I tried &lt;code&gt;TrimMode=partial&lt;/code&gt; and got 25 MB savings + 12 runtime regressions. Reverted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open source as a trust mechanism, not ideology
&lt;/h2&gt;

&lt;p&gt;This is a utility that reads my text — sometimes confidential (client emails, draft docs). Would I trust it if it were closed-source from an unknown indie dev?&lt;/p&gt;

&lt;p&gt;No.&lt;/p&gt;

&lt;p&gt;So why should other people trust me?&lt;/p&gt;

&lt;p&gt;Answer: &lt;strong&gt;open the source&lt;/strong&gt;. Remove "trust me bro" and show what happens.&lt;/p&gt;

&lt;p&gt;I picked MIT. Almost went "source-available" (popular among indie SaaS right now) but decided:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If someone forks and fixes a bug I missed → that saves me work, doesn't "compete" with my product&lt;/li&gt;
&lt;li&gt;If someone forks and sells their own version → they still don't have my community, my support, my updates. The product isn't the code.&lt;/li&gt;
&lt;li&gt;"Source-available" gets a negative reaction in the dev community. MIT gets a positive one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;API keys live in &lt;strong&gt;Windows Credential Manager&lt;/strong&gt; via &lt;a href="https://www.nuget.org/packages/Meziantou.Framework.Win32.CredentialManager" rel="noopener noreferrer"&gt;&lt;code&gt;Meziantou.Framework.Win32.CredentialManager&lt;/code&gt;&lt;/a&gt;, not &lt;code&gt;config.json&lt;/code&gt;. DPAPI encryption under the hood, bound to the user account, non-portable across machines by design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons after 4 months
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Win32 is alive.&lt;/strong&gt; Microsoft didn't replace it — they hid it behind WPF/WinUI. Build anything non-trivial system-side, and you're back to user32.dll. My P/Invoke list for the core workflow: &lt;code&gt;RegisterHotKey&lt;/code&gt;, &lt;code&gt;SendInput&lt;/code&gt;, &lt;code&gt;GetForegroundWindow&lt;/code&gt;, &lt;code&gt;GetGUIThreadInfo&lt;/code&gt;, &lt;code&gt;AttachThreadInput&lt;/code&gt;, &lt;code&gt;SetFocus&lt;/code&gt;, &lt;code&gt;BringWindowToTop&lt;/code&gt;, &lt;code&gt;IsIconic&lt;/code&gt;, &lt;code&gt;ShowWindowAsync&lt;/code&gt;. That's just the baseline.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Native beats Electron for tray utilities.&lt;/strong&gt; Not ideology — pragmatism. 49 MB vs 250 MB installer, &amp;lt;1s cold start vs 3-5s, ~80 MB RAM idle vs ~400 MB. For something that lives in the background, that's the difference between "I don't notice it" and "oh there you are."&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;Local\&lt;/code&gt; Mutex, not &lt;code&gt;Global\&lt;/code&gt;.&lt;/strong&gt; Singletons usually use &lt;code&gt;Global\&lt;/code&gt; namespace, which requires &lt;code&gt;SeCreateGlobalPrivilege&lt;/code&gt;. That right is granted to interactive users by default but &lt;strong&gt;stripped on locked-down domain machines&lt;/strong&gt; (kiosks, AppLocker configs). On those systems, my app crashed at startup with &lt;code&gt;UnauthorizedAccessException&lt;/code&gt;. &lt;code&gt;Local\&lt;/code&gt; (per-session) has no such restriction and matches the semantics I actually want (one instance per user session, not per machine):&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;   &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;DefaultMutexName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;@"Local\CapyBroV2"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
   &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;mutex&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Mutex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;initiallyOwned&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mutexName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;createdNew&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;createdNew&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also: &lt;code&gt;initiallyOwned: false&lt;/code&gt;. With &lt;code&gt;true&lt;/code&gt;, I'd get &lt;code&gt;AbandonedMutexException&lt;/code&gt; after every crash. With &lt;code&gt;false&lt;/code&gt;, process death cleans up silently.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Foreground-poller for popup dismiss, not Mouse.Capture.&lt;/strong&gt; My first prompt-picker used &lt;code&gt;Mouse.Capture(this, SubTree)&lt;/code&gt; to detect clicks outside. WPF ListBox grabs &lt;code&gt;Mouse.Capture&lt;/code&gt; internally for click-drag selection — my &lt;code&gt;LostMouseCapture&lt;/code&gt; handler closed the popup BEFORE the user's MouseLeftButtonUp reached the ListBox. Final version uses a 100ms &lt;code&gt;DispatcherTimer&lt;/code&gt; + &lt;code&gt;GetForegroundWindow()&lt;/code&gt; poll. If foreground isn't my popup → close. Cross-process clicks (browser tabs, Notepad, Telegram) are invisible to WPF's input system — polling Win32 is the only reliable catch-all.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;STJ with source generation.&lt;/strong&gt; Not &lt;code&gt;JsonSerializer.Deserialize&amp;lt;T&amp;gt;(json)&lt;/code&gt; (reflection-based). Instead:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;   &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonSerializable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AppConfig&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
   &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;partial&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppConfigJsonContext&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;JsonSerializerContext&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

   &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Deserialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AppConfigJsonContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AppConfig&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One day to set up &lt;code&gt;[JsonSerializable]&lt;/code&gt; attrs for each DTO. Result: AOT-friendly, no runtime reflection, faster parsing, cleaner stack traces on &lt;code&gt;JsonException&lt;/code&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tech is ~30% of the work.&lt;/strong&gt; The other 70% is marketing, docs, screenshots, localizations, SEO, GitHub issue triage, replying on Reddit. As a solo dev, that's not "side activity" — it's &lt;strong&gt;the&lt;/strong&gt; activity after MVP.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Stack receipts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;~12,000 lines of C# (the WPF app)&lt;/li&gt;
&lt;li&gt;~3,000 lines of Next.js (marketing site)&lt;/li&gt;
&lt;li&gt;~4 months, nights + weekends&lt;/li&gt;
&lt;li&gt;~$130 spent (domain, OpenRouter test credits, stock icons I didn't end up using)&lt;/li&gt;
&lt;li&gt;Coffee: uncountable&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;macOS port via Avalonia (~2 months)&lt;/li&gt;
&lt;li&gt;Browser extension companion for web apps with shadow DOM&lt;/li&gt;
&lt;li&gt;Native AOT once WPF + AOT become compatible (would shave 49 MB → ~25 MB)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Code + links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Source:&lt;/strong&gt; &lt;a href="https://github.com/phantasmat2018/capy-bro" rel="noopener noreferrer"&gt;https://github.com/phantasmat2018/capy-bro&lt;/a&gt; (MIT)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Site:&lt;/strong&gt; &lt;a href="https://capybro.app" rel="noopener noreferrer"&gt;https://capybro.app&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Installer:&lt;/strong&gt; &lt;a href="https://github.com/phantasmat2018/capy-bro/releases/tag/v2.0.0" rel="noopener noreferrer"&gt;https://github.com/phantasmat2018/capy-bro/releases/tag/v2.0.0&lt;/a&gt; (Win 10/11 x64, 49 MB)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've built a similar Windows-side AI tool, I'd love to hear what Win32 weirdness you ran into. The Office (Word, Excel) paste-back behavior is something I still haven't 100% nailed — Word works, Excel works only via the F2/Esc edit-mode dance. If anyone has a clean solution, drop it in the comments 🙏&lt;/p&gt;

&lt;p&gt;Thanks for reading.&lt;/p&gt;




&lt;p&gt;Update — June 5: CapyBro is live on Product Hunt today!&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://www.producthunt.com/products/capybro" rel="noopener noreferrer"&gt;https://www.producthunt.com/products/capybro&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Would love your thoughts there too 🦫&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>opensource</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
