<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Build Desktop App Rust on RockB</title><link>https://baeseokjae.github.io/tags/build-desktop-app-rust/</link><description>Recent content in Build Desktop App Rust on RockB</description><image><title>RockB</title><url>https://baeseokjae.github.io/images/og-default.png</url><link>https://baeseokjae.github.io/images/og-default.png</link></image><generator>Hugo</generator><language>en-us</language><lastBuildDate>Thu, 01 Oct 2026 07:34:23 +0000</lastBuildDate><atom:link href="https://baeseokjae.github.io/tags/build-desktop-app-rust/index.xml" rel="self" type="application/rss+xml"/><item><title>Building a Native App for Coding Agents in Rust and GPUI: A rust coding agent gui How-To</title><link>https://baeseokjae.github.io/posts/native-app-for-coding-agents-rust-gpui/</link><pubDate>Thu, 01 Oct 2026 07:34:23 +0000</pubDate><guid>https://baeseokjae.github.io/posts/native-app-for-coding-agents-rust-gpui/</guid><description>Build a rust coding agent gui with GPUI: the dependency-channel trap, streaming without repaint storms, PTY panes, JSON-RPC control, and packaging.</description><content:encoded><![CDATA[<p>Build a native coding-agent GUI in Rust by pairing Zed&rsquo;s GPU-accelerated GPUI framework with Longbridge&rsquo;s <code>gpui-component</code> kit, keeping both on the same dependency channel, and driving every update as an event rather than a polling timer.</p>
<h2 id="why-build-a-native-gui-for-coding-agents-in-rust-and-gpui">Why Build a Native GUI for Coding Agents in Rust and GPUI?</h2>
<p>Because the interesting part of an agent app is not the chat bubble — it is the input path, the process boundary, and the frame budget. GPUI hands you all three as explicit primitives instead of hiding them behind a webview. The counter-argument is equally real: GPUI is pre-1.0, its dependency channels are genuinely confusing, and its first build compiles a graphics stack from source. This guide is the build path that currently does not exist as a single document — the popular GPUI tutorials stop at a static Linear-lookalike dashboard, and the best agent-specific write-up (Paneflow&rsquo;s post-mortem) is an architecture essay rather than a reproducible how-to.</p>
<h3 id="is-native-actually-worth-it-over-electron-or-tauri">Is &ldquo;native&rdquo; actually worth it over Electron or Tauri?</h3>
<p>The honest answer in 2026 is that the reason to go native is <em>control</em>, not megabytes. Paneflow — a GPUI terminal workspace for running Claude Code, Codex, and OpenCode side by side — abandoned a working Tauri prototype not because of memory, but because a webview could not give it raw key chords, clean IME behaviour, and inter-pane focus without a JavaScript round-trip (<a href="https://dev.to/arthurj-dev/building-a-native-terminal-for-ai-coding-agents-in-rust-gpui-2bg4">post-mortem</a>). Its stated lesson: &ldquo;when a framework asks you to work around its own abstraction for two basic features, you&rsquo;re building against the framework, not with it. Pivot.&rdquo;</p>
<p>Electron is rejected first in almost every one of these projects, and the reason is structural: an agent surface streams tokens continuously, repaints a terminal grid at 60–120fps, and must never drop a keystroke. That is a rendering workload, not a document workload.</p>
<h3 id="what-do-the-shipping-apps-actually-build">What do the shipping apps actually build?</h3>
<p>Look at what exists as of October 2026:</p>
<table>
  <thead>
      <tr>
          <th>App</th>
          <th>What it is</th>
          <th>Framework evidence</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Zed</td>
          <td>Reference implementation, 91,149 stars, 10,863 forks, v1.22.0 released 2026-09-30</td>
          <td>GPUI (it <em>is</em> GPUI)</td>
      </tr>
      <tr>
          <td>Paneflow</td>
          <td>Native terminal for Claude Code / Codex / OpenCode; 83 stars, GPL-3.0</td>
          <td>GPUI + <code>alacritty_terminal</code></td>
      </tr>
      <tr>
          <td>Waku</td>
          <td>One agent-neutral timeline, git-ref checkpoint/rewind, Sparkle updates on macOS</td>
          <td>Native Rust + GPUI (Show HN 2026-08-16, 39 points)</td>
      </tr>
      <tr>
          <td>muxel</td>
          <td>GPUI multi-agent multiplexer: tiled panes, worktrees, live agent status, scheduled runs, SSH</td>
          <td>GPUI, GPL-3.0, first commit 2026-06-24</td>
      </tr>
      <tr>
          <td>code-assistant (stippi)</td>
          <td>LLM coding assistant with GPUI GUI, TUI, and MCP/ACP modes; 182 stars, MIT</td>
          <td>GPUI</td>
      </tr>
      <tr>
          <td>deck</td>
          <td>~700-line starter app with a decision-log <code>LEARNINGS.md</code></td>
          <td>GPUI + gpui-component</td>
      </tr>
  </tbody>
