C+
Packages · View as Markdown

terminal

A native terminal widget for C+ on macOS and Linux, with a portable screen model that also builds on Windows.

The package provides:

  • terminal/terminal: a platform-neutral VT screen with scrollback.
  • terminal/pty: a real POSIX pseudo-terminal session — forkpty on macOS and Linux, with shell integration installed into zsh.
  • terminal/backend: the platform half — AppKit on macOS and GTK 4 on Linux, with automatic PTY reads, keyboard forwarding, paste, resize propagation, and bounded scrollback.
  • terminal/widget: the portable facet-facing wrapper applications should normally import.

terminal/backend was terminal/appkit until 2026-09-08. The rename is what let a second platform exist at all: the module naming one platform was the one module whose job is not to, and the package did not build off macOS. Anything importing terminal/appkit should import terminal/backend, or better, terminal/widget.

Windows: the screen model and the package build and pass, the widget does not exist yet. terminal/terminal is platform-neutral and fully exercised there. What is missing is a live session: a ConPTY backend was written in full against this seam and BACKED OUT, because on the host it was developed on it produces zero bytes — CreatePseudoConsole returns S_OK, the host process spawns, the child exits, and the pipe stays empty, with every explanation ruled out by measurement. Shipping a terminal that shows nothing is worse than not shipping one. The recipe is preserved in the header of src/pty_windows.cplus for whoever picks it up on a machine where it works.

The model is a grid of cells addressed by row and column, so clear, the alternate screen, scroll regions and absolute cursor addressing all work — top, vim and less take the pane and give it back. Per-cell colour and attributes are not modelled: SGR is parsed and dropped, so a renderer draws one uniform run of text. Mouse reporting and sixel are not claimed.

Core use

import "terminal/terminal" as terminal;

var screen = terminal::new(rows: 24 as u16, cols: 80 as u16);
screen.feed(bytes_from_a_pty);
show(screen.view());

Native widget

Add the package with cpc pm add . terminal; the command writes the native backend's dependency closure.

[dependencies]
terminal = "*"
stdlib   = "*"

Keep the Widget alive for as long as the terminal should remain interactive. Create, use, and drop it on the platform UI thread. It owns the shell and shuts it down on stop() or drop.

import "terminal/widget" as terminal;
import "stdlib/option" as option;

var term = match terminal::start(cwd: project_path) {
    option::Option[terminal::Widget]::Some(w) => w,
    option::Option[terminal::Widget]::None => { /* show an error */ }
};

// Portable facet node. Keep `term` alive beside the mounted tree.
let node = term.node().grow(1.0f64);

term.send(bytes) writes programmatic input, term.text() snapshots the pane, and term.is_running() reports whether the PTY is still active.

Running commands

An application that builds and runs a project in its pane has to know when the build finished and whether it worked. A terminal cannot infer either from the byte stream — the shell is the only party that knows — so terminal/pty installs zsh hooks that report it, and the widget surfaces the result.

let id: u64 = term.run("cpc build");     // 0 = refused: stopped, or still busy

match term.exit_code() {
    option::Option[i32]::Some(0) => { /* built */ }
    option::Option[i32]::Some(_) => { show(term.output()); }
    option::Option[i32]::None    => { /* still running */ }
}

output() is that command's own output as it reached the SCREEN — no prompt, no echo of the command, and none of the markers a shell writes and then erases. on_command_end(handler, ctx) fires on the main thread once per completion; command_state(), finished_count(), command_line() and cwd() are the pollable form. interrupt() is ^C.

With a shell other than zsh, has_integration() is false and run falls back to bracketing the command with marks of its own — still reporting the exit code, at the cost of two echoed printf lines.

Typing

The pane takes keys directly — printable characters, Return, Tab, Delete, arrows, Home/End, Page Up/Down, Escape, and control characters such as ^C. Arrows switch to the application form (ESC O A) when a full-screen program asks for it, and paste is bracketed when one asks for that. ⌘C / ⌘V / ⌘A copy, paste and select; paste goes to the shell, never into the view, because the view is read-only.

Keys go to whichever element holds the window's first responder, so an app that wants a terminal ready to type into says so:

fn on_attach(ref this) {
    let focused: bool = this.term.focus();   // false = not on screen yet
}

Call focus() again at the end of a handler that runs one of the app's own controls: clicking a button can take the first responder, and has_focus() reports where it is. A view mounted through the native escape hatch is not addressable by key, so facet::find(key) cannot do this — the widget owns the verb.

Apps working directly with AppKit can instead import terminal/backend and use view(), native_handle(), or the flex node(). Linux's backend exposes the same portable node() and native handle around its GTK widget.

Shell history

Off by default: an app's pane must not write the user's global shell history, which macOS caps at 1000 entries. save_history: true opts into the user's real history. Either way the shell loads the user's own rc files.

Docs

Status

macOS and Linux have live PTY/UI backends. The public session seam is kept to start/read/write/resize/close/poll_exit, which is what let the GTK backend and a Windows experiment use it without changing the screen model. The ConPTY attempt failed on its test host and was backed out; its recipe remains in src/pty_windows.cplus.

The package builds and its suite passes on Windows; only the live session is missing there. See the note at the top.

Run the tests with:

cd vendor/terminal
cpc test