⚠️ Malware / Trojaner / VirenGuardBreaker: Derailing AI-assisted malware analysis with a code comment(10.09.2026 um 11:00 Uhr)
⚠️ Malware / Trojaner / VirenAttack hides malware in PNGs and drops custom reverse tunnel on victims' machines(31.08.2026 um 20:26 Uhr)
⚠️ Malware / Trojaner / Viren33-hour BGP hijack of Softaculous traffic prompts security scramble(01.09.2026 um 14:04 Uhr)
🕵️ SicherheitslückenProlific Microsoft 0-day hunter drops CrowdStrike Falcon exploit PoC(03.09.2026 um 20:08 Uhr)
🔧 AI Nachrichten OpenAI commits $1B in AI credits to frontline cyber defenders(04.09.2026 um 01:47 Uhr)
⚠️ Malware / Trojaner / VirenGuardBreaker: Derailing AI-assisted malware analysis with a code comment(10.09.2026 um 11:00 Uhr)
⚠️ Malware / Trojaner / VirenAttack hides malware in PNGs and drops custom reverse tunnel on victims' machines(31.08.2026 um 20:26 Uhr)
⚠️ Malware / Trojaner / Viren33-hour BGP hijack of Softaculous traffic prompts security scramble(01.09.2026 um 14:04 Uhr)
🕵️ SicherheitslückenProlific Microsoft 0-day hunter drops CrowdStrike Falcon exploit PoC(03.09.2026 um 20:08 Uhr)
🔧 AI Nachrichten OpenAI commits $1B in AI credits to frontline cyber defenders(04.09.2026 um 01:47 Uhr)

🔧 Programmierung 🕛 vor 3 Monaten 19 Min Lesezeit
0

Building a native terminal for AI coding agents in Rust + GPUI

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht

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 tee and the announcement never came back.



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




CODE
const 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.rs walks the PID tree of every workspace and queries listening sockets directly. On Linux, that means parsing /proc/net/tcp and /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_pids BFS-walks /proc/{pid}/task/{pid}/children to gather every descendant of the workspace's shell PID. Then collect_socket_inodes reads /proc/{pid}/fd/ for each PID, looking for symlinks of the shape socket:[<inode>], and accumulates the inode set. Finally, /proc/net/tcp is parsed line-by-line; column 3 is the TCP state (hex 0A is TCP_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 uses libproc:




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_ports returns an empty Vec and the sidebar falls back to the regex path. The native Win32 API is GetExtendedTcpTable, which is on the roadmap.



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






The N-ary layout tree



My first attempt at panes used a binary SplitNode enum: 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:




CODE
pub 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 every Render call. Putting ratios behind Rc<Cell<f32>> lets the render closure read the ratio while the drag handler writes it, with no Arc and no lock. This is one of the patterns I underestimated coming in: GPUI's "no Arc<Mutex<...>> for UI state" rule pushes you toward Rc<Cell<...>> and Rc<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_size at drag time, not a fixed range.



Four presets ship today: even_h and even_v (built from the same from_panes_equal constructor), main_vertical (60% left, 40% stacked right), and tiled (the tmux algorithm: increment rows then cols alternately until rows*cols >= N, then fill row-by-row). Rendering emits GPUI flex divs with flex_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 via portable-pty



Paneflow uses crate's local_socket module. On Unix it's a Unix domain socket at $XDG_RUNTIME_DIR/paneflow/paneflow.sock (with $TMPDIR fallback 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 mode 0600 set immediately after bind(), plus getsockopt(SO_PEERCRED) on Linux and LOCAL_PEERCRED on macOS: every accepted connection checks the peer's UID against the server's before any method dispatches. A mismatch returns JSON-RPC -32001 permission denied and closes the stream.



Architecturally, methods fall in two buckets. Stateless methods (system.ping, system.capabilities, system.identify) reply directly on the socket thread:




CODE
match 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 touch Entity<T> directly. So dispatch_to_gpui wraps the request in an IpcRequest with a one-shot response channel, sends it across an mpsc queue, and blocks on the reply with a 5-second timeout:




CODE
fn 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 (in src-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 structured JsonRpcError via a _jsonrpc_error sentinel value that promote_response rewrites into a proper JSON-RPC error envelope 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_start and the sidebar marks the pane as agent-active. A shell script can push commands into the active surface with surface.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 a mpsc::channel between two threads.



Here's the smallest useful client you can write today, just socat and a JSON line:




CODE
echo '{"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.

Vollständiger Original-Bericht
Ausführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
1 Quelle
GuardBreaker: Derailing AI-assisted malware analysis with a code comment
1 Quelle
Attack hides malware in PNGs and drops custom reverse tunnel on victims' machines
1 Quelle
33-hour BGP hijack of Softaculous traffic prompts security scramble
Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten Building a native terminal for AI coding agents in Rust + GPUI

Thematisch verwandte Begriffe: Building, native, terminal, coding · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...