Daedalus, PTY pipeline
How the embedded terminal runs and displays shell commands
Daedalus's embedded terminal lets operators see the output of gh, doctl, ssh, podman compose commands as they run. The pipeline involves a Rust-side PTY and a frontend xterm.js renderer.
The pipeline
Wizard step needs to run a command
→ invoke("terminal_exec", { command: "podman compose up -d" })
→ Rust: pty.write(command + "\n")
→ portable-pty wrote to the bash subprocess's stdin
→ bash executes; stdout/stderr stream back
→ portable-pty reads, emits chunks
→ Rust: sanitize(chunk), strip secrets
→ tauri::Manager::emit("pty_output", sanitized_chunk)
→ Frontend xterm.js writes to terminal displayComponents
Rust side: daedalus/src-tauri/src/commands/pty.rs
A single persistent portable-pty instance per Daedalus session. Owns:
- A
Childprocess (bash). - A
Readerfor stdout/stderr. - A
Writerfor stdin. - A ring buffer (last 100KB) for
terminal_read.
static PTY: Lazy<Mutex<PtyState>> = Lazy::new(|| Mutex::new(...));
#[tauri::command]
async fn terminal_exec(window: tauri::Window, command: String) -> Result<(), String> {
let pty = PTY.lock();
pty.write(format!("{}\n", command).as_bytes())?;
Ok(())
}Reader task
A spawned Tokio task continuously reads from the PTY:
tokio::spawn(async move {
let mut buf = vec![0; 1024];
while let Ok(n) = reader.read(&mut buf).await {
if n == 0 { break; }
let chunk = String::from_utf8_lossy(&buf[..n]);
let sanitized = sanitize_secrets(&chunk);
ring_buffer.push(&sanitized);
window.emit("pty_output", sanitized).ok();
}
});Sanitizer: daedalus/src-tauri/src/commands/sanitize.rs
Regex-based redaction of common secret patterns. See Internals, Daedalus secrets sanitizer.
Frontend: daedalus/src/components/terminal.tsx
Subscribes to pty_output events:
import { listen } from "@tauri-apps/api/event";
import { Terminal } from "@xterm/xterm";
useEffect(() => {
const term = new Terminal();
term.open(termRef.current);
const unlisten = listen<string>("pty_output", e => {
term.write(e.payload);
});
return () => { unlisten.then(fn => fn()); };
}, []);xterm.js handles terminal escape sequences (colors, cursor positioning).
Ring buffer
The last 100KB of output is held in memory. terminal_read (MCP tool) returns this on demand.
100KB ≈ 1500 lines of typical command output. Anything older falls off.
Why one persistent PTY
- Cwd persists. A
cd /opt/olympusin one command sticks for the next. - Env vars persist. Setting
export FOO=barworks. - No spawn overhead per command.
The trade-off: one slow / hung command blocks subsequent commands. Daedalus's UI displays a "command running" indicator; the operator can Ctrl+C via xterm.js (which forwards to the PTY).
What can go wrong
- PTY size mismatch, xterm.js's columns/rows must match the PTY's. Daedalus calls
resize_pty(cols, rows)on window resize. - Stuck reader, if the bash subprocess hangs, the reader task blocks. Recovery: kill Daedalus, restart.
- Ring buffer overflow, large outputs (e.g.
docker pullof many layers) overflow quickly. Critical info may scroll off. Mitigate by piping to a file:command | tee /tmp/log.
What it's NOT
Daedalus's terminal is not a general-purpose shell, it's a wizard tool. Operators do their normal shell work in their normal terminal. The Daedalus PTY exists for the specific commands the wizard needs to run (provisioning, deploying, etc.).