</table>
<p>The pattern is general, not terminal-only: adjacent GPUI apps include zedis (Redis GUI, 2,120 stars), disktree (disk treemap, 2,050 stars), and Loungy (launcher, 1,733 stars).</p>
<h2 id="what-are-the-prerequisites">What Are the Prerequisites?</h2>
<p>You need a recent Rust stable, a working GPU stack, and patience for one long first build.</p>
<ul>
<li><strong>Rust:</strong> gpui HEAD uses just-stabilized standard-library APIs (for example <code>cold_path</code>), so an older toolchain fails. Zed pins Rust 1.95.0; Paneflow pins 1.96.1. Both do it through <code>rust-toolchain.toml</code> — copy that habit.</li>
<li><strong>GPU backend:</strong> macOS uses Metal (Xcode required). On Linux, the crates.io build goes through Blade/Vulkan while the git build goes through wgpu, since zed PR #46758 merged on 2026-02-13. Windows is the least-evidenced target: Paneflow defers its Windows ARM64 build pending GPUI DirectX reliability.</li>
<li><strong>First build cost:</strong> 5–15 minutes, once, because GPUI and wgpu compile from source. Rebuilds afterwards are fast.</li>
<li><strong>Ecosystem context:</strong> GPUI is a small crate next to the rest of Rust&rsquo;s UI world. Over the last 90 days crates.io served 19.75M downloads of ratatui, 12.82M of tauri, 5.69M of egui, 604K of iced, and 177K of <code>gpui</code>. That is exactly why the component kit matters so much.</li>
</ul>
<h2 id="which-gpui-dependency-path-should-you-choose">Which GPUI Dependency Path Should You Choose?</h2>
<p>This is the decision that costs people the most time, and almost nobody documents it. There are three channels, and they are <strong>not</strong> interchangeable.</p>
<table>
  <thead>
      <tr>
          <th>Channel</th>
          <th>What you get</th>
          <th>Trade-off</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>crates.io pair (<code>gpui</code> 0.2.x + <code>gpui-component</code> 0.5.x)</td>
          <td>Official crates, <code>cargo add</code> simplicity</td>
          <td>Frozen snapshot from Oct 2025; Blade/Vulkan on Linux</td>
      </tr>
      <tr>
          <td>Git pair (<code>gpui</code> + <code>gpui_platform</code> + <code>gpui-component</code>, all from Zed/Longbridge git)</td>
          <td>Current Zed main, wgpu on Linux, real components</td>
          <td>Needs bleeding-edge Rust stable; commit a <code>Cargo.lock</code></td>
      </tr>
      <tr>
          <td><code>gpui-unofficial</code> / forks</td>
          <td>A tag-for-tag mirror, or added features (Kael)</td>
          <td>Not a wgpu fork; incompatible with <code>gpui-component</code></td>
      </tr>
  </tbody>
</table>
<h3 id="why-does-gpui-on-cratesio-look-abandoned">Why does <code>gpui</code> on crates.io look abandoned?</h3>
<p>It is Zed&rsquo;s own official crate — repository <code>zed-industries/zed</code>, homepage <code>gpui.rs</code> — but the entire 0.2.x line shipped in October 2025. Version 0.2.0 landed 2025-10-09, 0.2.2 landed 2025-10-22, and nothing has been published since. On 2026-10-01 crates.io reports <code>gpui</code> 0.2.2 as max stable, roughly 11 months behind Zed main, with 316,531 all-time downloads and 177,435 in the last 90 days. Zed main has meanwhile split the framework into <code>gpui</code>, <code>gpui_platform</code>, <code>gpui_web</code>, and <code>gpui_macros</code> — and none of those split crates are on crates.io yet.</p>
<p><code>gpui-component</code> moves faster: 0.7.0 released 2026-09-28, 159,788 all-time downloads, 94,980 in the last 90 days, backing the 15,377-star <code>longbridge/gpui-kit</code> repo. But its <em>published</em> releases are built against the frozen registry <code>gpui</code>.</p>
<h3 id="what-is-the-e0277-trap">What is the E0277 trap?</h3>
<p>This is the failure mode worth memorising. A published <code>gpui-component</code> is compiled against one specific <code>gpui</code>. If your <code>Cargo.toml</code> pulls a <em>different</em> GPUI lineage — <code>gpui-unofficial</code>, a mismatched git revision, or a crates.io/git mix — both dependencies compile cleanly and independently. Then <strong>your</strong> code fails with <code>E0277</code>:</p>



