<?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: Arthur Jean</title>
    <description>The latest articles on DEV Community by Arthur Jean (@arthurj-dev).</description>
    <link>https://dev.to/arthurj-dev</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%2F3942560%2F0981145d-c719-4424-b9ab-bc895955113a.png</url>
      <title>DEV Community: Arthur Jean</title>
      <link>https://dev.to/arthurj-dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/arthurj-dev"/>
    <language>en</language>
    <item>
      <title>Hardening the subsystem that runs your CLI agents (Paneflow v0.3.4)</title>
      <dc:creator>Arthur Jean</dc:creator>
      <pubDate>Fri, 29 May 2026 07:42:56 +0000</pubDate>
      <link>https://dev.to/arthurj-dev/hardening-the-subsystem-that-runs-your-cli-agents-paneflow-v034-12kd</link>
      <guid>https://dev.to/arthurj-dev/hardening-the-subsystem-that-runs-your-cli-agents-paneflow-v034-12kd</guid>
      <description>&lt;p&gt;Paneflow is a terminal multiplexer and AI agents IDE written in pure Rust on top of Zed's GPUI. The pitch is one line: a Rust native host for your CLI agents (Claude Code, Codex, OpenCode) is leaner than the Electron app running the same agents.&lt;/p&gt;

&lt;p&gt;That claim only holds if the thing survives an all day session, not a 5 minute demo. v0.3.4 is the release where I went and made it hold. Two audits back to back across four axes (memory, security, performance, robustness) on the agent subsystem, roughly 49 fixes.&lt;/p&gt;

&lt;p&gt;This is the engineering writeup: what was broken, the actual code, and the number that proves the fix. Every finding cites a file and a line. If I can't point at the code, it's not a finding.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules I held the whole way
&lt;/h2&gt;

&lt;p&gt;Three constraints, not negotiable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No new &lt;code&gt;.unwrap()&lt;/code&gt; / &lt;code&gt;.expect()&lt;/code&gt;.&lt;/strong&gt; The project runs &lt;code&gt;panic = deny&lt;/code&gt; on the clippy gate, so a careless panic fails the build, not review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No new abstraction layers.&lt;/strong&gt; Hardening that grows the codebase usually just relocates the bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every perf claim is reproducible.&lt;/strong&gt; There is a heaptrack runbook in the repo and Criterion baselines for the hot paths. "38x faster" is a command you can run, not a number I picked.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Performance: the O(n2) in the markdown streaming path
&lt;/h2&gt;

&lt;p&gt;This is the one worth reading.&lt;/p&gt;

&lt;p&gt;Agent responses stream in as chunks. Each chunk gets appended to a &lt;code&gt;markdown::Markdown&lt;/code&gt; widget for live rendering. The append in Zed's widget looks like this (&lt;code&gt;crates/markdown/src/markdown.rs:588&lt;/code&gt; upstream):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;SharedString&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.source&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that carefully. Every append clones the entire accumulated source, concatenates, and reallocates. For a response that arrives in N chunks, you pay 1 + 2 + 3 + ... + N copies. That is O(n2) over the length of the response. On five agents streaming at once, this burned 30 to 50 percent of a core copying text that had not changed.&lt;/p&gt;

