<?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.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>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;

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