Skip to content

Commit 4140d5c

Browse files
tui: extract engine-free tui-dashboard-core so the aggregator drops libghostty (#337)
Follow-up: the `tui-dashboard` aggregator linked `libghostty-vt.so` (via `tui → ix-vt`) despite only rendering frames and never driving a PTY. ## Why feature-gating doesn't work The nix build is `cargo --workspace`, which unifies `tui`'s features. `tui-py`/`tui-node` force the engine on the shared `tui` build, so `tui-dashboard` would link the engine-on `tui` regardless of its own flags. The fix is to stop depending on the engine crate at all. ## Change Extract the engine-free surface the aggregator uses into a new `tui-dashboard-core` crate: the wire types (`TerminalFrame`, `ProducerSnapshot`, `socket_dir`, `socket_path`) and the browser server (`Hub`, `serve_hub`, `Dashboard`). It depends on neither `tui` nor `ix-vt`. - `tui-dashboard` now depends on `tui-dashboard-core` instead of `tui` → no engine, no libghostty. - `tui` depends on `tui-dashboard-core` and re-exports the moved types, so its public API and `tui-py`/`tui-node`/`mcp` are unchanged. `collect_frames` and the in-process `serve` stay in `tui`. ## Verified locally - `patchelf --print-needed` on the built `tui-dashboard` binary: `libgcc_s`, `libm`, `libc`, `ld-linux` — **no `libghostty-vt`**. - `nix build .#checks.x86_64-linux.rust-package-tests` passes (clippy + all crates + consumers). - `nix run .#lint` passes. Made with Claude (claude-opus-4-8). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2a21655 commit 4140d5c

20 files changed

Lines changed: 318 additions & 208 deletions

File tree

Cargo.lock

Lines changed: 19 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ members = [
2020
"packages/tap/pty",
2121
"packages/tui",
2222
"packages/tui-dashboard",
23+
"packages/tui-dashboard-core",
2324
"packages/tui-node",
2425
"packages/tui-py",
2526
"packages/vt/ix-vt",
@@ -104,6 +105,7 @@ toml = { version = "1.1.2", default-features = false }
104105
tower = "0.5"
105106
tower-http = "0.6"
106107
tui = { path = "packages/tui" }
108+
tui-dashboard-core = { path = "packages/tui-dashboard-core" }
107109
url = { version = "2.5.8", default-features = false }
108110
uuid = "1"
109111
vt100 = "0.16"
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[package]
2+
name = "tui-dashboard-core"
3+
version.workspace = true
4+
edition.workspace = true
5+
publish.workspace = true
6+
description = "Engine-free wire types and browser-facing dashboard server shared by the tui producer and the standalone aggregator"
7+
8+
[lints]
9+
workspace = true
10+
11+
[dependencies]
12+
axum.workspace = true
13+
base64.workspace = true
14+
futures.workspace = true
15+
loro.workspace = true
16+
parking_lot.workspace = true
17+
serde = { workspace = true, features = ["derive"] }
18+
snafu.workspace = true
19+
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "net", "time"] }
20+
tokio-stream = { workspace = true, features = ["sync"] }
21+
uuid = { workspace = true, features = ["v4"] }
22+
23+
[dev-dependencies]
24+
serde_json.workspace = true
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
id = "tui-dashboard-core";
3+
inRustWorkspace = true;
4+
}
File renamed without changes.

