NativeDesktop

Terminal

Show a shell or any byte stream in a window with nd-terminal: TerminalSession, terminal(), theme colors, input, and what runs on each OS.

nd-terminal is a terminal widget. libghostty-vt parses the program's output and keeps the screen and scrollback. A pseudo-terminal or any byte stream carries the bytes, and a GPU presenter on each backend draws the grid. Your app owns a TerminalSession, and terminal(id, &session) shows it.

Example: Terminal. A terminal with tabs over libghostty-vt: one shell per tab, titled by what the shell reports. Source in examples/terminal.
Terminal on macOS

Captured on macOS 27.0, 2026-10-22.

Terminal on Linux

Captured on NixOS 26.11 (Zokor), Hyprland, 2026-10-22.

Terminal on Windows

Captured on Windows 11 26H2, 2026-10-22.

Add it

Depend on nd-terminal directly, or turn on nd's terminal feature. The example depends on the crate directly because Cargo unifies nd's features across a workspace, so the feature would link libghostty-vt into every example.

To build libghostty-vt, you need Zig 0.16 on PATH (the dev shell has it) and network access for the first build. Its build script compiles Ghostty in debug mode whenever the profile has debug info, and that build parses output thousands of times slower. Add this to the workspace:

[profile.dev.package.libghostty-vt-sys]
debug = false

A session

A session is the program plus the terminal state. Create one with:

match TerminalSession::spawn(&self.command, SessionOptions::default()) {
    Ok(session) => self.push(session),
    Err(e) => eprintln!("terminal: could not start a shell: {e}"),
}

From examples/terminal/src/lib.rs, line 42.

Command::shell() runs the user's login shell. To run something else, use Command::new(program) with .arg, .args, .cwd, and .env. SessionOptions sets the initial cols and rows (80 by 24) and the scrollback in lines (10 000). The grid resizes to fit the element, so these values matter only before the first layout.

The session belongs to the app, not the view. Keep it in your state, and a tab switch keeps its scrollback and its running program. Dropping the last clone ends the child process. Reads and writes run on their own threads with a bounded inbox, so a program that floods output or stops reading input never blocks the UI thread. The session parses at most a fixed budget of output per frame.

The element

terminal(SharedString::from(format!("term-{id}")), &tab.session)
    .size_full()
    .on_title_changed(cx.listener(move |this, e: &TitleChanged, _, cx| {
        if let Some(tab) = this.tabs.iter_mut().find(|t| t.id == id) {
            tab.title = e.title.clone().into();
            cx.notify();
        }
    }))
    .on_exit(cx.listener(move |this, _: &Exited, _, cx| {
        this.close(id);
        cx.notify();
    }))
    .on_link(|e: &LinkActivated, _, _| open_url(&e.link.url))

From examples/terminal/src/lib.rs, line 77.

Events arrive on the UI thread:

HandlerEventWhen
on_title_changedTitleChanged { title }The program set the title (OSC 0 or 2).
on_cwd_changedCwdChanged { cwd }OSC 7
on_exitExited { status }The program exited or the stream ended.
on_bellBellBEL
on_notificationNotification { title, body }OSC 9 or 777
on_selection_changedSelectionChanged { has_selection }A selection was made or cleared.
on_linkLinkActivated { link }A link was opened with Cmd-click (Ctrl-click outside macOS).
on_focus_changedFocusChangedFocus moved.

Links are OSC 8 hyperlinks and plain URLs. The element doesn't open them. The example passes the URL to open or xdg-open.

Use .font_size(points), .font_family(name), and .padding(points) to adjust the drawing. Without them, the element uses the platform's monospace font and size.

Input and selection

The session encodes keys itself. That includes the Kitty keyboard protocol when a program asks for it, bracketed paste, mouse reporting, and focus reports (mode 1004). A mouse press goes to a program that tracks the mouse; otherwise it starts a selection. Hold Shift to force a selection. Copy writes the selection to the clipboard. Wheel and trackpad scrolling go to the program when it tracks the mouse. On the alternate screen with alternate scroll on, scrolling turns into arrow keys. Otherwise it moves through the scrollback.

On macOS, the view implements NSTextInputClient, so input methods compose in it. GPUI backends use the platform text input.

Choose key bindings with care, because plain Ctrl-T and Ctrl-W belong to the shell. The example binds Cmd-T and Cmd-W on macOS, and Ctrl-Shift-T and Ctrl-Shift-W elsewhere:

let (new, close) = if cfg!(target_os = "macos") { ("cmd-t", "cmd-w") } else { ("ctrl-shift-t", "ctrl-shift-w") };
cx.bind_keys([
    KeyBinding::new(new, NewTab, Some("Terminals")),
    KeyBinding::new(close, CloseTab, Some("Terminals")),
]);

From examples/terminal/src/lib.rs, line 137.

To drive a session from code, write(bytes) sends typed input, paste(text) pastes, screen_text() returns the visible text, and scroll_rows(n) moves the viewport. title(), cwd(), exit_status(), and is_closed() read its state.

Colors

The 16 ANSI colors are theme tokens: cx.theme().terminal.ansi. The hues come from the semantic tokens: red is danger, green is success, yellow is warning, and blue is accent. Magenta and cyan rotate the accent's hue. Each one is adjusted to be legible as text on the base surface. Black and white swap between light and dark, as light terminal themes do. The background, foreground, cursor, and selection also come from the theme, so the terminal follows light mode, dark mode, and a new accent without any code.

To override them, pass TerminalColors to .colors(..). Each field is optional: background, foreground, cursor, selection, and ansi. The appearance audit accepts the theme-derived colors, but an override has to meet contrast on its own.

Box drawing, block elements, braille, and Powerline separators are drawn from geometry, so they meet at cell edges at any font size. Nerd Font icons come from a bundled Symbols Nerd Font Mono and shrink to fit their cell instead of being clipped.

What runs where

macOSLinuxWindows
Presentera Metal layer in its own NSViewGPUI rendererGPUI renderer
Pseudo-terminalyesportable-pty, not run yetConPTY through portable-pty, not run yet
Verifiedtier 1, tier 2 goldens, window test, scroll budgetnot runnot run

On macOS, the presenter is its own view with a CAMetalLayer, presented with the Core Animation transaction. The GPUI presenter has run only on macOS. Running it on Linux and Windows, and the ConPTY path, are open items in TODO.md. Kitty graphics are compiled out.

The scroll budget test scrolls 100 000 lines at 3 and 40 rows per frame. Main-thread time per frame was 0.6 ms at p95 on AppKit and 4.6 ms on GPUI.