Embedding Scenarios: Six Consumers, One Core
A boundary used by one consumer is not a boundary. This chapter walks six different programs that could embed your terminal core, and for each one asks: what does it need, what must it not be forced to take, and what does supporting it prove?
Four of them you should actually build. The other two are thought experiments that will change your API anyway.
Scenario 1: A Desktop Terminal Application
This is your terminal-gui. The reference consumer.
| Needs | Must not be forced to take |
|---|---|
terminal-protocol, terminal-core, terminal-input, terminal-pty, terminal-render-model | Anything mux-related |
| Damage tracking, to render efficiently | A specific rendering backend |
| Terminal replies, clipboard requests, title changes | A specific clipboard library |
#![allow(unused)] fn main() { let mut term = Terminal::new(rows, cols, config.terminal.clone()); let mut pty = Pty::spawn(&config.pty)?; loop { // ...event loop from Section 3... term.advance(&bytes); if let Some(r) = non_empty(term.take_replies()) { pty.write_all(&r)?; } if let Some((sel, text)) = term.take_clipboard_write() { clipboard.set(sel, text); } if term.take_title_changed() { window.set_title(term.title()); } if term.damage().any() { renderer.draw(&RenderSnapshot::from_terminal(&term, ..)); } } }
Proves: the core can drive a real interactive application.
Scenario 2: A Multiplexer
This is your terminal-mux. The most demanding consumer, and the one that validates the design.
| Needs | Must not be forced to take |
|---|---|
N independent Terminals in one process | Any display, window, font, or GPU |
| To run with zero attached clients | An event loop it does not own |
| Screen snapshots serializable over a socket | A rendering backend |
| Per-pane configuration | A global config |
#![allow(unused)] fn main() { // Fifty terminals, no display, no window system, possibly on a headless server. let mut panes: HashMap<PaneId, (Pty, Terminal)> = HashMap::new(); for spec in pane_specs { panes.insert(spec.id, (Pty::spawn(&spec.pty)?, Terminal::new(spec.rows, spec.cols, spec.config))); } }
Proves: the core is display-independent and instantiable many times with different
configurations. If Terminal::new read a global config, this scenario would be impossible — which is
why the configuration rule
matters.
Scenario 3: An IDE Terminal Panel
An editor (VS Code, Zed, an egui app) embedding a terminal in a dock.
| Needs | Must not be forced to take |
|---|---|
| To render into the host's framebuffer or scene graph | winit — the host owns the window |
| To receive input events in the host's types | terminal-input's event types verbatim |
| To be one of many widgets sharing an event loop | Ownership of the event loop |
| To resize to a panel, not a window | Full-screen assumptions |
#![allow(unused)] fn main() { // The host owns the window and the loop. The terminal is a component. pub struct TerminalWidget { term: Terminal, pty: Pty, reader: Receiver<Vec<u8>>, } impl TerminalWidget { /// Called by the host whenever it is convenient. Non-blocking. pub fn tick(&mut self) { while let Ok(chunk) = self.reader.try_recv() { self.term.advance(&chunk); } let r = self.term.take_replies(); if !r.is_empty() { let _ = self.pty.write_all(&r); } } /// The host translates ITS key type into ours. We do not know its type. pub fn on_key(&mut self, key: Key, mods: Modifiers) { if let Some(b) = encode_key(key, mods, self.term.modes()) { let _ = self.pty.write_all(&b); } } /// The host draws. We hand it cells; it decides what a pixel is. pub fn snapshot(&self) -> RenderSnapshot { RenderSnapshot::from_terminal(&self.term, ..) } } }
What this scenario reveals about your API:
- You must not own the event loop. Any API shaped
run_forever()fails here immediately. - You must not own the window.
RenderSnapshot— cells, not pixels — is what makes this work. - Reading from the PTY must be separable from parsing, so the host can drive it on its own schedule.
- Resize must be cheap, because a docked panel is resized by dragging, constantly.
If your API fails this scenario, it fails because it assumed it was the whole program. That assumption is the most common way an "embeddable" library turns out not to be.
Scenario 4: A Remote Terminal Viewer
A web page or mobile app displaying a terminal running on a server. The core runs server-side; only rendering data crosses the network.
┌──────────┐ WebSocket ┌────────────────────────────────────┐
│ browser │◀──────────────▶│ server │
│ canvas │ RenderSnapshot│ Terminal + Pty │
│ keydown │ key events │ (your core, unchanged) │
└──────────┘ └────────────────────────────────────┘
| Needs | Must not be forced to take |
|---|---|
RenderSnapshot to be serializable | Any local rendering |
| Damage-based incremental updates (bandwidth matters) | Full frames every time |
| Input encoding to happen server-side, where the modes live | Client knowledge of DECCKM |
Proves: the render model is genuinely renderer-independent, and — because it is the same shape as the mux protocol — that your mux design generalizes. If you built the mux, this is nearly free: the transport changes and nothing else does.
Watch out: RenderSnapshot must derive Serialize. If it contains borrowed slices or non-Send
types, this scenario fails and so does off-thread rendering.
Scenario 5: A Test Harness
This is your terminal-cli plus your golden tests. The consumer with the strictest requirements,
because it needs no OS at all for the replay path.
| Needs | Must not be forced to take |
|---|---|
To construct a Terminal, feed bytes, and inspect state | A PTY (for replay) |
| Determinism | Threads, timers, or any wall-clock dependency |
| To run thousands of times per second | Any I/O |
#![allow(unused)] fn main() { #[test] fn erase_uses_the_current_background() { // No PTY. No shell. No window. No threads. 5 microseconds. let mut t = Terminal::new(24, 80, TerminalConfig::default()); t.advance(b"\x1b[41m\x1b[2J"); assert_eq!(t.screen().cell(0, 0).style().bg, Color::Indexed(1)); } }
Proves: the core has no hidden dependencies. If a test needs a PTY to construct a Terminal, you
have a hidden dependency, and every test in the suite pays for it in time and flakiness.
This is also the scenario that keeps you honest over time. A test suite that runs in two seconds gets run; one that takes two minutes gets skipped. The boundary is what buys the two seconds.
Scenario 6: A Headless Terminal Simulator
The purest consumer: no PTY, no display, no process. Bytes in, screen out. Used for fuzzing, for
asciinema-style playback, for CI checks on terminal output, and for a browser demo via WASM.
/// The forty-line program that proves the boundary. If this needs ANYTHING /// beyond terminal-core, the boundary is wrong. fn main() { let mut term = Terminal::new(24, 80, TerminalConfig::default()); let mut input = Vec::new(); std::io::stdin().read_to_end(&mut input).unwrap(); term.advance(&input); print!("{}", term.snapshot_text()); }
And the WASM version:
#![allow(unused)] fn main() { #[wasm_bindgen] pub struct WasmTerminal { inner: Terminal } #[wasm_bindgen] impl WasmTerminal { #[wasm_bindgen(constructor)] pub fn new(rows: usize, cols: usize) -> Self { Self { inner: Terminal::new(rows, cols, TerminalConfig::default()) } } pub fn advance(&mut self, bytes: &[u8]) { self.inner.advance(bytes); } pub fn snapshot_json(&self) -> String { self.inner.snapshot_json() } } }
wasm-pack build --target web crates/terminal-wasm
# If this fails, something in the chain touched the OS. Find it.
Proves: everything. This is the acceptance test for Milestone 13, and it is the reason
terminal-core was forbidden from taking libc on day one.
The Requirement Matrix
What each scenario needs. Read the columns: a crate needed by only one scenario is suspicious; one needed by all six is genuinely core.
| Desktop | Mux | IDE panel | Remote | Test harness | Headless | |
|---|---|---|---|---|---|---|
terminal-protocol | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
terminal-core | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
terminal-render-model | ✅ | ✅ | ✅ | ✅ | — | — |
terminal-input | ✅ | ✅ | ✅ | ✅ | — | — |
terminal-pty | ✅ | ✅ | ✅ | ✅ | partly | ❌ |
terminal-gui | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Owns the event loop | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ |
| Owns a window | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| Needs serialization | — | ✅ | — | ✅ | — | — |
Needs wasm32 | — | — | maybe | — | — | ✅ |
Two rows do all the work:
- "Owns the event loop" — the IDE panel says no, and that single ❌ is why your API must be
advance()/tick()rather thanrun(). - "Needs
wasm32" — the headless simulator says yes, and that is whyterminal-coremay not take an OS dependency.
Design for those two constraints and the other four scenarios come free.
The API Shape That Satisfies All Six
#![allow(unused)] fn main() { // ✓ The shape that works everywhere. impl Terminal { pub fn new(rows: usize, cols: usize, config: TerminalConfig) -> Self; pub fn advance(&mut self, bytes: &[u8]); // caller drives; no loop pub fn resize(&mut self, rows: usize, cols: usize); pub fn screen(&self) -> &Screen; // borrow, do not copy pub fn damage(&self) -> &Damage; pub fn clear_damage(&mut self); pub fn take_replies(&mut self) -> Vec<u8>; // effects OUT, caller performs pub fn take_clipboard_write(&mut self) -> Option<(Selection, Vec<u8>)>; pub fn set_config(&mut self, config: TerminalConfig); // live reload, no global } // ✗ Shapes that break at least one scenario: // fn run(&mut self) -> ! → the IDE panel cannot use it // fn new() reading a global config → the mux cannot have per-pane config // fn on_reply(&mut self, cb: impl Fn) → callers' types leak into signatures; // breaks Send/serialization // fn render(&self, surface: &mut Surface) → couples the core to a renderer // fn spawn_shell(&mut self) → couples the core to the OS; kills WASM }
Experiment
CLAIM. Your core genuinely satisfies all six scenarios — or you can find exactly which one it fails and why.
METHOD. Write the smallest possible version of each. Time-box each to thirty minutes.
# 1. Headless simulator — 40 lines. The acceptance test.
cargo new --bin experiments/headless
# read stdin → Terminal::advance → print snapshot
echo -e '\033[31mred\033[0m\nhello' | cargo run -p headless
# 2. Test harness — you already have it.
cargo test -p terminal-core
# 3. WASM — the strictest.
cargo build --target wasm32-unknown-unknown -p terminal-core
# 4. IDE panel — a widget in an egui/iced app that does NOT own the loop.
cargo new --bin experiments/panel
# 5. Remote viewer — serialize a RenderSnapshot to JSON, over a socket, and
# render it in a second process.
cargo new --bin experiments/remote
# 6. Mux and desktop — you built them.
PREDICTION. Before starting: which scenario will fail first, and what will the error be?
RESULT. For each failure, the fix is a boundary change, not a workaround. Record what you changed and which scenario forced it. That list is your architecture's justification.
Test
#![allow(unused)] fn main() { #[test] fn core_constructs_with_no_os_facilities() { // No PTY, no threads, no files, no clock. let mut t = Terminal::new(24, 80, TerminalConfig::default()); t.advance(b"hello"); assert!(t.snapshot_text().starts_with("hello")); } #[test] fn many_terminals_with_different_configs_coexist() { // The mux scenario. Impossible with a global config. let a = Terminal::new(24, 80, TerminalConfig { scrollback_limit: 100, ..Default::default() }); let b = Terminal::new(40, 120, TerminalConfig { scrollback_limit: 50_000, ..Default::default() }); assert_ne!(a.config().scrollback_limit, b.config().scrollback_limit); } #[test] fn render_snapshot_is_send_and_serializable() { // Required by: off-thread rendering, the mux protocol, and the remote viewer. fn assert_send<T: Send>() {} assert_send::<RenderSnapshot>(); let t = Terminal::new(5, 10, TerminalConfig::default()); let snap = RenderSnapshot::from_terminal(&t, &Theme::default(), None); let json = serde_json::to_string(&snap).unwrap(); let back: RenderSnapshot = serde_json::from_str(&json).unwrap(); assert_eq!(back.rows, snap.rows); } #[test] fn the_core_never_drives_its_own_loop() { // A compile-time property, asserted socially: grep for a `run` method that // does not return. The IDE-panel scenario depends on this. let src = include_str!("../src/terminal.rs"); assert!(!src.contains("pub fn run(&mut self) -> !")); } #[test] fn effects_are_queued_not_performed() { // The take_* pattern: the core never touches a clipboard, an fd, or a window. let mut t = Terminal::new(5, 10, TerminalConfig { clipboard_policy: ClipboardPolicy::WriteOnly, ..Default::default() }); t.advance(format!("\x1b]52;c;{}\x1b\\", base64("hi")).as_bytes()); assert_eq!(t.take_clipboard_write().unwrap().1, b"hi"); assert!(t.take_clipboard_write().is_none(), "take must drain"); } }
Validation / Self-check
- Name the six scenarios and one thing each needs that the others do not.
- Which two matrix rows determine the API shape, and what do they force?
- Why does the IDE panel scenario forbid a
run()method? - Why does the headless simulator forbid an OS dependency in the core?
- Why must
RenderSnapshotbeSendand serializable? Name three consumers that need it. - Why is a callback-based reply API worse than
take_replies()? Give two concrete costs. - Why can the mux not tolerate a global configuration?
- Which scenario did your core fail first, and what did you change?
- Write the headless simulator from memory. How many lines, and which crates?
- A colleague proposes adding
Terminal::spawn_shell()for convenience. Which scenarios break?
Next: FFI and Bindings.