<div class="goat svg-container ">
  
    <svg
      xmlns="http://www.w3.org/2000/svg"
      font-family="Menlo,Lucida Console,monospace"
      
        viewBox="0 0 512 25"
      >
      <g transform='translate(8,16)'>
<text text-anchor='middle' x='0' y='4' fill='currentColor' style='font-size:1em'>e</text>
<text text-anchor='middle' x='8' y='4' fill='currentColor' style='font-size:1em'>r</text>
<text text-anchor='middle' x='16' y='4' fill='currentColor' style='font-size:1em'>r</text>
<text text-anchor='middle' x='24' y='4' fill='currentColor' style='font-size:1em'>o</text>
<text text-anchor='middle' x='32' y='4' fill='currentColor' style='font-size:1em'>r</text>
<text text-anchor='middle' x='40' y='4' fill='currentColor' style='font-size:1em'>[</text>
<text text-anchor='middle' x='48' y='4' fill='currentColor' style='font-size:1em'>E</text>
<text text-anchor='middle' x='56' y='4' fill='currentColor' style='font-size:1em'>0</text>
<text text-anchor='middle' x='64' y='4' fill='currentColor' style='font-size:1em'>2</text>
<text text-anchor='middle' x='72' y='4' fill='currentColor' style='font-size:1em'>7</text>
<text text-anchor='middle' x='80' y='4' fill='currentColor' style='font-size:1em'>7</text>
<text text-anchor='middle' x='88' y='4' fill='currentColor' style='font-size:1em'>]</text>
<text text-anchor='middle' x='96' y='4' fill='currentColor' style='font-size:1em'>:</text>
<text text-anchor='middle' x='112' y='4' fill='currentColor' style='font-size:1em'>t</text>
<text text-anchor='middle' x='120' y='4' fill='currentColor' style='font-size:1em'>h</text>
<text text-anchor='middle' x='128' y='4' fill='currentColor' style='font-size:1em'>e</text>
<text text-anchor='middle' x='144' y='4' fill='currentColor' style='font-size:1em'>t</text>
<text text-anchor='middle' x='152' y='4' fill='currentColor' style='font-size:1em'>r</text>
<text text-anchor='middle' x='160' y='4' fill='currentColor' style='font-size:1em'>a</text>
<text text-anchor='middle' x='168' y='4' fill='currentColor' style='font-size:1em'>i</text>
<text text-anchor='middle' x='176' y='4' fill='currentColor' style='font-size:1em'>t</text>
<text text-anchor='middle' x='192' y='4' fill='currentColor' style='font-size:1em'>b</text>
<text text-anchor='middle' x='200' y='4' fill='currentColor' style='font-size:1em'>o</text>
<text text-anchor='middle' x='208' y='4' fill='currentColor' style='font-size:1em'>u</text>
<text text-anchor='middle' x='216' y='4' fill='currentColor' style='font-size:1em'>n</text>
<text text-anchor='middle' x='224' y='4' fill='currentColor' style='font-size:1em'>d</text>
<text text-anchor='middle' x='240' y='4' fill='currentColor' style='font-size:1em'>`</text>
<text text-anchor='middle' x='248' y='4' fill='currentColor' style='font-size:1em'>M</text>
<text text-anchor='middle' x='256' y='4' fill='currentColor' style='font-size:1em'>y</text>
<text text-anchor='middle' x='264' y='4' fill='currentColor' style='font-size:1em'>V</text>
<text text-anchor='middle' x='272' y='4' fill='currentColor' style='font-size:1em'>i</text>
<text text-anchor='middle' x='280' y='4' fill='currentColor' style='font-size:1em'>e</text>
<text text-anchor='middle' x='288' y='4' fill='currentColor' style='font-size:1em'>w</text>
<text text-anchor='middle' x='296' y='4' fill='currentColor' style='font-size:1em'>:</text>
<text text-anchor='middle' x='312' y='4' fill='currentColor' style='font-size:1em'>R</text>
<text text-anchor='middle' x='320' y='4' fill='currentColor' style='font-size:1em'>e</text>
<text text-anchor='middle' x='328' y='4' fill='currentColor' style='font-size:1em'>n</text>
<text text-anchor='middle' x='336' y='4' fill='currentColor' style='font-size:1em'>d</text>
<text text-anchor='middle' x='344' y='4' fill='currentColor' style='font-size:1em'>e</text>
<text text-anchor='middle' x='352' y='4' fill='currentColor' style='font-size:1em'>r</text>
<text text-anchor='middle' x='360' y='4' fill='currentColor' style='font-size:1em'>`</text>
<text text-anchor='middle' x='376' y='4' fill='currentColor' style='font-size:1em'>i</text>
<text text-anchor='middle' x='384' y='4' fill='currentColor' style='font-size:1em'>s</text>
<text text-anchor='middle' x='400' y='4' fill='currentColor' style='font-size:1em'>n</text>
<text text-anchor='middle' x='408' y='4' fill='currentColor' style='font-size:1em'>o</text>
<text text-anchor='middle' x='416' y='4' fill='currentColor' style='font-size:1em'>t</text>
<text text-anchor='middle' x='432' y='4' fill='currentColor' style='font-size:1em'>s</text>
<text text-anchor='middle' x='440' y='4' fill='currentColor' style='font-size:1em'>a</text>
<text text-anchor='middle' x='448' y='4' fill='currentColor' style='font-size:1em'>t</text>
<text text-anchor='middle' x='456' y='4' fill='currentColor' style='font-size:1em'>i</text>
<text text-anchor='middle' x='464' y='4' fill='currentColor' style='font-size:1em'>s</text>
<text text-anchor='middle' x='472' y='4' fill='currentColor' style='font-size:1em'>f</text>
<text text-anchor='middle' x='480' y='4' fill='currentColor' style='font-size:1em'>i</text>
<text text-anchor='middle' x='488' y='4' fill='currentColor' style='font-size:1em'>e</text>
<text text-anchor='middle' x='496' y='4' fill='currentColor' style='font-size:1em'>d</text>
</g>

    </svg>
  
