Olympus Docs
InternalsDaedalus

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 display

Components

Rust side: daedalus/src-tauri/src/commands/pty.rs

A single persistent portable-pty instance per Daedalus session. Owns:

  • A Child process (bash).
  • A Reader for stdout/stderr.
  • A Writer for 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/olympus in one command sticks for the next.
  • Env vars persist. Setting export FOO=bar works.
  • 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 pull of 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.).

On this page