libsinclair: embedding the terminal
August 4, 2026 · View on GitHub
crates/libsinclair is Sinclair's terminal packaged as a library: the
session/emulation core plus the gpui renderer, without the app shell
(windows, tabs, settings, plugins, relay). The Sinclair app itself builds on
it — there is one renderer implementation, not a fork.
What you get
- Headless core (always):
Session(pty + child + reader thread), the fullvtemulation,inputkey/mouse encoding,themeschemes, and the render policy modules (colors,metrics,mouse,boxdraw) that resolve what a frame should look like without touching a GPU. uifeature (default): the gpui layer —element::TerminalElementpaints the grid;termview::TermViewis a drop-in pane with the session, keyboard, mouse, selection, scrollback, links, and copy/paste wired up.
Deliberately not included: config (bring your own settings; the library
takes plain option structs), plugins, the relay mesh, auto-update, and all
window chrome.
Consuming from another app
libsinclair is consumed as a git dependency (its crate names — vt,
terminal, pty — are not registry-publishable as-is):
[dependencies]
libsinclair = { git = "https://github.com/wess/sinclair" }
# Required: cargo patches do not propagate through git dependencies, so
# zed's gpui patches must be mirrored here (see docs/gpui.md for why).
[patch.crates-io]
async-process = { git = "https://github.com/zed-industries/async-process.git", rev = "0b6d6713570af61806e1e5cb40e0f757cb93fd9d" }
async-task = { git = "https://github.com/smol-rs/async-task.git", rev = "b4486cd71e4e94fbda54ce6302444de14f4d190e" }
Requires Rust stable >= 1.96. The [patch.crates-io] block is only needed
with the ui feature (it exists for gpui); a headless consumer
(default-features = false) skips it and gets a gpui-free tree.
If your app also depends on a crates.io gpui (e.g. via guise), add the
gpui redirect from docs/gpui.md so a single gpui resolves across the tree.
A terminal pane in a gpui app
use gpui::AppContext as _;
use libsinclair::terminal::{Event, SessionOptions};
use libsinclair::termview::{TermOptions, TermView};
let window = cx.open_window(options, |window, cx| {
cx.new(|cx| {
TermView::spawn(SessionOptions::default(), TermOptions::default(), window, cx)
.expect("spawn shell")
})
})?;
TermOptions covers appearance and behavior: colors (Colors::from_scheme
over any of the built-in theme schemes), font, padding, cursor shape,
copy-on-select, smart select, middle-click paste, macOS option-as-alt, and
OSC 52 clipboard policy. Every session Event (title, bell, exit, cwd,
notifications) is re-emitted by the view, so the host reacts with a normal
gpui subscription — see crates/libsinclair/examples/embed.rs for a complete
window that quits when the shell exits, runnable with:
cargo run -p libsinclair --example embed
A TermView's focus listeners are subscriptions on the window it was built in,
so a host that moves one into a different window — tearing a pane off into
its own window, say — must call TermView::rehome(window, cx) as part of the
move. Until it does, the view reports the old window's focus, and can arrive in
its new home unfocused with no listener left that can mark it focused again
(the cursor stays hollow until the user clicks away and back).
Hosts that outgrow TermView (overlays, search UI, split dimming) keep
TerminalElement and build their own view around it — that is exactly what
the Sinclair app's TerminalView does. The element handles grid resize,
damage-aware frame reuse, selection/search/link overlays, sixel images, and
mouse reporting; the view supplies appearance, focus state, and a CopyHook
if it wants redaction or clipboard history layered over copy-on-select.
Headless embedding
use libsinclair::{Session, SessionOptions};
let (session, events) = Session::spawn(SessionOptions::default())?;
// Event::Wakeup on the receiver -> read the grid under the terminal lock:
session.with_term(|term| { /* term.visible_row(..), term.cursor_pos(), .. */ });
session.write(b"ls\r")?;
The embedder contract: with_term locks the terminal; Wakeup events are
coalesced. Read session.output_generation(), process the terminal, then call
session.acknowledge_wakeup(generation) and process immediately again when it
returns true. vt's damage tracking (term.take_damage()) supports partial
redraw. bridge::forward (ui feature) exposes the flume receiver as an async
stream without another thread or queue.