&lt;p&gt;The fix has two parts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part 1, root cause, in a fork.&lt;/strong&gt; I patched the widget to accumulate into a &lt;code&gt;String&lt;/code&gt; buffer with &lt;code&gt;push_str&lt;/code&gt; instead of rebuilding a &lt;code&gt;SharedString&lt;/code&gt; on every call. One additive field, no API change. The Criterion bench inside the fork:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;markdown_append/pre_fix_concat     ~94.4 us
markdown_append/post_fix_buffered  ~2.44 us
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;About 38x on that path. Nine downstream Zed consumers of the widget compile unchanged. Paneflow builds against &lt;code&gt;ArthurDEV44/zed@paneflow/markdown-append-fix&lt;/code&gt; (pinned by exact sha in &lt;code&gt;Cargo.lock&lt;/code&gt;, see &lt;code&gt;src-app/Cargo.toml:47-55&lt;/code&gt;) and reverts to upstream the moment the PR merges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part 2, bound the call rate, in app code.&lt;/strong&gt; Even with an O(1) append, calling it at 60 Hz for a long response is wasteful. The streaming tick is now adaptive (&lt;code&gt;src-app/src/agents/thread_view.rs:44-66&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;STREAMING_TICK_FAST&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_millis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// 60 Hz, &amp;lt; 4 KB&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;STREAMING_TICK_MEDIUM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_millis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// 20 Hz, past 4 KB&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;STREAMING_TICK_SLOW&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_millis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// ~7 Hz, past 16 KB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Long responses slow their repaint cadence as they grow. The eye does not notice 7 Hz on a 16 KB wall of text; the CPU does.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two smaller perf wins
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;highlight_code&lt;/code&gt; is memoized.&lt;/strong&gt; Syntax highlighting via syntect ran synchronously inside &lt;code&gt;render&lt;/code&gt;, 3 to 8 ms per visible code block per frame. It is now cached by language and a hash of the content (&lt;code&gt;src-app/src/agents/message_render.rs:1056-1121&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;highlight_cache_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;u64&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;src_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;djb2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;lang_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lang&lt;/span&gt;&lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;djb2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.unwrap_or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;src_hash&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="n"&gt;lang_hash&lt;/span&gt;&lt;span class="nf"&gt;.rotate_left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;13&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="n"&gt;HIGHLIGHT_CACHE_CAP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a warm cache, per frame cost drops from ~150 to 400 ms (3 to 8 ms across 50 blocks) to a HashMap lookup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;compute_activity_state&lt;/code&gt; scans less.&lt;/strong&gt; Honest framing here: this is not O(1), and the audit's original note that called it that was wrong. The function used to scan the full &lt;code&gt;items&lt;/code&gt; Vec (user messages, assistant chunks, tool calls) on every &lt;code&gt;cx.notify()&lt;/code&gt;. It now walks a parallel &lt;code&gt;Vec&amp;lt;usize&amp;gt;&lt;/code&gt; of tool call indices only (&lt;code&gt;src-app/src/agents/thread_view.rs:233-241&lt;/code&gt;). A thread of 500 items with 200 tool calls scans 200 positions instead of 500. Still O(N), just over a smaller N. 2 to 5x on a typical thread. I am keeping the accurate number, not the flattering one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory: bounded footprint on long sessions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Diff bodies are freed after review.&lt;/strong&gt; A &lt;code&gt;DiffSnapshot&lt;/code&gt; held every edit's original &lt;code&gt;old_text&lt;/code&gt; for the full thread lifetime. A refactor session of 500 turns (50 KB files, 20 edits each) retained 10 to 100 MB of file content for edits you had already reviewed and would never look at again. Now the body is dropped on review completion and the renderer shows a placeholder (&lt;code&gt;src-app/src/agents/edit_tool_block.rs:345-363&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="py"&gt;.cleared_diff_lines&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;col&lt;/span&gt;
        &lt;span class="nf"&gt;.child&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"[diff body cleared after review, {n} lines]"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="nf"&gt;.into_any_element&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 heaptrack target for this fix is under 5 MB delta, down from 50 to 200 MB on the reference scenario.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per tool call UI state is pruned.&lt;/strong&gt; &lt;code&gt;tool_label_markdown&lt;/code&gt; (an &lt;code&gt;Entity&amp;lt;Markdown&amp;gt;&lt;/code&gt; per tool call) and &lt;code&gt;diff_scroll_handles&lt;/code&gt; used to be cleared only on Keep All / Reject All. Read, Search, and Execute calls leaked GPUI registry entries for the whole thread. They are now purged the moment each call hits a terminal state (&lt;code&gt;src-app/src/agents/thread_view.rs:1083-1085&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caches are bounded.&lt;/strong&gt; The session cache was a HashMap global to the process with no cap; switching across 20+ project directories grew it monotonically. Now capped at 10 LRU entries (&lt;code&gt;src-app/src/agent_sessions.rs:52&lt;/code&gt;). The composer's &lt;code&gt;pending_prompts&lt;/code&gt; queue is bounded by both count and bytes: 8 prompts, 80 MiB (&lt;code&gt;src-app/src/agents/composer_ext.rs:53,64&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Security: the threat model is "something the agent touched"
&lt;/h2&gt;

&lt;p&gt;An AI IDE reads files written by other tools all day. The relevant threat is not "the user is malicious," it is "the input the agent produced or read is hostile."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JSONL parsers are capped at 64 KiB per line.&lt;/strong&gt; A malicious 500 MB single line JSONL planted in &lt;code&gt;~/.claude/projects/&amp;lt;slug&amp;gt;/&lt;/code&gt; used to OOM the process on read. Both session readers now cap (&lt;code&gt;src-app/src/claude_sessions.rs:39&lt;/code&gt;, &lt;code&gt;src-app/src/codex_sessions.rs:34&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;MAX_LINE_BYTES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An oversized line is detected and the session is skipped, not panicked on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Rust 1.85 env race.&lt;/strong&gt; Rust 1.85 made &lt;code&gt;std::env::remove_var&lt;/code&gt; &lt;code&gt;unsafe&lt;/code&gt; because it races concurrent &lt;code&gt;getenv&lt;/code&gt; from other threads. Paneflow scrubbed the &lt;code&gt;CLAUDECODE&lt;/code&gt; env var, and it was doing it from the wrong place. It now runs at the top of &lt;code&gt;main()&lt;/code&gt; before any thread or runtime exists (&lt;code&gt;src-app/src/main.rs:1023-1031&lt;/code&gt;, &lt;code&gt;crates/paneflow-acp/src/spawn.rs:67-72&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// SAFETY: called from main() before any thread::spawn or async&lt;/span&gt;
&lt;span class="c1"&gt;// runtime init, no concurrent getenv possible.&lt;/span&gt;
&lt;span class="k"&gt;unsafe&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;remove_var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CLAUDECODE_ENV&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 only race free place to mutate process env is before you have a second thread.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;surface.send_text&lt;/code&gt; was an undocumented same UID RCE primitive.&lt;/strong&gt; The IPC method that injects text into a pane could drive any agent or shell. It is now gated behind an explicit opt in and documented as such (&lt;code&gt;src-app/src/app/ipc_handler.rs:717-724&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;ipc_scripting_enabled&lt;/span&gt;&lt;span class="p"&gt;()&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;JsonRpcError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;32601&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"surface.send_text disabled; set PANEFLOW_IPC_SCRIPTING=1 to enable"&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="nf"&gt;.into_value&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;Even when enabled, payloads are capped at 64 KiB.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API keys are redacted before they hit the log.&lt;/strong&gt; Under &lt;code&gt;RUST_LOG=trace&lt;/code&gt;, wire lines were logged verbatim. Anything matching &lt;code&gt;sk-...&lt;/code&gt; or &lt;code&gt;*_API_KEY=...&lt;/code&gt; is now scrubbed before &lt;code&gt;trace_wire_line&lt;/code&gt;, with a zero allocation fast path for the common case (&lt;code&gt;crates/paneflow-acp/src/spawn.rs:117-130&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;workspace.create&lt;/code&gt; canonicalizes its cwd.&lt;/strong&gt; Relative traversal, NUL bytes, and passing a regular file as the cwd are rejected before use (&lt;code&gt;src-app/src/app/ipc_handler.rs:1241-1254&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kill on parent death, cross platform.&lt;/strong&gt; If Paneflow dies, the agent CLIs it spawned should not survive as orphans. Linux uses &lt;code&gt;PR_SET_PDEATHSIG&lt;/code&gt; in the shim's &lt;code&gt;pre_exec&lt;/code&gt; (&lt;code&gt;crates/paneflow-shim/src/main.rs:273-319&lt;/code&gt;), Windows uses a &lt;code&gt;JobObject&lt;/code&gt; that kills on close (&lt;code&gt;src-app/src/agents/parent_guard.rs:48-55&lt;/code&gt;), macOS is a documented no op pending a &lt;code&gt;kqueue&lt;/code&gt; hook. The shim's self exclusion check uses the Unix &lt;code&gt;(dev, ino)&lt;/code&gt; inode identity instead of comparing path strings, which a symlink can defeat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The breaking change.&lt;/strong&gt; &lt;code&gt;claude_code_bypass_permissions&lt;/code&gt; now defaults to &lt;code&gt;false&lt;/code&gt; (&lt;code&gt;crates/paneflow-config/src/loader.rs:696&lt;/code&gt;). On a fresh install the agent asks before each Claude Code tool call. The old default of &lt;code&gt;true&lt;/code&gt; was convenient and a latent vulnerability: per Anthropic's own docs, bypass mode offers no protection against prompt injection. If you scripted around the old behavior, set it back to &lt;code&gt;true&lt;/code&gt; explicitly. You opt in to the loaded gun now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Robustness: no panics under resource exhaustion
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The IPC thread no longer takes the app down.&lt;/strong&gt; It used to &lt;code&gt;.expect()&lt;/code&gt; on spawn. Under &lt;code&gt;RLIMIT_NPROC&lt;/code&gt; exhaustion or &lt;code&gt;EAGAIN&lt;/code&gt; on a fork bombed host, that panicked the GPUI main thread and killed every live agent. Now it degrades (&lt;code&gt;src-app/src/ipc.rs:219-340&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spawn_result&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="nf"&gt;.disable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nn"&gt;tracing&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;error!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"IPC disabled: paneflow-ipc thread spawn failed: {e}. &lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;
                     Check `ulimit -u` / container thread limits."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// tx dropped -&amp;gt; consumer sees Disconnected and tolerates it as "no IPC work this tick"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app keeps running without IPC. One feature degrades instead of the whole process dying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;wait_for_exit&lt;/code&gt; has a 30 s deadline.&lt;/strong&gt; A SIGKILL race where the OS reaped the zombie before &lt;code&gt;poll_child_exit&lt;/code&gt; could observe it used to park a &lt;code&gt;spawn_blocking&lt;/code&gt; thread forever. With tokio's blocking pool capped at 128, repeated races leaked the pool over a session (&lt;code&gt;src-app/src/agents/agent_terminal.rs:294-301&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;WAIT_FOR_EXIT_DEADLINE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&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;The runtime event channel is bounded.&lt;/strong&gt; It was &lt;code&gt;futures::channel::mpsc::unbounded&lt;/code&gt; and could accumulate hundreds of &lt;code&gt;RuntimeEvent::Chunk(String)&lt;/code&gt; on a burst (Claude Code doing 200 rapid edits) before the GPUI consumer drained it, spiking 5 to 20 MB. Now it is &lt;code&gt;tokio::sync::mpsc&lt;/code&gt; at capacity 256 with backpressure (&lt;code&gt;src-app/src/agents/runtime.rs:63-85&lt;/code&gt;). While I was there, the runtime command channel moved off a &lt;code&gt;spawn_blocking&lt;/code&gt; + &lt;code&gt;Mutex&lt;/code&gt; + join per command (about 100 us each) to a native async receiver.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Silent error sinks now leave a breadcrumb.&lt;/strong&gt; Six &lt;code&gt;Err(Closed)&lt;/code&gt; branches in the title summarizer used to swallow a dropped update when a window closed mid task, so a generated title or file insertion would just vanish. They now log (&lt;code&gt;src-app/src/agents/title_summarizer.rs:179-263&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mutex poison is recovered, not propagated.&lt;/strong&gt; If a thread panicked while holding the session cache lock, the next lock would panic on the poison in turn. The cache now recovers with &lt;code&gt;into_inner()&lt;/code&gt; and warns (&lt;code&gt;src-app/src/agent_sessions.rs:113-130&lt;/code&gt;). The same path treats a backwards clock step (NTP correction, DST) as a conservative cache miss rather than trusting a negative duration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quality gates
&lt;/h2&gt;

&lt;p&gt;So this does not regress next month:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;cargo deny&lt;/code&gt; in CI.&lt;/strong&gt; A daily security audit cron opens an issue when the lockfile gains a new advisory overnight, complementing the blocking PR gate. Two known transitive advisories are whitelisted with written rationale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Criterion baselines&lt;/strong&gt; for &lt;code&gt;blob_compress&lt;/code&gt;, &lt;code&gt;markdown_append&lt;/code&gt;, and &lt;code&gt;highlight_code&lt;/code&gt;, the three hot paths above, so a regression shows up as a number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A heaptrack runbook&lt;/strong&gt; (&lt;code&gt;tasks/heaptrack-runbook.md&lt;/code&gt;) with the reproducible procedure behind every RAM claim in this post, including the streaming scenario with 5 agents.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The bar
&lt;/h2&gt;

&lt;p&gt;"Leaner than the wrapper" is a marketing line until it survives a real workday. The work in v0.3.4 is what turns it into a property you can measure: bounded memory on long sessions, no main thread panic under resource exhaustion, a locked down IPC surface, and a streaming path that does not melt a core.&lt;/p&gt;

&lt;p&gt;Paneflow is free and open source: &lt;a href="https://github.com/ArthurDEV44/paneflow" rel="noopener noreferrer"&gt;github.com/ArthurDEV44/paneflow&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>ai</category>
      <category>performance</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Building a native terminal for AI coding agents in Rust + GPUI</title>
      <dc:creator>Arthur Jean</dc:creator>
      <pubDate>Wed, 20 May 2026 19:18:52 +0000</pubDate>
      <link>https://dev.to/arthurj-dev/building-a-native-terminal-for-ai-coding-agents-in-rust-gpui-2bg4</link>
      <guid>https://dev.to/arthurj-dev/building-a-native-terminal-for-ai-coding-agents-in-rust-gpui-2bg4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Author: Arthur Jean, solo indie maker. More on &lt;a href="https://paneflow.dev" rel="noopener noreferrer"&gt;paneflow.dev&lt;/a&gt; and &lt;a href="https://arthurjean.com" rel="noopener noreferrer"&gt;arthurjean.com&lt;/a&gt;.&lt;br&gt;
Repo: &lt;a href="https://github.com/ArthurDEV44/paneflow" rel="noopener noreferrer"&gt;github.com/ArthurDEV44/paneflow&lt;/a&gt;. MIT licensed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I spend my days running Claude Code, Codex, and OpenCode in parallel panes. Different agents on different branches, each with their own dev server, all in one terminal. tmux can do this, but every multiplexer I tried treated agents the same as any other shell process: no first-class branch context, no live dev-server detection, no session restore that survives a reboot, no programmatic way for an external tool to drive the editor. I built &lt;a href="https://paneflow.dev" rel="noopener noreferrer"&gt;Paneflow&lt;/a&gt; because I needed it.&lt;/p&gt;

&lt;p&gt;This is a post-mortem, not a launch post. Paneflow is a native terminal workspace, splits, panes, branch-aware workspaces, session restore, built in pure Rust on top of &lt;a href="https://zed.dev" rel="noopener noreferrer"&gt;Zed's GPUI framework&lt;/a&gt; and the upstream &lt;code&gt;alacritty_terminal&lt;/code&gt; crate. It started as a port of &lt;a href="https://github.com/manaflow-ai/cmux" rel="noopener noreferrer"&gt;cmux&lt;/a&gt;, a macOS-only Swift/AppKit project, and the Rust rewrite forced a string of decisions I had no good intuition for at the start. I want to walk through the ones that mattered: which UI frameworks I tried and rejected, how the GPUI/alacritty boundary actually looks, how dev-server detection works under the hood, the N-ary layout tree that replaced binary splits, the cross-platform PTY plumbing, the JSON-RPC control plane that makes agents first-class, and four lessons that surprised me.&lt;/p&gt;

&lt;p&gt;If you take one thing away, take this: for a terminal emulator, the UI framework must own glyph rasterization. Everything else flows from that single constraint.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frr4mowejyh8t4wmtiobn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frr4mowejyh8t4wmtiobn.png" alt="Paneflow showing a multi-workspace sidebar, an in-pane markdown viewer reading a PRD document, and the OpenCode AI agent active in the right pane" width="800" height="466"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a terminal for AI coding agents
&lt;/h2&gt;

&lt;p&gt;VSCode and classic IDEs matter less and less when you work with Claude Code or Codex. The work has shifted: instead of typing code in an editor, I'm directing agents from a shell. One pane runs Claude Code working on a feature branch. Another runs Codex doing a refactor on a second branch. A third tails the dev server. A fourth has notes and a markdown viewer open.&lt;/p&gt;

&lt;p&gt;The terminals we know weren't designed for this. They render a grid, they pipe stdin/stdout, they let you split. They don't know which workspace is on which branch, which pane has a server bound to port 5173, which session restored from yesterday's setup. Every multiplexer treats every pane identically, so the orchestration cognitive load lands entirely on you.&lt;/p&gt;

&lt;p&gt;Paneflow is built exactly for this mode. Workspaces are git-aware: each one knows its branch and the sidebar surfaces it. Dev-server detection scans for Vite, Next.js, Webpack, and others, then resolves their actual listening ports through the kernel, not by trusting whatever the framework printed. AI agent buttons in the tab bar launch Claude Code, Codex, or OpenCode in the active pane with one keystroke. A local JSON-RPC server exposes the editor so external tools can drive workspaces, send keystrokes, or post agent lifecycle events programmatically. Sessions save and restore so closing the app and reopening it tomorrow lands you exactly where you left off.&lt;/p&gt;

&lt;p&gt;Side-by-side comparisons vs cmux, WezTerm, iTerm2, and Warp at &lt;a href="https://paneflow.dev/compare" rel="noopener noreferrer"&gt;paneflow.dev/compare&lt;/a&gt;. The rest of this post is about how the engine works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not Electron, why not Tauri
&lt;/h2&gt;

&lt;p&gt;The first option I evaluated was Electron + xterm.js. The architecture review rejected it immediately: &lt;em&gt;"Electron: heavy memory footprint (contradicts cmux's 'not Electron' philosophy)."&lt;/em&gt; cmux was conceived as a native, low-RAM tool, and the whole point of porting it was to keep that property on Linux and Windows. So Electron was out without debate.&lt;/p&gt;

&lt;p&gt;The second option was &lt;a href="https://tauri.app" rel="noopener noreferrer"&gt;Tauri&lt;/a&gt;, and I genuinely spent time there. I started a real implementation, laid out the architecture, got the first interactions running. On paper it held up: lighter binary than Electron, frontend in whichever stack you want, Rust backend for the sensitive logic, cross-platform distribution with minimal friction. But as I went deeper, two things sharpened up.&lt;/p&gt;

&lt;p&gt;First, the technical blocker. The webview API surface is too narrow for the kind of low-level keyboard, IME, and input grabbing a multiplexer needs. Everything you take for granted in a native terminal (precise key chords without a JS intermediary, clean Asian IME, focus management between panes without a round-trip to the webview) becomes a workaround or a hole. For an editor or a typical productivity app, those trade-offs are acceptable. For a terminal that has to absorb every keystroke at sub-frame latency, they are not.&lt;/p&gt;

&lt;p&gt;Second, and this is what really decided it: I wanted to build something new, not wrap a webview with one more layer on top. When &lt;a href="https://zed.dev" rel="noopener noreferrer"&gt;Zed&lt;/a&gt; started, they could have built on Electron, on Tauri, on GTK or Qt. They chose to write their own framework, &lt;a href="https://github.com/zed-industries/zed/tree/main/crates/gpui" rel="noopener noreferrer"&gt;GPUI&lt;/a&gt;, because no existing option could deliver what they wanted for code editors: dense GPU-accelerated text rendering at 120 fps with sub-frame keystroke-to-pixel latency. The result speaks for itself.&lt;/p&gt;

&lt;p&gt;Zed's bet for IDEs applies word for word to terminals. Not wrap, not lean on a portability layer that flattens every feature to the lowest common denominator, but build on a foundation designed specifically for dense, native, low-latency text rendering. The difference with Zed is that I did not have to write the framework: they had already done it. GPUI owns text rendering end-to-end: shaping, atlasing, GPU draw, the lot. There is no "bring your own text path," there is no webview to work around. Adopting GPUI meant choosing the "dedicated native framework" approach over "webview with a Rust shell." Architectural ambition over ecosystem ease. That was the decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  The GPUI mental model
&lt;/h2&gt;

&lt;p&gt;A two-paragraph briefing so the rest of this post reads cleanly. Mutable state in GPUI lives in &lt;code&gt;Entity&amp;lt;T&amp;gt;&lt;/code&gt;. You mutate through a &lt;code&gt;Context&amp;lt;T&amp;gt;&lt;/code&gt;, you signal observers with &lt;code&gt;cx.notify()&lt;/code&gt;, you spawn async work with &lt;code&gt;cx.spawn()&lt;/code&gt;. Views implement &lt;code&gt;Render&lt;/code&gt; and return a &lt;code&gt;div()&lt;/code&gt; tree built with a Tailwind-shaped builder API. Every observable thing in Paneflow (the app root, the cursor blink phase, every terminal view, every pane, the sidebar) is an &lt;code&gt;Entity&amp;lt;T&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Keyboard input flows through GPUI's &lt;code&gt;actions!&lt;/code&gt; macro, which generates zero-sized typed structs in the &lt;code&gt;paneflow&lt;/code&gt; namespace (&lt;code&gt;SplitHorizontally&lt;/code&gt;, &lt;code&gt;LayoutTiled&lt;/code&gt;, &lt;code&gt;UndoClosePane&lt;/code&gt;, ...) that the framework dispatches through the focus chain. Adding a keybinding is one struct, one handler, one bind. GPUI's repaint is diff-based and dependency-tracked at observation time, so a &lt;code&gt;cx.notify()&lt;/code&gt; only invalidates the rects that subscribed. That's the whole surface area you need to follow what comes next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plugging in &lt;code&gt;alacritty_terminal&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The terminal grid itself is &lt;code&gt;alacritty_terminal = "0.26"&lt;/code&gt; from crates.io (&lt;code&gt;src-app/Cargo.toml:26&lt;/code&gt;). I migrated off Zed's internal fork as soon as 0.26 was released. Staying on a vendored fork of someone else's editor's fork of alacritty was not a sustainable path.&lt;/p&gt;

&lt;p&gt;The shared state is unsurprising: &lt;code&gt;Arc&amp;lt;FairMutex&amp;lt;Term&amp;lt;ZedListener&amp;gt;&amp;gt;&amp;gt;&lt;/code&gt;. &lt;code&gt;ZedListener&lt;/code&gt; is a thin newtype wrapping a futures &lt;code&gt;UnboundedSender&amp;lt;AlacEvent&amp;gt;&lt;/code&gt;. alacritty's &lt;code&gt;Term&lt;/code&gt; calls &lt;code&gt;listener.send_event(event)&lt;/code&gt; whenever the grid mutates; the receiver lives on the GPUI main thread.&lt;/p&gt;

&lt;p&gt;What is surprising is that Paneflow does &lt;strong&gt;not&lt;/strong&gt; use alacritty's &lt;code&gt;EventLoop::spawn()&lt;/code&gt;. That helper is convenient for embedded-terminal-in-an-editor cases, but a multiplexer wants finer control over OSC scanning, synchronized output, and shutdown. Instead, every terminal session runs two hand-rolled detached threads (&lt;code&gt;src-app/src/terminal/pty_loops.rs&lt;/code&gt;):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;pty_reader_loop&lt;/code&gt; reads PTY bytes into a 4096-byte buffer, runs OSC scanners (OSC 7 for CWD, OSC 133 for prompt boundaries, XTVersion for shell identification), advances the VTE &lt;code&gt;Processor&lt;/code&gt; against the locked &lt;code&gt;Term&lt;/code&gt;, and emits a single wakeup when bytes were processed outside a DEC 2026 synchronized-output window.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pty_message_loop&lt;/code&gt; receives &lt;code&gt;Msg&lt;/code&gt; from a &lt;code&gt;PtyNotifier&lt;/code&gt; over &lt;code&gt;std::sync::mpsc&lt;/code&gt; and writes input or resize commands back to the PTY master.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The reader's hot path is roughly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;term&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;term&lt;/span&gt;&lt;span class="nf"&gt;.lock&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;processor&lt;/span&gt;&lt;span class="nf"&gt;.advance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;term&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nf"&gt;drop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;term&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;processor&lt;/span&gt;&lt;span class="nf"&gt;.sync_bytes_count&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;listener&lt;/span&gt;&lt;span class="nf"&gt;.send_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;AlacEvent&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Wakeup&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 check on &lt;code&gt;sync_bytes_count()&lt;/code&gt; is the synchronized-output coalesce: a TUI like neovim or btop announces "I'm about to draw a frame, hold the wakeup until I'm done," and we honor it. Without that check, a busy TUI would generate hundreds of wakeups per logical frame and the GPU would render them all.&lt;/p&gt;

&lt;p&gt;On the GPUI side, the &lt;code&gt;TerminalView&lt;/code&gt;'s &lt;code&gt;cx.spawn&lt;/code&gt; event loop drains the wakeup channel for up to 4 ms (max 100 events), then issues one &lt;code&gt;cx.update()&lt;/code&gt; and one &lt;code&gt;cx.notify()&lt;/code&gt;. That's the keystroke-to-pixel path: shell writes, reader thread, VTE, wakeup, 4 ms batcher, &lt;code&gt;cx.notify()&lt;/code&gt;, GPUI diff repaint, atlas draw. There's a &lt;code&gt;PANEFLOW_LATENCY_PROBE=1&lt;/code&gt; env var on debug builds that traces this path end-to-end, which is the right tool when you suspect the batcher is doing something wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detecting dev servers from &lt;code&gt;/proc/net/tcp&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;One of the things I wanted from the start was a sidebar that knows what's running in each workspace. If a pane is hosting a Vite dev server on port 5173, I want a one-click open. If a Next.js app is on 3000, same. The naive solution is to regex the terminal output: catch "vite dev: &lt;a href="http://localhost:5173" rel="noopener noreferrer"&gt;http://localhost:5173&lt;/a&gt;" when the framework prints it. That works half the time. The other half, the line scrolls off, or the framework prints to stderr in a way the OSC scanner missed, or the user piped it through &lt;code&gt;tee&lt;/code&gt; and the announcement never came back.&lt;/p&gt;

&lt;p&gt;So Paneflow does both. &lt;code&gt;src-app/src/terminal/service_detector.rs&lt;/code&gt; regex-matches a list of 22 framework signatures against the terminal output as a fast, immediate signal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;FRAMEWORKS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"next.js"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Next.js"&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="s"&gt;"turbopack"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Next.js"&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="s"&gt;"vite"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Vite"&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="s"&gt;"nuxt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Nuxt"&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="s"&gt;"remix"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Remix"&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="s"&gt;"astro"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Astro"&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="s"&gt;"webpack-dev-server"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Webpack"&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="s"&gt;"uvicorn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"uvicorn"&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="s"&gt;"flask"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Flask"&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="s"&gt;"axum"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Axum"&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="c1"&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 third tuple element is &lt;code&gt;is_frontend&lt;/code&gt;: frontend frameworks get a clickable URL in the sidebar; backend ones get a status badge.&lt;/p&gt;

&lt;p&gt;The ground truth, though, comes from the kernel. &lt;code&gt;src-app/src/workspace/ports.rs&lt;/code&gt; walks the PID tree of every workspace and queries listening sockets directly. On Linux, that means parsing &lt;code&gt;/proc/net/tcp&lt;/code&gt; and &lt;code&gt;/proc/net/tcp6&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[cfg(target_os&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"linux"&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;detect_ports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;u16&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;all_pids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;HashSet&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pids&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;descendant&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;collect_descendant_pids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;all_pids&lt;/span&gt;&lt;span class="nf"&gt;.insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;descendant&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;let&lt;/span&gt; &lt;span class="n"&gt;owned_inodes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;collect_socket_inodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;all_pids&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;ports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Vec&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"/proc/net/tcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/proc/net/tcp6"&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="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;read_capped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="nf"&gt;.lines&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.skip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="nf"&gt;.split_whitespace&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;"0A"&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="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// 0A = TCP_LISTEN&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port_hex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nf"&gt;.split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;':'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.next_back&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_str_radix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port_hex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="py"&gt;.parse&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;u64&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;owned_inodes&lt;/span&gt;&lt;span class="nf"&gt;.contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;inode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;ports&lt;/span&gt;&lt;span class="nf"&gt;.push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&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="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;ports&lt;/span&gt;&lt;span class="nf"&gt;.sort_unstable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;ports&lt;/span&gt;&lt;span class="nf"&gt;.dedup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;ports&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The walk is in two steps. First, &lt;code&gt;collect_descendant_pids&lt;/code&gt; BFS-walks &lt;code&gt;/proc/{pid}/task/{pid}/children&lt;/code&gt; to gather every descendant of the workspace's shell PID. Then &lt;code&gt;collect_socket_inodes&lt;/code&gt; reads &lt;code&gt;/proc/{pid}/fd/&lt;/code&gt; for each PID, looking for symlinks of the shape &lt;code&gt;socket:[&amp;lt;inode&amp;gt;]&lt;/code&gt;, and accumulates the inode set. Finally, &lt;code&gt;/proc/net/tcp&lt;/code&gt; is parsed line-by-line; column 3 is the TCP state (hex &lt;code&gt;0A&lt;/code&gt; is &lt;code&gt;TCP_LISTEN&lt;/code&gt;), columns 1 and 9 are the local address and socket inode. We keep only ports whose inode is owned by our PID set, so we don't surface the SSH listener on port 22 just because someone happens to have a shell open.&lt;/p&gt;

&lt;p&gt;macOS doesn't have &lt;code&gt;/proc&lt;/code&gt;, so the branch there uses &lt;code&gt;libproc&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[cfg(target_os&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"macos"&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;detect_ports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;u16&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;libproc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;libproc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;file_info&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;ListFDs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ProcFDType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pidfdinfo&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;libproc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;libproc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;net_info&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;SocketFDInfo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SocketInfoKind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TcpSIState&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;libproc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;libproc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;proc_pid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;listpidinfo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ... for each pid in the workspace tree:&lt;/span&gt;
    &lt;span class="c1"&gt;//   listpidinfo::&amp;lt;ListFDs&amp;gt;(pid) to get the FD table&lt;/span&gt;
    &lt;span class="c1"&gt;//   filter to ProcFDType::Socket&lt;/span&gt;
    &lt;span class="c1"&gt;//   pidfdinfo::&amp;lt;SocketFDInfo&amp;gt;(pid, fd) to get the socket info&lt;/span&gt;
    &lt;span class="c1"&gt;//   keep entries where soi_kind == IPv4 and tcpsi_state == LISTEN&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Windows is currently a stub: &lt;code&gt;detect_ports&lt;/code&gt; returns an empty &lt;code&gt;Vec&lt;/code&gt; and the sidebar falls back to the regex path. The native Win32 API is &lt;code&gt;GetExtendedTcpTable&lt;/code&gt;, which is on the roadmap.&lt;/p&gt;

&lt;p&gt;The combination matters. Regex catches the immediate "Vite started" line within the second; &lt;code&gt;/proc&lt;/code&gt; catches anything actually listening even when the announcement was lost. Both paths feed into the same sidebar entity via GPUI's &lt;code&gt;EventEmitter&lt;/code&gt;, which fires &lt;code&gt;ActivityBurst&lt;/code&gt; events on PTY activity rather than polling (see lesson #3 below).&lt;/p&gt;

&lt;h2&gt;
  
  
  The N-ary layout tree
&lt;/h2&gt;

&lt;p&gt;My first attempt at panes used a binary &lt;code&gt;SplitNode&lt;/code&gt; enum: &lt;code&gt;Leaf | Split { direction, ratio, first, second }&lt;/code&gt;. It worked. It also produced terrible UX the moment you wanted three equal columns. You had to nest a split inside a split, and a 50/50 split-of-a-50/50 is a 25/75, not 33/33. Every preset became a special case. Every drag-resize on the outer divider produced visually inconsistent inner sizes.&lt;/p&gt;

&lt;p&gt;I rewrote the tree as an N-ary structure. The relevant types live in &lt;code&gt;src-app/src/layout/tree.rs:42-58&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;LayoutChild&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LayoutTree&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Rc&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Cell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;computed_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Rc&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Cell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;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;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;LayoutTree&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;Leaf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Entity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Pane&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;Container&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SplitDirection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;LayoutChild&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;drag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Rc&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Cell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DragState&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;container_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Rc&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Cell&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;f32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&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;Three things to call out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Vec&amp;lt;LayoutChild&amp;gt;&lt;/code&gt;, not &lt;code&gt;(Box&amp;lt;Self&amp;gt;, Box&amp;lt;Self&amp;gt;)&lt;/code&gt;.&lt;/strong&gt; A container holds any number of children. Three columns is one container with three children, not a binary tree of containers. Drag-resizing across siblings becomes a single ratio rebalance, not a recursive walk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Rc&amp;lt;Cell&amp;lt;f32&amp;gt;&amp;gt;&lt;/code&gt; for ratios.&lt;/strong&gt; GPUI's render tree is single-threaded, and the layout body is rebuilt on every &lt;code&gt;Render&lt;/code&gt; call. Putting ratios behind &lt;code&gt;Rc&amp;lt;Cell&amp;lt;f32&amp;gt;&amp;gt;&lt;/code&gt; lets the render closure read the ratio while the drag handler writes it, with no &lt;code&gt;Arc&lt;/code&gt; and no lock. This is one of the patterns I underestimated coming in: GPUI's "no &lt;code&gt;Arc&amp;lt;Mutex&amp;lt;...&amp;gt;&amp;gt;&lt;/code&gt; for UI state" rule pushes you toward &lt;code&gt;Rc&amp;lt;Cell&amp;lt;...&amp;gt;&amp;gt;&lt;/code&gt; and &lt;code&gt;Rc&amp;lt;RefCell&amp;lt;...&amp;gt;&amp;gt;&lt;/code&gt; everywhere, and that's correct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Constants are deliberately small.&lt;/strong&gt; &lt;code&gt;MIN_PANE_SIZE = 80.0&lt;/code&gt; (&lt;code&gt;tree.rs:62&lt;/code&gt;), &lt;code&gt;DIVIDER_PX = 4.0&lt;/code&gt; (&lt;code&gt;tree.rs:60&lt;/code&gt;), max 32 panes per workspace, max 20 workspaces. The clamp on resize is dynamic, &lt;code&gt;MIN_PANE_SIZE / container_size&lt;/code&gt; at drag time, not a fixed range.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Four presets ship today: &lt;code&gt;even_h&lt;/code&gt; and &lt;code&gt;even_v&lt;/code&gt; (built from the same &lt;code&gt;from_panes_equal&lt;/code&gt; constructor), &lt;code&gt;main_vertical&lt;/code&gt; (60% left, 40% stacked right), and &lt;code&gt;tiled&lt;/code&gt; (the tmux algorithm: increment rows then cols alternately until &lt;code&gt;rows*cols &amp;gt;= N&lt;/code&gt;, then fill row-by-row). Rendering emits GPUI flex divs with &lt;code&gt;flex_basis(relative(ratio))&lt;/code&gt; per child, and the divider is a 4 px element with a drag listener that rewrites two adjacent ratios in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-platform PTY via &lt;code&gt;portable-pty&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Paneflow uses &lt;a href="https://crates.io/crates/portable-pty" rel="noopener noreferrer"&gt;&lt;code&gt;portable-pty = "0.8"&lt;/code&gt;&lt;/a&gt; (&lt;code&gt;src-app/Cargo.toml:29&lt;/code&gt;). Its &lt;code&gt;native_pty_system()&lt;/code&gt; resolves to ConPTY on Windows and openpty on Unix with no caller-side conditional compilation. The &lt;code&gt;PtyBackend&lt;/code&gt; trait in &lt;code&gt;src-app/src/pty.rs&lt;/code&gt; has exactly one production implementation, &lt;code&gt;PortablePtyBackend&lt;/code&gt;, and the call site has zero &lt;code&gt;#[cfg]&lt;/code&gt; guards. The two I/O thread loops in &lt;code&gt;pty_loops.rs&lt;/code&gt; are pure &lt;code&gt;std::io::Read&lt;/code&gt;/&lt;code&gt;Write&lt;/code&gt; over the boxed handle.&lt;/p&gt;

&lt;p&gt;Where platform-specific code does appear, it's at exactly two seams:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Process shutdown.&lt;/strong&gt; Unix uses &lt;code&gt;libc::kill(pid, SIGTERM)&lt;/code&gt; with a grace window before &lt;code&gt;SIGKILL&lt;/code&gt;. Windows uses &lt;code&gt;TerminateProcess&lt;/code&gt; and &lt;code&gt;WaitForSingleObject&lt;/code&gt; from &lt;code&gt;windows-sys&lt;/code&gt;. Both branches are &lt;code&gt;#[cfg]&lt;/code&gt;-guarded in &lt;code&gt;pty_session.rs&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CWD detection.&lt;/strong&gt; Linux reads &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/cwd&lt;/code&gt;. macOS calls &lt;code&gt;proc_pidinfo&lt;/code&gt;. The Windows branch returns &lt;code&gt;None&lt;/code&gt; for now and falls back to OSC 7 emission from the shell prompt. Three small &lt;code&gt;#[cfg(target_os = "...")]&lt;/code&gt; blocks; nothing leaks into the rest of the codebase.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the entire surface area of "this code knows what OS it's running on", fewer than 100 lines across the whole app. Everything else compiles unchanged on Linux, macOS, and Windows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agent orchestration via JSON-RPC
&lt;/h2&gt;

&lt;p&gt;This is the part the rest of the article was building toward. Paneflow exposes a JSON-RPC 2.0 server on a local socket so external tools, scripts, and AI agents can drive the editor programmatically. Workspace navigation, sending text to panes, splitting surfaces, and a six-method &lt;code&gt;ai.*&lt;/code&gt; namespace that lets agents publish their lifecycle events back to the UI: session_start, prompt_submit, tool_use, notification, stop, session_end.&lt;/p&gt;

&lt;p&gt;Transport: the &lt;a href="https://crates.io/crates/interprocess" rel="noopener noreferrer"&gt;&lt;code&gt;interprocess&lt;/code&gt;&lt;/a&gt; crate's &lt;code&gt;local_socket&lt;/code&gt; module. On Unix it's a Unix domain socket at &lt;code&gt;$XDG_RUNTIME_DIR/paneflow/paneflow.sock&lt;/code&gt; (with &lt;code&gt;$TMPDIR&lt;/code&gt; fallback on macOS); on Windows it's a named pipe at &lt;code&gt;\\.\pipe\paneflow&lt;/code&gt;. Same wire protocol (newline-delimited JSON-RPC 2.0), same Rust call sites, zero &lt;code&gt;#[cfg]&lt;/code&gt; at the dispatch level. The socket is &lt;strong&gt;strictly local&lt;/strong&gt;: no network surface, no port binding. Trust derives from filesystem mode &lt;code&gt;0600&lt;/code&gt; set immediately after &lt;code&gt;bind()&lt;/code&gt;, plus &lt;code&gt;getsockopt(SO_PEERCRED)&lt;/code&gt; on Linux and &lt;code&gt;LOCAL_PEERCRED&lt;/code&gt; on macOS: every accepted connection checks the peer's UID against the server's before any method dispatches. A mismatch returns JSON-RPC &lt;code&gt;-32001 permission denied&lt;/code&gt; and closes the stream.&lt;/p&gt;

&lt;p&gt;Architecturally, methods fall in two buckets. Stateless methods (&lt;code&gt;system.ping&lt;/code&gt;, &lt;code&gt;system.capabilities&lt;/code&gt;, &lt;code&gt;system.identify&lt;/code&gt;) reply directly on the socket thread:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="nf"&gt;.as_str&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s"&gt;"system.ping"&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nd"&gt;json!&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="s"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"pong"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="s"&gt;"system.identify"&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nd"&gt;json!&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="s"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Paneflow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nd"&gt;env!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"CARGO_PKG_VERSION"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s"&gt;"protocol"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"jsonrpc-2.0"&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;dispatch_to_gpui&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;request_tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&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;Stateful methods (&lt;code&gt;workspace.*&lt;/code&gt;, &lt;code&gt;surface.*&lt;/code&gt;, &lt;code&gt;ai.*&lt;/code&gt;) need the GPUI main thread because that's where all mutable state lives. The socket thread cannot touch &lt;code&gt;Entity&amp;lt;T&amp;gt;&lt;/code&gt; directly. So &lt;code&gt;dispatch_to_gpui&lt;/code&gt; wraps the request in an &lt;code&gt;IpcRequest&lt;/code&gt; with a one-shot response channel, sends it across an &lt;code&gt;mpsc&lt;/code&gt; queue, and blocks on the reply with a 5-second timeout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;dispatch_to_gpui&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;request_tx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;mpsc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Sender&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IpcRequest&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Value&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp_tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp_rx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;mpsc&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;ipc_req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;IpcRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;response_tx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;resp_tx&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;request_tx&lt;/span&gt;&lt;span class="nf"&gt;.send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ipc_req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.is_err&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nd"&gt;json!&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="s"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;32000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"App shutting down"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;resp_rx&lt;/span&gt;&lt;span class="nf"&gt;.recv_timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;promote_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nd"&gt;json!&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="s"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;32000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Timeout"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;id&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;On the GPUI side, &lt;code&gt;process_ipc_requests&lt;/code&gt; (in &lt;code&gt;src-app/src/app/ipc_handler.rs&lt;/code&gt;) drains the receiver each tick, dispatches by method name, and sends the result back through the per-request response channel. Stateful handlers can return a structured &lt;code&gt;JsonRpcError&lt;/code&gt; via a &lt;code&gt;_jsonrpc_error&lt;/code&gt; sentinel value that &lt;code&gt;promote_response&lt;/code&gt; rewrites into a proper JSON-RPC &lt;code&gt;error&lt;/code&gt; envelope at the boundary, so handlers stay synchronous and don't have to construct envelopes themselves.&lt;/p&gt;

&lt;p&gt;What this buys, concretely: a Claude Code session can announce itself with &lt;code&gt;ai.session_start&lt;/code&gt; and the sidebar marks the pane as agent-active. A shell script can push commands into the active surface with &lt;code&gt;surface.send_text&lt;/code&gt;. A test harness can spin up a workspace, run a battery of agent calls, and tear it down. The protocol is bytes on a socket; the security model is filesystem mode + peer credentials; the dispatch is a &lt;code&gt;mpsc::channel&lt;/code&gt; between two threads.&lt;/p&gt;

&lt;p&gt;Here's the smallest useful client you can write today, just &lt;code&gt;socat&lt;/code&gt; and a JSON line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","method":"surface.send_text","params":{"text":"ls\n"},"id":1}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | socat - UNIX-CONNECT:&lt;span class="nv"&gt;$XDG_RUNTIME_DIR&lt;/span&gt;/paneflow/paneflow.sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full method surface is documented in the &lt;a href="https://github.com/ArthurDEV44/paneflow#ipc" rel="noopener noreferrer"&gt;README&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four things I got wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. I over-invested in Tauri.&lt;/strong&gt; I pushed a Tauri implementation pretty far before admitting that the webview API surface could not carry a multiplexer's constraints (low-level input, IME, inter-pane focus). The right time to pivot was about two weeks before the moment I actually pivoted. Lesson: when a framework asks you to work around its own abstraction for two basic features, you're building against the framework, not with it. Pivot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Block-character rendering gaps.&lt;/strong&gt; Paneflow had a visible gap between adjacent block characters (U+2580 to U+259F) when rendered at certain font sizes. I spent six fix attempts on it: integer cell dimensions, origin rounding, shader edge math, shared-boundary subpixel adjustments. Every fix was plausible. None of them worked. The actual bug was that my block-character coverage table was incomplete: eleven codepoints (U+2594, U+2596 to U+259F) were missing, so they fell through to the default font path which has slight metric differences. The fix was extending the codepoint table, found by binary-scanning the binary of a TUI that triggered the bug. Lesson: when GPU quad math looks correct on every probe but the visual artifact persists, the problem is probably which codepoints you're emitting, not how.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Polling timers defeat &lt;code&gt;cx.notify()&lt;/code&gt;.&lt;/strong&gt; An early version of the sidebar polled for port-scan results every 500 ms and CWD changes every 2 s. That produced six to eight unnecessary repaints per second when nothing was happening. The fix was a full migration to GPUI's &lt;code&gt;EventEmitter&lt;/code&gt;/&lt;code&gt;cx.subscribe&lt;/code&gt;/&lt;code&gt;cx.emit&lt;/code&gt; push model, with custom events like &lt;code&gt;ActivityBurst&lt;/code&gt; and &lt;code&gt;CwdChanged&lt;/code&gt; emitted from the PTY reader. Idle repaints went to zero. GPUI's diff repaint is cheap, but it isn't free, and a &lt;code&gt;cx.notify()&lt;/code&gt; in a &lt;code&gt;setInterval&lt;/code&gt;-shaped loop is exactly the pattern the framework was designed to replace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Session persistence is table-stakes.&lt;/strong&gt; I shipped the first internal build without session save/restore because the layout code was already complex and I wanted to land the v1 commit. The first thing I did with the build was open four workspaces, rebuild the binary, and lose all four. Every multiplexer user has tmux in their muscle memory. Restart-survives-state isn't a feature, it's the price of admission. Save it to &lt;code&gt;~/.cache/paneflow/session.json&lt;/code&gt; on &lt;code&gt;CloseWindow&lt;/code&gt;, restore on startup, scope it into v1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The repo is &lt;a href="https://github.com/ArthurDEV44/paneflow" rel="noopener noreferrer"&gt;github.com/ArthurDEV44/paneflow&lt;/a&gt;, MIT licensed. Linux (Wayland and X11) and macOS (Apple Silicon) ship today as signed and notarized builds; native Windows is in flight, the code is largely ready, the signing infrastructure is being wired up. Prebuilt artifacts are on the &lt;a href="https://paneflow.dev/download" rel="noopener noreferrer"&gt;download page&lt;/a&gt;; honest side-by-side comparisons vs cmux, WezTerm, iTerm2, and Warp at &lt;a href="https://paneflow.dev/compare" rel="noopener noreferrer"&gt;paneflow.dev/compare&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Issues, suggestions, and "your N-ary tree should really be a Z-tree" arguments are all welcome on the tracker. I'm especially curious about what's missing for your workflow versus the multiplexer you use today; that's the feedback that shapes the next release.&lt;/p&gt;

&lt;p&gt;If this was useful, the engineering work I'm most proud of is in the &lt;code&gt;terminal/element/&lt;/code&gt; module, that's where glyph shaping, atlas blits, and the APCA adjustment pipeline all live, and it didn't fit in this post. I'll write that one up next.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>ai</category>
      <category>terminal</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