packages/tui/src/dashboard/hub.rs renamed to packages/tui-dashboard-core/src/dashboard/hub.rs

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -226,23 +226,21 @@ fn write_cursor(meta: &LoroMap, frame: &TerminalFrame) -> Result<()> {
226226
/// Write a frame's exit code into its meta map: an `i64` when the process exited
227227
/// with a code, otherwise absent (still running, or signalled).
228228
fn write_exit(meta: &LoroMap, frame: &TerminalFrame) -> Result<()> {
229-
match frame.exit_code {
230-
Some(code) => meta.insert("exit_code", i64::from(code)).map_err(loro_err),
231-
None => {
232-
// A signalled or still-running process has no code; clear any prior
233-
// value so a re-spawned id under the same key never shows a stale
234-
// exit code. delete on an absent key is a harmless no-op.
235-
meta.delete("exit_code").map_err(loro_err)
236-
}
237-
}
229+
frame.exit_code.map_or_else(
230+
// A signalled or still-running process has no code; clear any prior
231+
// value so a re-spawned id under the same key never shows a stale exit
232+
// code. delete on an absent key is a harmless no-op.
233+
|| meta.delete("exit_code").map_err(loro_err),
234+
|code| meta.insert("exit_code", i64::from(code)).map_err(loro_err),
235+
)
238236
}
239237

240238
/// Owns the shared document and fans CRDT updates out to SSE subscribers.
241239
///
242240
/// One hub backs any number of frame sources. The in-process dashboard drives
243-
/// it from a poll loop over a [`TuiManager`](crate::TuiManager); the aggregator
244-
/// drives it from many unix-socket readers. Both call [`apply_scope`] and
245-
/// [`remove_scope`]; the hub serializes them under one lock.
241+
/// it from a poll loop over a `TuiManager`; the aggregator drives it from many
242+
/// unix-socket readers. Both call [`apply_scope`](Self::apply_scope) and
243+
/// [`remove_scope`](Self::remove_scope); the hub serializes them under one lock.
246244
pub struct Hub {
247245
state: Mutex<DocState>,
248246
updates: broadcast::Sender<Arc<str>>,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
//! A read-only web dashboard for live PTY terminals.
2+
//!
3+
//! The dashboard renders whatever sits in a [`Hub`]'s Loro document and streams
4+
//! changes to browsers over Server-Sent Events. Two frame sources drive a hub:
5+
//!
6+
//! * the in-process dashboard (`tui::serve`) polls one `TuiManager` and applies
7+
//! its terminals under one scope.
8+
//! * the standalone aggregator (`tui-dashboard`) reads many producer sockets and
9+
//! applies each producer under its own scope.
10+
//!
11+
//! Both paths share [`serve_hub`], the router, the page, and the SSE stream, so
12+
//! there is one owner for the browser-facing surface. Loro is only the view-sync
13+
//! layer: the PTYs stay in their owning process, browsers never write back, so
14+
//! the doc has a single editor per scope and conflict resolution never runs; the
15+
//! CRDT buys cheap incremental text diffs and a late joiner catching up from one
16+
//! snapshot.
17+
18+
mod hub;
19+
mod server;
20+
21+
use base64::Engine as _;
22+
use base64::engine::general_purpose::STANDARD as BASE64;
23+
24+
pub use hub::Hub;
25+
pub use server::{Dashboard, serve_hub};
26+
27+
/// Base64 for the SSE wire. One spelling shared by the snapshot and update
28+
/// encoders in [`server`].
29+
fn b64(bytes: &[u8]) -> String {
30+
BASE64.encode(bytes)
31+
}

packages/tui/src/dashboard/server.rs renamed to packages/tui-dashboard-core/src/dashboard/server.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,15 @@ impl Drop for Dashboard {
9292
/// shutdown receiver.
9393
///
9494
/// The server task runs on `runtime`, which must outlive the dashboard (the
95-
/// manager's runtime for the in-process [`serve`](super::serve), the process
96-
/// runtime for the aggregator). The caller spawns its frame-source tasks on the
97-
/// same runtime against the returned receiver and attaches them with
95+
/// manager's runtime for the in-process `tui::serve`, the process runtime for
96+
/// the aggregator). The caller spawns its frame-source tasks on the same runtime
97+
/// against the returned receiver and attaches them with
9898
/// [`Dashboard::push_task`], so one shutdown signal stops the whole dashboard.
99+
///
100+
/// # Errors
101+
///
102+
/// Returns [`Error::Dashboard`] when `addr` cannot be bound or its resolved
103+
/// local address cannot be read.
99104
pub async fn serve_hub(
100105
hub: Arc<Hub>,
101106
addr: SocketAddr,
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
use snafu::Snafu;
2+
3+
#[derive(Debug, Snafu)]
4+
pub enum Error {
5+
/// Collapses the dashboard's foreign-boundary failures (TCP bind, Loro
6+
/// encode) into one observable message.
7+
#[snafu(display("dashboard error: {message}"), visibility(pub(crate)))]
8+
Dashboard { message: String },
9+
}
10+
11+
pub type Result<T, E = Error> = std::result::Result<T, E>;
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
//! Wire types and discovery paths shared by the producer (`tui::publish`) and
2+
//! the dashboard/aggregator ([`crate::dashboard`]).
3+
//!
4+
//! A producer streams [`ProducerSnapshot`]s over a unix socket; the aggregator
5+
//! folds them into one document keyed by `producer`. Both halves agree on these
6+
//! shapes and on where the sockets live ([`socket_dir`]), so neither side
7+
//! reaches into the other.
8+
9+
use std::path::PathBuf;
10+
11+
use serde::{Deserialize, Serialize};
12+
13+
/// One terminal's rendered state at a single poll tick.
14+
///
15+
/// The unit a producer streams and the dashboard renders. `id` is unique within
16+
/// its producer; the aggregator namespaces it by producer for a global key.
17+
#[derive(Debug, Clone, Serialize, Deserialize)]
18+
pub struct TerminalFrame {
19+
/// Stable per-producer terminal id (the manager's UUID).
20+
pub id: String,
21+
/// The command that was spawned.
22+
pub command: String,
23+
/// Positional arguments, space-joined for display.
24+
pub args: String,
25+
/// Terminal height in rows.
26+
pub rows: u16,
27+
/// Terminal width in columns.
28+
pub cols: u16,
29+
/// Whether the child is still running.
30+
pub alive: bool,
31+
/// The visible screen, rows newline-joined, with minimal ANSI SGR runs
32+
/// encoding per-cell color and attributes. The dashboard parses the SGR
33+
/// back into styled spans; a plain reader still sees the text.
34+
pub screen: String,
35+
// These fields were added after the first wire shape. `#[serde(default)]`
36+
// keeps a mixed-version dashboard working: a producer built before this
37+
// change streams frames without them, and the aggregator drops a frame
38+
// whose JSON fails to parse, so without defaults those older producers'
39+
// terminals would silently vanish from the dashboard.
40+
/// Cursor row in viewport cell coordinates (0-based, top first).
41+
#[serde(default)]
42+
pub cursor_row: u16,
43+
/// Cursor column in viewport cell coordinates (0-based, left first).
44+
#[serde(default)]
45+
pub cursor_col: u16,
46+
/// Whether the screen is showing its cursor (the inverse of `CSI ?25l`).
47+
#[serde(default)]
48+
pub cursor_visible: bool,
49+
/// The cursor shape token: `"block"`, `"underline"`, or `"bar"`.
50+
#[serde(default)]
51+
pub cursor_shape: String,
52+
/// The child's exit code when it has exited with one, else `None` (still
53+
/// running, or terminated by a signal).
54+
#[serde(default)]
55+
pub exit_code: Option<i32>,
56+
}
57+
58+
/// One producer process's terminals, as sent over its discovery socket.
59+
///
60+
/// `producer` namespaces every terminal in `terminals` so many processes can
61+
/// share one aggregated document without key collisions. Each message carries
62+
/// the producer's full terminal set, so the latest message fully describes that
63+
/// producer and a late-joining reader needs no backlog.
64+
#[derive(Debug, Clone, Serialize, Deserialize)]
65+
pub struct ProducerSnapshot {
66+
/// Stable per-process id: `"<pid>-<short-uuid>"`.
67+
pub producer: String,
68+
/// Every terminal this producer currently tracks.
69+
pub terminals: Vec<TerminalFrame>,
70+
}
71+
72+
/// The directory where producers expose their per-process sockets and the
73+
/// aggregator looks for them.
74+
///
75+
/// Resolved in order: `$IX_TUI_DIR`, then `$XDG_RUNTIME_DIR/ix-tui`, then
76+
/// `/tmp/ix-tui-<user>`. Kept deliberately short: macOS caps a unix socket
77+
/// path (`sun_path`) at 104 bytes, and `$TMPDIR` on macOS is long enough to
78+
/// blow that budget once a filename is appended, so it is not used.
79+
#[must_use]
80+
pub fn socket_dir() -> PathBuf {
81+
if let Some(dir) = std::env::var_os("IX_TUI_DIR") {
82+
return PathBuf::from(dir);
83+
}
84+
if let Some(runtime) = std::env::var_os("XDG_RUNTIME_DIR") {
85+
return PathBuf::from(runtime).join("ix-tui");
86+
}
87+
let user = std::env::var("USER").unwrap_or_else(|_| "shared".to_owned());
88+
PathBuf::from(format!("/tmp/ix-tui-{user}"))
89+
}
90+
91+
/// A unique socket path for the current process inside [`socket_dir`].
92+
///
93+
/// The filename is `"<pid>-<short-uuid>.sock"`: the pid is human-legible for
94+
/// debugging and the uuid suffix keeps it unique across pid reuse.
95+
#[must_use]
96+
pub fn socket_path() -> PathBuf {
97+
let short = uuid::Uuid::new_v4().simple().to_string();
98+
socket_dir().join(format!(
99+
"{}-{}.sock",
100+
std::process::id(),
101+
&short[..8]
102+
))
103+
}
104+
105+
#[cfg(test)]
106+
mod tests {
107+
use super::TerminalFrame;
108+
109+
/// A frame streamed by a producer built before the cursor/exit fields were
110+
/// added still deserializes: the new fields fall back to their defaults
111+
/// instead of failing the whole `ProducerSnapshot` parse and dropping the
112+
/// terminal from the dashboard.
113+
#[test]
114+
fn old_wire_shape_deserializes_with_field_defaults() {
115+
let old = r#"{
116+
"id": "t1", "command": "vim", "args": "-u NONE",
117+
"rows": 24, "cols": 80, "alive": true, "screen": "hi"
118+
}"#;
119+
let frame: TerminalFrame = serde_json::from_str(old).expect("old shape parses");
120+
assert_eq!(frame.screen, "hi");
121+
assert_eq!(frame.cursor_row, 0);
122+
assert_eq!(frame.cursor_col, 0);
123+
assert!(!frame.cursor_visible);
124+
assert_eq!(frame.cursor_shape, "");
125+
assert_eq!(frame.exit_code, None);
126+
}
127+
}

0 commit comments

Comments
 (0)