Spawn and drive multiple PTY-backed terminal programs from one process. Each spawned child gets a real pseudo-terminal, so interactive programs (vim, less, a shell, a REPL) behave as they would in a normal terminal instead of seeing a dumb pipe.
The output of every child is fed through a vt100 emulator, so you read back a rendered screen (viewport, scrollback, per-cell styling) rather than a raw byte stream full of escape sequences.
use std::time::Duration;
use tui::{SpawnConfig, TuiManager};
let manager = TuiManager::new();
// Spawn on an 80x24 PTY with 10,000 lines of scrollback (the defaults).
let term = manager.spawn("cat".into(), vec![], SpawnConfig::default())?;
term.write("hello\n")?;
// Block until the child paints something, then read the rendered screen.
for line in term.read_blocking(Duration::from_secs(1))? {
println!("{line}");
}
let snapshot = term.read_full()?; // scrollback + viewport together
let cells = term.read_styled_cells()?; // per-cell char + color + attrs
# Ok::<(), tui::Error>(())Every blocking method has an _async twin (write_async, read_viewport_async,
…) that returns a future instead of driving the runtime itself, for callers that
already run inside tokio.
TuiManagerowns one multi-threaded tokio runtime, spawns processes, and tracks the live ones (list,get). It shares a clone of its runtime into every handle it hands out.TuiInstanceis the handle for one child. It carries every read/write method and a clone of the runtime, so it keeps working for as long as you hold it. Cloning a handle is cheap and all clones address the same process.- A per-child actor task owns the PTY master. It is the only thing that
touches the PTY and the vt100 parser, so reads and writes from many threads
serialize through one mailbox (
tokio::sync::mpsc) instead of locking.
The PTY master is non-blocking and driven with async I/O; the slave handed to the child is a real terminal device, so signals, line discipline, and terminal sizing all work.
read_viewportreturns the visible screen, oneStringper row.read_scrollbackreturns lines that have scrolled above the viewport, oldest first.read_fullreturns both as aFullOutput.read_blocking(timeout)polls the viewport until it has content or the timeout elapses; it errors withNoOutputAvailableonly if nothing arrives.read_charsreturns arows x colsgrid ofchar.read_styled_cellsreturns anndarray::Array2<StyledCell>; eachStyledCellcarries its character, typedfg/bgColor, and the bold, italic, underline, and inverse flags.
slice_2d with RowRange/ColRange extracts a rectangular sub-region of a
Vec<String> (1-indexed, inclusive) when you only want part of the screen.
An empty screen reads back as an empty Vec, not an error; read_blocking
keeps the wait-for-first-paint behavior by polling until content appears.
The actor owns the child, so a handle can observe and control it:
is_alive/exit_statereport whether the child is running or its exit code (ExitState::Exited(Some(code)), orExited(None)when a signal killed it).wait(timeout)blocks until exit, returningNoneon timeout;wait_asyncis the future form.killsendsSIGKILL, which (unlike a cooperative Ctrl+C) a program cannot ignore.TuiManager::removedrops a handle fromlist.
A child that has exited keeps its final screen readable; writes return
TuiNotFound.
With the dashboard feature, tui::serve(&manager, addr, poll) starts a
read-only web grid of every live terminal. It mirrors each viewport into a
single Loro CRDT document and streams updates to browsers over Server-Sent
Events; the page imports them with loro-crdt. The returned Dashboard exposes
url() and stops on stop() or drop. This is the engine behind the Python
package's serve().
serve renders one process. To watch many processes (several agents, each with
its own manager) in one grid, the producer and the viewer are split:
- Producer (
publishfeature):tui::publish(&manager, path, poll)binds a unix socket atpathand streams the manager's terminals as NDJSONProducerSnapshotpane lines. Usetui::socket_path()for a per-process path inside the discovery directory (discovery_dir). The socket, the wire serialization, and the fan-out live indashboard-core; this crate only adapts a live manager into terminal panes, so a publishing process stays HTTP- and CRDT-free. - Aggregator (the
dashboardbinary): run it by hand. It scans the discovery directory, connects to every producer socket, folds each producer's panes into one document under its own scope, and serves the same web canvas. No producer owns the server and exactly one process binds a TCP port, so any number of agents share one stable URL.
nix run .#dashboard # serve http://127.0.0.1:8080/ for the discovery dir
nix run .#dashboard -- --help # --host, --port, --dir, --rescan-ms
nix run .#dashboard demo # publish one terminal/html/data pane to exercise itThe two halves share serve_hub, the page, and the SSE stream, so a single
process (serve) and the aggregator render through exactly the same code. The
discovery directory resolves to $IX_DASH_DIR, else $XDG_RUNTIME_DIR/ix-dash,
else /tmp/ix-dash-<user>, kept short for the macOS 104-byte socket-path limit.
Pass a SpawnConfig to set the terminal size and scrollback depth at spawn:
use tui::SpawnConfig;
let config = SpawnConfig { rows: 40, cols: 120, scrollback_lines: 50_000 };Size is fixed for the life of the process; there is no runtime resize today.
All fallible calls return Result<T, Error>, a snafu-derived enum:
ProcessSpawn— the child failed to launch.TuiNotFound— the handle's actor has exited (the channel is closed).WriteToTui/ReadFromTui— a PTY I/O call failed.NoOutputAvailable— the screen is still empty.InvalidRowRange/InvalidColRange/RowIndexOutOfBounds/ColIndexOutOfBounds— bad arguments toslice_2d.ArrayConversion— building the styled-cell grid failed (carries the underlyingndarray::ShapeError).
- Unix only: depends on PTY devices, so Linux and macOS, not Windows.
- No runtime resize. Size is fixed for the life of the process.
pty-process for PTY creation, tokio for the async runtime, vt100 for
terminal emulation, ndarray for the cell grid, parking_lot for the registry
lock, and snafu for errors.