Author: Arthur Jean, solo indie maker. More on .
Repo: because I needed it.
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 , 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.
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.
. The rest of this post is about how the engine works.
Why not Electron, why not Tauri
The first option I evaluated was Electron + xterm.js. The architecture review rejected it immediately: "Electron: heavy memory footprint (contradicts cmux's 'not Electron' philosophy)." 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.
The second option was started, they could have built on Electron, on Tauri, on GTK or Qt. They chose to write their own framework, " 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
teeand the announcement never came back.
So Paneflow does both.
src-app/src/terminal/service_detector.rsregex-matches a list of 22 framework signatures against the terminal output as a fast, immediate signal:
CODEconst FRAMEWORKS: &[(&str, &str, bool)] = &[
("next.js", "Next.js", true),
("turbopack", "Next.js", true),
("vite", "Vite", true),
("nuxt", "Nuxt", true),
("remix", "Remix", true),
("astro", "Astro", true),
("webpack-dev-server", "Webpack", true),
("uvicorn", "uvicorn", false),
("flask", "Flask", false),
("axum", "Axum", false),
// ...
];
The third tuple element is
is_frontend: frontend frameworks get a clickable URL in the sidebar; backend ones get a status badge.
The ground truth, though, comes from the kernel.
src-app/src/workspace/ports.rswalks the PID tree of every workspace and queries listening sockets directly. On Linux, that means parsing/proc/net/tcpand/proc/net/tcp6:
CODE#[cfg(target_os = "linux")]
pub fn detect_ports(pids: &[u32]) -> Vec<u16> {
let mut all_pids = HashSet::new();
for &pid in pids {
for descendant in collect_descendant_pids(pid) {
all_pids.insert(descendant);
}
}
let owned_inodes = collect_socket_inodes(&all_pids);
let mut ports = Vec::new();
for path in &["/proc/net/tcp", "/proc/net/tcp6"] {
if let Ok(content) = read_capped(path, 256 * 1024) {
for line in content.lines().skip(1) {
let fields: Vec<&str> = line.split_whitespace().collect();
if fields[3] != "0A" { continue; } // 0A = TCP_LISTEN
if let Some(port_hex) = fields[1].split(':').next_back()
&& let Ok(port) = u16::from_str_radix(port_hex, 16)
&& let Ok(inode) = fields[9].parse::<u64>()
&& owned_inodes.contains(&inode)
{
ports.push(port);
}
}
}
}
ports.sort_unstable();
ports.dedup();
ports
}
The walk is in two steps. First,
collect_descendant_pidsBFS-walks/proc/{pid}/task/{pid}/childrento gather every descendant of the workspace's shell PID. Thencollect_socket_inodesreads/proc/{pid}/fd/for each PID, looking for symlinks of the shapesocket:[<inode>], and accumulates the inode set. Finally,/proc/net/tcpis parsed line-by-line; column 3 is the TCP state (hex0AisTCP_LISTEN), 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.
macOS doesn't have
/proc, so the branch there useslibproc:
CODE#[cfg(target_os = "macos")]
pub fn detect_ports(pids: &[u32]) -> Vec<u16> {
use libproc::libproc::file_info::{ListFDs, ProcFDType, pidfdinfo};
use libproc::libproc::net_info::{SocketFDInfo, SocketInfoKind, TcpSIState};
use libproc::libproc::proc_pid::listpidinfo;
// ... for each pid in the workspace tree:
// listpidinfo::<ListFDs>(pid) to get the FD table
// filter to ProcFDType::Socket
// pidfdinfo::<SocketFDInfo>(pid, fd) to get the socket info
// keep entries where soi_kind == IPv4 and tcpsi_state == LISTEN
}
Windows is currently a stub:
detect_portsreturns an emptyVecand the sidebar falls back to the regex path. The native Win32 API isGetExtendedTcpTable, which is on the roadmap.
The combination matters. Regex catches the immediate "Vite started" line within the second;
/proccatches anything actually listening even when the announcement was lost. Both paths feed into the same sidebar entity via GPUI'sEventEmitter, which firesActivityBurstevents on PTY activity rather than polling (see lesson #3 below).
The N-ary layout tree
My first attempt at panes used a binary
SplitNodeenum:Leaf | Split { direction, ratio, first, second }. 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.
I rewrote the tree as an N-ary structure. The relevant types live in
src-app/src/layout/tree.rs:42-58:
CODEpub struct LayoutChild {
pub node: LayoutTree,
pub ratio: Rc<Cell<f32>>,
pub computed_size: Rc<Cell<f32>>,
}
pub enum LayoutTree {
Leaf(Entity<Pane>),
Container {
direction: SplitDirection,
children: Vec<LayoutChild>,
drag: Rc<Cell<Option<DragState>>>,
container_size: Rc<Cell<f32>>,
},
}
Three things to call out:
Vec<LayoutChild>, not(Box<Self>, Box<Self>). 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.
Rc<Cell<f32>>for ratios. GPUI's render tree is single-threaded, and the layout body is rebuilt on everyRendercall. Putting ratios behindRc<Cell<f32>>lets the render closure read the ratio while the drag handler writes it, with noArcand no lock. This is one of the patterns I underestimated coming in: GPUI's "noArc<Mutex<...>>for UI state" rule pushes you towardRc<Cell<...>>andRc<RefCell<...>>everywhere, and that's correct.
Constants are deliberately small.MIN_PANE_SIZE = 80.0(tree.rs:62),DIVIDER_PX = 4.0(tree.rs:60), max 32 panes per workspace, max 20 workspaces. The clamp on resize is dynamic,MIN_PANE_SIZE / container_sizeat drag time, not a fixed range.
Four presets ship today:
even_handeven_v(built from the samefrom_panes_equalconstructor),main_vertical(60% left, 40% stacked right), andtiled(the tmux algorithm: increment rows then cols alternately untilrows*cols >= N, then fill row-by-row). Rendering emits GPUI flex divs withflex_basis(relative(ratio))per child, and the divider is a 4 px element with a drag listener that rewrites two adjacent ratios in place.
Cross-platform PTY viaportable-pty
Paneflow uses crate's
local_socketmodule. On Unix it's a Unix domain socket at$XDG_RUNTIME_DIR/paneflow/paneflow.sock(with$TMPDIRfallback on macOS); on Windows it's a named pipe at\\.\pipe\paneflow. Same wire protocol (newline-delimited JSON-RPC 2.0), same Rust call sites, zero#[cfg]at the dispatch level. The socket is strictly local: no network surface, no port binding. Trust derives from filesystem mode0600set immediately afterbind(), plusgetsockopt(SO_PEERCRED)on Linux andLOCAL_PEERCREDon macOS: every accepted connection checks the peer's UID against the server's before any method dispatches. A mismatch returns JSON-RPC-32001 permission deniedand closes the stream.
Architecturally, methods fall in two buckets. Stateless methods (
system.ping,system.capabilities,system.identify) reply directly on the socket thread:
CODEmatch method.as_str() {
"system.ping" => json!({"jsonrpc": "2.0", "result": {"pong": true}, "id": id}),
"system.identify" => json!({"jsonrpc": "2.0", "result": {
"name": "Paneflow",
"version": env!("CARGO_PKG_VERSION"),
"protocol": "jsonrpc-2.0"
}, "id": id}),
_ => dispatch_to_gpui(&request_tx, method, params, id),
}
Stateful methods (
workspace.*,surface.*,ai.*) need the GPUI main thread because that's where all mutable state lives. The socket thread cannot touchEntity<T>directly. Sodispatch_to_gpuiwraps the request in anIpcRequestwith a one-shot response channel, sends it across anmpscqueue, and blocks on the reply with a 5-second timeout:
CODEfn dispatch_to_gpui(
request_tx: &mpsc::Sender<IpcRequest>,
method: String,
params: Value,
id: Value,
) -> Value {
let (resp_tx, resp_rx) = mpsc::channel();
let ipc_req = IpcRequest { method, params, _id: id.clone(), response_tx: resp_tx };
if request_tx.send(ipc_req).is_err() {
return json!({"jsonrpc": "2.0", "error": {"code": -32000, "message": "App shutting down"}, "id": id});
}
match resp_rx.recv_timeout(Duration::from_secs(5)) {
Ok(result) => promote_response(result, id),
Err(_) => json!({"jsonrpc": "2.0", "error": {"code": -32000, "message": "Timeout"}, "id": id}),
}
}
On the GPUI side,
process_ipc_requests(insrc-app/src/app/ipc_handler.rs) 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 structuredJsonRpcErrorvia a_jsonrpc_errorsentinel value thatpromote_responserewrites into a proper JSON-RPCerrorenvelope at the boundary, so handlers stay synchronous and don't have to construct envelopes themselves.
What this buys, concretely: a Claude Code session can announce itself with
ai.session_startand the sidebar marks the pane as agent-active. A shell script can push commands into the active surface withsurface.send_text. 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 ampsc::channelbetween two threads.
Here's the smallest useful client you can write today, just
socatand a JSON line:
CODEecho '{"jsonrpc":"2.0","method":"surface.send_text","params":{"text":"ls\n"},"id":1}' \
| socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/paneflow/paneflow.sock
The full method surface is documented in the , 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 .
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.
If this was useful, the engineering work I'm most proud of is in the
terminal/element/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.↗ Original-Artikel auf dev.to lesenVollständiger Original-BerichtAusführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
Building a native terminal for AI coding agents in Rust + GPUI
- ▸ Why a terminal for AI coding agents
- ▸ Why not Electron, why not Tauri
- ▸ The GPUI mental model
- ▸ Plugging in alacritty_terminal
- ▸ Detecting dev servers from /proc/net/tcp
- ▸ The N-ary layout tree
- ▸ Cross-platform PTY via portable-pty
- ▸ Agent orchestration via JSON-RPC
- ▸ Four things I got wrong
- ▸ Try it
SOCIAL SHARE CARD GENERATOR