</div>
<p>Your view implements one crate&rsquo;s <code>Render</code> trait; <code>open_window</code> and <code>Root::new</code> are asking for the other&rsquo;s. The dependencies compiled fine, so the error points at code you just wrote. The fix is to keep the pair matched: same channel for <code>gpui</code> and <code>gpui-component</code>, with a committed <code>Cargo.lock</code> for reproducibility.</p>
<p>The alternative single-dependency shortcut is <code>gpui-kit</code>, which bundles GPUI, the platform entry point, <code>gpui-component</code>, and assets in one crate — the simplest possible <code>Cargo.toml</code> for a first app.</p>
<h2 id="what-does-the-minimal-gpui-app-look-like">What Does the Minimal GPUI App Look Like?</h2>
<p>GPUI exposes three registers: entity state (<code>Entity&lt;T&gt;</code> + <code>Context&lt;T&gt;</code>), high-level declarative views (<code>impl Render</code> returning a <code>div()</code> tree with a Tailwind-shaped builder API), and low-level imperative <code>Element</code>s for custom layout and virtualised lists. Actions are user-defined structs that map keystrokes to typed operations, and an async executor is integrated with the platform event loop.</p>
<p>The canonical skeleton is small:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span>Application::new().run(<span style="color:#f92672">|</span>cx: <span style="color:#66d9ef">&amp;</span><span style="color:#a6e22e">mut</span> App<span style="color:#f92672">|</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">let</span> bounds <span style="color:#f92672">=</span> Bounds::centered(None, size(px(<span style="color:#ae81ff">900.</span>), px(<span style="color:#ae81ff">600.</span>)), cx);
</span></span><span style="display:flex;"><span>    cx.open_window(
</span></span><span style="display:flex;"><span>        WindowOptions {
</span></span><span style="display:flex;"><span>            window_bounds: Some(WindowBounds::Windowed(bounds)),
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">..</span>Default::default()
</span></span><span style="display:flex;"><span>        },
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">|</span>_, cx<span style="color:#f92672">|</span> cx.new(<span style="color:#f92672">|</span>_<span style="color:#f92672">|</span> HelloWorld { text: <span style="color:#e6db74">&#34;World&#34;</span>.into() }),
</span></span><span style="display:flex;"><span>    ).unwrap();
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><p>Two rules make or break this step:</p>
<ol>
<li><strong>Call <code>gpui_component::init(cx)</code> before opening any window.</strong> Skip it and <code>cx.theme()</code> panics.</li>
<li><strong><code>Root::new</code> must be the literal top layer</strong> of your element tree, not nested inside a wrapper.</li>
</ol>
<h2 id="how-do-you-manage-state-without-a-browser">How Do You Manage State Without a Browser?</h2>
<p>There is no virtual DOM and no reconciler. You have entities, contexts, and notifications:</p>
<ul>
<li><code>Entity&lt;T&gt;</code> holds state; <code>Context&lt;T&gt;</code> gives you a handle to mutate it.</li>
<li><code>cx.notify()</code> marks <strong>that entity</strong> dirty and schedules a repaint.</li>
<li><code>cx.spawn()</code> runs async work on the GPUI executor without blocking the UI thread.</li>
<li><code>cx.subscribe()</code> / <code>cx.emit()</code> wire entities to each other as event streams.</li>
</ul>
<p>Four performance rules that follow directly:</p>
<ul>
<li>Never block the UI thread on I/O. Apply the state, call <code>cx.notify()</code> immediately, persist in the background.</li>
<li>Notify the <strong>smallest</strong> entity that owns the change — a repaint of the root for a status-dot change is waste.</li>
<li>Render large collections with <code>uniform_list</code> / <code>list</code>, never a flex column of N children.</li>
<li>Filter in memory; do not re-query on every keystroke.</li>
</ul>
<h2 id="how-do-you-stream-agent-output-without-wrecking-the-frame-budget">How Do You Stream Agent Output Without Wrecking the Frame Budget?</h2>
<p>Push, do not poll. This is the single highest-value lesson in the public record.</p>
<p>Paneflow measured <strong>6–8 unnecessary repaints per second</strong> while idle, caused by a 500ms port scan plus a 2-second working-directory poll. Migrating to <code>EventEmitter</code> with <code>cx.subscribe</code> / <code>cx.emit</code> took idle repaints to zero. As its author put it: &ldquo;GPUI&rsquo;s diff repaint is cheap, but it isn&rsquo;t free.&rdquo;</p>
<p>The architecture that actually works:</p>
<ol>
<li><strong>Model updates as events, not timers.</strong> A token arrives → emit → subscribed entity appends to its buffer → <code>cx.notify()</code> on that entity only.</li>
<li><strong>Coalesce aggressively.</strong> Paneflow drains terminal wakeups for up to <strong>4ms or 100 events</strong>, then issues exactly <strong>one <code>cx.update()</code> and one <code>cx.notify()</code></strong> per batch. Your token stream should behave the same way: accumulate, then repaint once per frame.</li>
<li><strong>Respect terminal output semantics.</strong> Honour DEC 2026 synchronized output (Paneflow tracks <code>sync_bytes_count()</code>), so a busy TUI cannot generate hundreds of wakeups per frame.</li>
<li><strong>Never call <code>cx.notify()</code> from a <code>setInterval</code>-shaped loop.</strong> That is literally the pattern GPUI was designed to replace.</li>
</ol>
<table>
  <thead>
      <tr>
          <th>Anti-pattern</th>
          <th>Measured cost</th>
          <th>Fix</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>500ms port-scan timer</td>
          <td>part of 6–8 idle repaints/sec</td>
          <td>event-driven port state</td>
      </tr>
      <tr>
          <td>2s CWD poll</td>
          <td>part of 6–8 idle repaints/sec</td>
          <td>OSC 7 title/CWD parsing in the reader thread</td>
      </tr>
      <tr>
          <td>One <code>cx.notify()</code> per token</td>
          <td>frames dominated by diff churn</td>
          <td>batch: 4ms / 100 events, one notify</td>
      </tr>
      <tr>
          <td>Repainting the root entity</td>
          <td>repaint of the whole tree</td>
          <td>notify the smallest owning entity</td>
      </tr>
  </tbody>
</table>
<h2 id="how-do-you-embed-a-real-terminal-instead-of-a-fake-log-view">How Do You Embed a Real Terminal Instead of a Fake Log View?</h2>
<p>Pin <code>alacritty_terminal</code> (0.26.0, updated 2026-04-06; 1,761,005 all-time downloads, 1,013,303 in 90 days) and drive it yourself. Paneflow migrated off Zed&rsquo;s internal fork as soon as 0.26 landed on crates.io, and the shape of its implementation is worth copying:</p>
<ul>
<li>Two hand-rolled detached threads rather than alacritty&rsquo;s <code>EventLoop::spawn</code>: a <code>pty_reader_loop</code> with a <strong>4096-byte buffer</strong> plus OSC 7 / 133 / XTVersion scanners, and a <code>pty_message_loop</code> for input and resize.</li>
<li>A GPUI-side loop that drains wakeups for up to 4ms (maximum 100 events) and then does one <code>cx.update()</code> + one <code>cx.notify()</code>.</li>
<li>The VTE processor advances against the locked <code>Term</code> on the reader thread; the UI reads a snapshot.</li>
</ul>
<p>The reason to hand-roll this rather than use a shortcut: GPUI owns glyph rasterization end-to-end — shaping, atlasing, GPU draw. There is no &ldquo;bring your own text path,&rdquo; so the boundary between your PTY thread and your <code>Entity&lt;T&gt;</code> is your responsibility.</p>
<h2 id="how-do-you-give-agents-a-control-plane">How Do You Give Agents a Control Plane?</h2>
<p>A GUI that merely displays subprocess output is a viewer. A GUI that participates needs a protocol. Paneflow&rsquo;s answer is a local JSON-RPC 2.0 endpoint over a Unix socket at <code>$XDG_RUNTIME_DIR/paneflow/paneflow.sock</code> (a named pipe on Windows), with a 5-second dispatch timeout and an <code>ai.*</code> namespace — <code>session_start</code>, <code>prompt_submit</code>, <code>tool_use</code>, <code>notification</code>, <code>stop</code>, <code>session_end</code> — so a CLI agent can announce its own lifecycle to the sidebar.</p>
<p>Two details matter for correctness:</p>
<ul>
<li>The socket thread cannot touch <code>Entity&lt;T&gt;</code>. Dispatch to the GPUI main thread through an <code>mpsc</code> one-shot response channel with a timeout, or you will deadlock the executor.</li>
<li>Use an explicit <code>ai.*</code> namespace rather than a generic <code>send</code> call. The UI can then render agent state (thinking / waiting / stalled / done) from typed events instead of guessing from stdout.</li>
</ul>
<p>This is also where a GPUI app can beat a web app: because you own the process tree, you can surface permissions, sandbox scope, and pending approvals as first-class UI, not as a chat message the user has to scroll back to find.</p>
<h2 id="how-do-you-get-the-look-right">How Do You Get the Look Right?</h2>
<p>Steal the component kit instead of rebuilding the DOM. <code>gpui-component</code> (the open core of <code>longbridge/gpui-kit</code>, 952 forks) ships <strong>75+ documented components and primitives</strong> across three layers: styled <code>gpui-component</code> widgets, unstyled behaviour in <code>gpui-base</code>, and <code>gpui-shell</code> JS extensions for a Rust host. It has powered the shipped Longbridge Pro commercial desktop app from day one, and it drew 515 points and 218 comments on Hacker News.</p>
<p>For an agent app specifically, three of its solved problems matter most:</p>
<ul>
<li><strong>Virtual lists</strong> that render only the visible range, including variable-height items — your transcript will outgrow any flex column.</li>
<li><strong>A code editor widget</strong> that stays stable at 200K lines with Tree-sitter highlighting and LSP diagnostics, completion, and hover.</li>
<li><strong>A serializable dock layout</strong> with resizable panels, draggable tabs, and nested splits — persisted as data, not markup.</li>
</ul>
<p>Also included: native Markdown/HTML rendering, a data table good for hundreds of thousands of rows, and UI integration testing with headless windows, synthetic pointer/keyboard input, and assertions on state, focus, layout, and accessibility.</p>
<p>Vendor claims to attribute rather than assert: &ldquo;120 FPS&rdquo; and &ldquo;60–80% less memory than equivalent Electron apps&rdquo; both come from Longbridge&rsquo;s blog. Similarly, the widely repeated &ldquo;47–71% lower idle memory&rdquo; and &ldquo;8.2MB vs 121.7MB installer&rdquo; figures are single-author blog benchmarks of Tauri versus Electron, not GPUI measurements.</p>
<h3 id="where-does-clicking-break">Where does clicking break?</h3>
<p>Element IDs. GPUI&rsquo;s <code>on_click</code> requires the <strong>same element ID between mouse-down and mouse-up</strong>. If your render function derives IDs from a render-time counter, every frame creates what GPUI sees as a brand-new element, and <code>on_click</code> never fires while <code>on_mouse_down</code> does. Use user-provided or deterministically derived stable IDs.</p>
<h3 id="what-else-trips-people-up">What else trips people up?</h3>
<p>A digest of the sharp edges, all of them real:</p>
<ul>
<li><code>.with_assets(gpui_component_assets::Assets)</code> is mandatory, or icons render blank.</li>
<li>Never set <code>.selected()</code> on a list item inside <code>render_item</code> — <code>ListState</code> owns selection.</li>
<li><code>.searchable(true)</code> lives on <code>ListState</code>, not on the element.</li>
<li>There is no <code>Modal</code>; use <code>Dialog</code>.</li>
<li><code>Input</code> is a stateful <code>InputState</code> entity, not a value.</li>
<li><code>KeyBinding::new</code> panics on malformed key strings.</li>
<li><code>cargo-bundle</code> 0.11 trips &ldquo;No matching IconType&rdquo; on a lone 1024px PNG.</li>
</ul>
<p>And when a visual artifact survives several plausible fixes, suspect your data, not your math. Paneflow spent six GPU-math fixes chasing gaps between block characters (U+2580–259F); the actual cause was 11 missing codepoints in its coverage table.</p>
<h2 id="how-do-you-package-and-ship-it">How Do You Package and Ship It?</h2>
<p>Packaging is where weekend projects die, and the fix is to make it declarative. A <code>[package.metadata.bundle]</code> block read by <code>cargo-bundle</code> produces a double-clickable app, and signing/notarization stays opt-in.</p>
<p>Three things surprise people:</p>
<ol>
<li><strong>Dock icon and app label are bundle-only.</strong> Running <code>cargo run</code> shows the binary name and no icon; only the bundle carries identity.</li>
<li><strong>Signing needs money.</strong> A frictionless macOS install requires a paid Apple Developer ID (<code>codesign</code> + <code>xcrun notarytool</code>). Linux ships as <code>.deb</code> / <code>.rpm</code> / <code>.AppImage</code>, Windows as <code>.msi</code> / <code>.exe</code>.</li>
<li><strong>Auto-update is thin.</strong> Waku reports Sparkle binary deltas on macOS; muxel ships in-app updates. There is no widely adopted cross-platform updater for GPUI apps to recommend.</li>
</ol>
<p>Also set expectations on size. The commonly blogged claim that &ldquo;Zed is ~30MB&rdquo; is false: Zed&rsquo;s actual v1.22.0 assets, published 2026-09-30, are <code>Zed-aarch64.dmg</code> at 112MB, <code>zed-linux-x86_64.tar.gz</code> at 119MB, and <code>Zed-aarch64.exe</code> at 68MB. GPUI gives you a smaller footprint than Electron because there is no Chromium and no Node runtime — not because a real app with fonts and assets is a 10MB artifact. Judge by idle CPU, idle RAM, and startup latency instead.</p>
<h2 id="how-do-you-survive-pre-10-churn">How Do You Survive Pre-1.0 Churn?</h2>
<p>GPUI&rsquo;s own README warns plainly: &ldquo;There will often be breaking changes between versions.&rdquo; Budget for it mechanically rather than hoping.</p>
<p>The encouraging data point: the bump from the Oct-2025 snapshot to mid-2026 cost one project exactly <strong>four one-line edits</strong> — <code>Application::new()</code> became <code>gpui_platform::application()</code>, <code>Menu</code> gained a <code>disabled</code> field, and <code>window.focus()</code> gained a <code>cx</code> argument. That is a small, boring diff if your tree is small.</p>
<p>Pin your way to stability:</p>
<ul>
<li>Commit <code>Cargo.lock</code>. Reproducibility on the git channel comes from the lockfile, not from a version range.</li>
<li>Pin the toolchain with <code>rust-toolchain.toml</code>.</li>
<li>Keep the <code>gpui</code> / <code>gpui_platform</code> / <code>gpui-component</code> triple on one channel.</li>
<li>Isolate UI code from agent logic, so a framework bump never touches your PTY or protocol layer.</li>
</ul>
<h2 id="what-should-your-first-week-look-like">What Should Your First Week Look Like?</h2>
<p>A realistic sequence, in order:</p>
<ol>
<li>Build the skeleton window with a matched dependency pair and a committed lockfile. Confirm it compiles before writing any agent code.</li>
<li>Add <code>gpui_component::init(cx)</code> and <code>.with_assets(...)</code>; verify the theme and icons render.</li>
<li>Implement the agent loop as an <code>Entity&lt;T&gt;</code> with <code>EventEmitter</code>; drive it from a fake token source and confirm idle repaints stay at zero.</li>
<li>Add the PTY pane with <code>alacritty_terminal</code>, coalescing wakeups at 4ms / 100 events.</li>
<li>Add session persistence on <code>CloseWindow</code> — before it can hurt you.</li>
<li>Add the JSON-RPC control plane and render agent lifecycle state in the sidebar.</li>
<li>Only then do bundling, icons, and the first signed build.</li>
</ol>
<p>One step deserves special emphasis. Paneflow shipped without session persistence, opened four workspaces, rebuilt the binary, and lost all four. Save state to something like <code>~/.cache/yourapp/session.json</code> on <code>CloseWindow</code>. It is the cheapest credibility you can buy, and the alternative is that the first real user experience your app delivers is data loss.</p>
<h2 id="faq">FAQ</h2>
<p><strong>Is crates.io <code>gpui</code> the real Zed framework or a fork?</strong>
It is Zed&rsquo;s own official crate — repository <code>zed-industries/zed</code>, homepage <code>gpui.rs</code>. The catch is staleness, not provenance. The whole 0.2.x line shipped in October 2025 (0.2.0 on 2025-10-09, 0.2.2 on 2025-10-22) with nothing published since, so it lags Zed main by months. Zed main has also split the framework into <code>gpui</code>, <code>gpui_platform</code>, <code>gpui_web</code>, and <code>gpui_macros</code>, and none of those split crates are on crates.io yet.</p>
<p><strong>Why do I get E0277 trait-mismatch errors when both crates compile?</strong>
You are mixing two GPUI lineages. A published <code>gpui-component</code> is built against one specific <code>gpui</code>; if your manifest pulls a different one — <code>gpui-unofficial</code>, a mismatched git revision, or a crates.io/git mix — both dependencies compile independently, but your <code>Render</code> impl satisfies one crate&rsquo;s <code>Render</code> trait while <code>open_window</code> and <code>Root::new</code> expect the other&rsquo;s. Keep the pair matched and commit the lockfile.</p>
<p><strong>Do I need a webview at all?</strong>
No. GPUI has no webview and no JavaScript bridge: your UI is Rust, and GPUI owns text shaping, atlasing, and GPU draw end-to-end. If you want React ergonomics anyway, GPUIX (remorses/gpuix, 2,495 stars) paints a React tree from TypeScript into GPUI through napi on desktop or wasm-bindgen in the browser — instead of into the DOM.</p>
<p><strong>How do I stream tokens without killing the frame rate?</strong>
Push, don&rsquo;t poll. Model updates as events with <code>EventEmitter</code> plus <code>cx.subscribe</code> / <code>cx.emit</code>, accumulate tokens into the receiving entity&rsquo;s state, and coalesce: Paneflow drains wakeups for up to 4ms or 100 events and then performs exactly one <code>cx.update()</code> and one <code>cx.notify()</code> for the batch. A timer-shaped <code>cx.notify()</code> loop is the exact pattern GPUI was built to replace — one project measured 6–8 wasted repaints per second from it.</p>
<p><strong>How big is the first build, and will my app be tiny?</strong>
The first build compiles GPUI (and wgpu on Linux) from source: roughly 5–15 minutes, once, after which rebuilds are fast. You need a recent Rust stable because gpui HEAD uses just-stabilized standard-library APIs — Zed pins 1.95.0 and Paneflow pins 1.96.1 via <code>rust-toolchain.toml</code>. Your app will be smaller than an Electron one (no Chromium, no Node), but do not expect a 10MB artifact: Zed&rsquo;s own v1.22.0 installers are 112MB for macOS and 119MB for Linux, because that is fonts, assets, and a full editor.</p>
]]></content:encoded></item></channel></rss>