Skip to content

Emulators and Pseudo-Terminals

One-shot commands and embedded terminals are built from two lower layers, which this page covers for code that needs them directly: a pseudo-terminal, which runs a program as a terminal window does and hands back the bytes it writes, and a terminal emulator, which turns those bytes into what the terminal shows. Neither knows about the other, so output recorded ahead of time can be fed to an emulator with no program run, and a program's output can go somewhere other than an emulator.

Pseudo-terminals need Linux or macOS.

Pseudo-terminals

run_pty runs a program on a new pseudo-terminal until it exits, in its own session with the terminal as its controlling terminal, calling on_output with what the program writes, standard output and standard error together. It returns how the program exited (Exited), why it couldn't start (FailedToStart), or that it outran its timeout (TimedOut), and hands out a PtyConnection that writes to the program without blocking and resizes its terminal. Cancelling it hangs the program up, as closing a terminal window does.

use_pty runs a program while its component is mounted, as a terminal window or a multiplexer's pane does: it starts once its size is positive, resizes the terminal as its size changes, and starts the program again when its argv or environment change. It hangs the program up in the background, so a program slow to exit doesn't hold up the app's rendering.

Hanging up in the background

The render loop waits for a cancelled effect to finish before it renders again, and hanging up a program that ignores SIGHUP takes up to two hangup_graces. So use_pty, and use_command for each run, call run_pty in a task of the app's task group and await that task through asyncio.shield, which keeps their effect's cancellation from waiting for it:

sequenceDiagram
    participant L as Render loop
    participant E as Effect
    participant R as run_pty, in the app's task group

    L->>E: Start the effect
    E->>R: Start run_pty and await it through shield
    alt The program exits, or outruns its timeout
        R->>R: Hang up whatever it left running
        R-->>E: Exited or TimedOut
        E->>E: Set the status, or report the run's outcome
    else Cancelled, by cancel, another run or argv, or unmounting
        L->>E: Cancel, then wait for it to finish
        E->>R: Cancel
        E-->>L: Finished at once
        Note over L: Renders again
        R->>R: Hang up the program, for up to two hangup_graces
        R-->>R: Ends cancelled, with no outcome to return
    end
    Note over L,R: An app that stops waits for every hangup still going

A program that ends by itself returns its outcome through the effect as usual, but only once run_pty has hung up what it left running. Only that effect waits for it, not the render loop, but a program that exits leaving a background job that ignores SIGHUP reads as running until that job is gone. A cancelled run has no outcome to return: cancel has already made it Cancelled, and another run or argv reports its own.

Emulators

An Emulator is fed the bytes a program writes, answers the program's queries through on_reply, and snapshot turns what its terminal shows into an immutable Screen. A snapshot of a terminal that didn't change is the same Screen as before, and rows that didn't change are shared between snapshots, so a canvas drawing one redraws only when it changed. scrolled_off returns the rows that scrolled off the top of the screen since it was last called, for an emulator given a scrollback.

ScreenView is the Canvas painter that draws a Screen, with its cursor when asked, and Transcript the one that draws everything a program printed.

Colors

A program's colors reach the app's terminal as the program chose them, so they follow the user's theme as they would had the program run there: a palette color (ESC [ 31 m, or ESC [ 38 ; 5 ; n m) stays an IndexedColor, and an RGB color a Color. A palette entry the program changed (with OSC 4) is the RGB color it changed it to, since the user's terminal doesn't know it.

A cell in the program's default colors leaves them unset, so it follows the text_style of the canvas showing it. To have those follow the user's theme too, give the canvas the terminal's default colors, as text_terminal_default | text_bg_terminal_default | content_terminal_default (see Terminal colors). A cell whose colors the program swapped (inverse video) or hid (invisible text) has to name its default colors, so it takes the emulator's foreground and background instead, which are white and black unless the program changes them (with OSC 10 and 11). set_default_colors changes them on a running emulator, as the user changing their terminal's theme does, though a color the program set stays what it set.

API

counterweight.pseudoterminal.run_pty async

run_pty(
    argv: Sequence[str],
    columns: int,
    rows: int,
    on_output: Callable[[bytes], None],
    on_start: Callable[[PtyConnection], None] = ...,
    env: Mapping[str, str] = ...,
    clear_env: bool = ...,
    stdin: StandardInput = ...,
    hangup_grace: timedelta = ...,
    timeout: None = None,
) -> Exited | FailedToStart
run_pty(
    argv: Sequence[str],
    columns: int,
    rows: int,
    on_output: Callable[[bytes], None],
    on_start: Callable[[PtyConnection], None] = ...,
    env: Mapping[str, str] = ...,
    clear_env: bool = ...,
    stdin: StandardInput = ...,
    hangup_grace: timedelta = ...,
    timeout: timedelta | None = ...,
) -> Exited | FailedToStart | TimedOut
run_pty(
    argv: Sequence[str],
    columns: int,
    rows: int,
    on_output: Callable[[bytes], None],
    on_start: Callable[
        [PtyConnection], None
    ] = lambda connection: None,
    env: Mapping[str, str] = frozendict(),
    clear_env: bool = False,
    stdin: StandardInput = "pty",
    hangup_grace: timedelta = HANGUP_GRACE,
    timeout: timedelta | None = None,
) -> Exited | FailedToStart | TimedOut

Runs argv on a new pseudo-terminal columns by rows, in its own session with the terminal as its controlling terminal, until it exits.

PARAMETER DESCRIPTION
on_output

Called on the event loop with each read of what the program writes, standard output and standard error together, as a terminal shows them.

TYPE: Callable[[bytes], None]

on_start

Called once the program is running, with the connection to write to it and resize its terminal.

TYPE: Callable[[PtyConnection], None] DEFAULT: lambda connection: None

env

Variables to set in the program's environment. The program gets the app's environment (unless clear_env), without COLUMNS and LINES, then TERM=xterm-256color and COLORTERM=truecolor over it, then env over those.

TYPE: Mapping[str, str] DEFAULT: frozendict()

clear_env

Leave out the app's environment, so the program gets only the terminal's variables and env.

TYPE: bool DEFAULT: False

stdin

Where the program reads its standard input from.

TYPE: StandardInput DEFAULT: 'pty'

hangup_grace

How long the program has to exit after each signal that ends it (see below).

TYPE: timedelta DEFAULT: HANGUP_GRACE

timeout

How long the program may run before it's hung up and TimedOut is returned, or None for no limit. Only the program's own run counts against it: once it exits, it has exited, however long reading what it wrote last and hanging up what it left running take.

TYPE: timedelta | None DEFAULT: None

RETURNS DESCRIPTION
Exited | FailedToStart | TimedOut

How the program exited, why it couldn't start, or that it ran out of time.

Once the program exits, what it wrote just before is read for up to DRAIN_TIMEOUT. Then, or when cancelled or out of time, the program is hung up, as closing a terminal window does: its process group, which includes anything it started in the background, gets SIGHUP, and whatever is still running hangup_grace later gets SIGTERM, then SIGKILL after another, so no program outlives the call that ran it. Cancelled while hanging up, it sends SIGKILL at once. What the program writes while it's hung up isn't passed to on_output.

counterweight.pseudoterminal.PtyConnection dataclass

PtyConnection(
    master: int,
    pid: int,
    pending: bytearray = bytearray(),
    is_writing: bool = False,
    is_closed: bool = False,
)

The master side of a running program's pseudo-terminal, which run_pty hands out while the program runs. Writes that the program isn't reading yet are buffered and flushed when the terminal is writable, so a handler that writes never blocks the event loop. Once run_pty returns, writing and resizing do nothing.

master instance-attribute

master: int

pid instance-attribute

pid: int

pending class-attribute instance-attribute

pending: bytearray = field(default_factory=bytearray)

is_writing class-attribute instance-attribute

is_writing: bool = False

is_closed class-attribute instance-attribute

is_closed: bool = False

write

write(data: bytes) -> None

flush

flush() -> None

stop_writing

stop_writing() -> None

resize

resize(columns: int, rows: int) -> None

Sets the terminal's size, which sends the program SIGWINCH, as resizing a terminal window does.

close

close() -> None

counterweight.pseudoterminal.StandardInput

StandardInput = Literal['pty', 'devnull']

Where the program reads its standard input from: its terminal, for a program that takes keys, or /dev/null, so a program that reads input gets end of file rather than waiting for keys that never come.

counterweight.pseudoterminal.use_pty

use_pty(
    argv: Sequence[str],
    columns: int,
    rows: int,
    on_output: Callable[[bytes], None],
    env: Mapping[str, str] = frozendict(),
    clear_env: bool = False,
    hangup_grace: timedelta = HANGUP_GRACE,
) -> Pty

Runs argv on a pseudo-terminal while the calling component is mounted, as a terminal window does, with its terminal columns by rows, reading its standard input from the terminal. See run_pty for env, clear_env and hangup_grace, and for how the program is hung up when the component unmounts.

The program starts once columns and rows are both positive, so a component that sizes the terminal from its own box with use_rects, which is zero on its first render, starts the program at its real size. Later changes to them resize the terminal, which sends the program SIGWINCH, except to a size that isn't positive. A change to argv, env or clear_env hangs the program up and starts it again. Hanging up goes on in the background, so a program slow to exit doesn't hold up the app's rendering, and an app that stops waits for it.

PARAMETER DESCRIPTION
on_output

Called, from the event loop rather than during a render, with each read of what the program writes. The latest render's on_output is the one called.

TYPE: Callable[[bytes], None]

counterweight.pseudoterminal.Pty dataclass

Pty(write: Callable[[bytes], None], status: PtyStatus)

What use_pty returns.

write instance-attribute

write: Callable[[bytes], None]

Sends bytes to the program, as typing into its terminal does. It does nothing while the program isn't running, and is the same function on every render.

status instance-attribute

status: PtyStatus

counterweight.pseudoterminal.PtyStatus

counterweight.pseudoterminal.Starting dataclass

Starting()

The program hasn't started yet: its terminal is waiting for a size, or the program is being spawned.

counterweight.pseudoterminal.Running dataclass

Running()

The program is running.

counterweight.emulator.Emulator

Emulator(
    columns: int,
    rows: int,
    on_reply: Callable[[bytes], None],
    foreground: Color = WHITE,
    background: Color = BLACK,
    scrollback: int = 0,
)

A terminal emulator columns wide and rows tall, fed what a program writes.

on_reply receives the bytes the emulator answers the program's queries with (cursor position reports, device attributes), which belong on the program's input, as a terminal would send them. foreground and background are the colors the program's default colors stand for wherever a cell has to name them (see Screen.background), until the program changes them. Its palette starts as DEFAULT_PALETTE.

scrollback is how many of the rows that scroll off the top of the screen scrolled_off can return, at most MAX_SCROLLBACK.

Use it as a context manager, or call close, to free the emulator's memory as soon as it's done with.

scrollback instance-attribute

scrollback = scrollback

terminal instance-attribute

terminal = Terminal(
    cols=columns,
    rows=rows,
    scrollback=scrollback
    and scrollback + rows_per_page(columns),
    fg=foreground,
    bg=background,
)

state instance-attribute

state = ffi.new('GhosttyRenderState*')

row_iterator instance-attribute

row_iterator = ffi.new('GhosttyRenderStateRowIterator*')

cells instance-attribute

cells = ffi.new('GhosttyRenderStateRowCells*')

free instance-attribute

free = weakref.finalize(
    self,
    free_render_state,
    self.state[0],
    self.row_iterator[0],
    self.cells[0],
)

cursor instance-attribute

cursor = init_sized('GhosttyRenderStateCursor')

colors instance-attribute

colors = init_sized('GhosttyRenderStateColors')

style instance-attribute

style = init_sized('GhosttyStyle')

rgb instance-attribute

rgb = ffi.new('GhosttyColorRgb*')

palette_index instance-attribute

palette_index = ffi.new('GhosttyColorPaletteIndex*')

cells_view instance-attribute

cells_view = ffi.new('GhosttyCellsView*')

wide instance-attribute

wide = ffi.new('int*')

has_styling instance-attribute

has_styling = ffi.new('bool*')

content_tag instance-attribute

content_tag = ffi.new('int*')

codepoint instance-attribute

codepoint = ffi.new('uint32_t*')

style_id instance-attribute

style_id = ffi.new('GhosttyStyleId*')

classify_keys instance-attribute

classify_keys = ffi.new(
    "GhosttyCellData[5]",
    [
        lib.GHOSTTY_CELL_DATA_WIDE,
        lib.GHOSTTY_CELL_DATA_HAS_STYLING,
        lib.GHOSTTY_CELL_DATA_CONTENT_TAG,
        lib.GHOSTTY_CELL_DATA_CODEPOINT,
        lib.GHOSTTY_CELL_DATA_STYLE_ID,
    ],
)

classify_values instance-attribute

classify_values = ffi.new(
    "void*[5]",
    [
        ffi.cast("void*", self.wide),
        ffi.cast("void*", self.has_styling),
        ffi.cast("void*", self.content_tag),
        ffi.cast("void*", self.codepoint),
        ffi.cast("void*", self.style_id),
    ],
)

grapheme_length instance-attribute

grapheme_length = ffi.new('uint32_t*')

grapheme_buffer instance-attribute

grapheme_buffer = ffi.new('uint32_t[32]')

is_dirty instance-attribute

is_dirty = ffi.new('bool*')

row_id instance-attribute

row_id = ffi.new('GhosttyRenderStateRowId*')

title_out instance-attribute

title_out = ffi.new('GhosttyString*')

overscan instance-attribute

overscan = ffi.new('GhosttyRenderStateOverscan*')

captured instance-attribute

captured = ffi.new('GhosttyRenderStateOverscan*')

plain instance-attribute

plain: dict[int, tuple[StyledGrapheme, ...]] = {}

converted instance-attribute

converted: dict[
    tuple[int, int], tuple[StyledGrapheme, ...]
] = {}

last instance-attribute

last: Screen | None = None

palette instance-attribute

palette: bytes

pending instance-attribute

pending: list[tuple[StyledGrapheme, ...]] = []

last_scrolled_off instance-attribute

last_scrolled_off: tuple[int, int] | None = None

__enter__

__enter__() -> Self

__exit__

__exit__(
    exc_type: type[BaseException] | None,
    exc_value: BaseException | None,
    traceback: TracebackType | None,
) -> None

feed

feed(data: bytes) -> None

Feed the emulator bytes the program wrote, raising whatever on_reply raised while it ran.

resize

resize(columns: int, rows: int) -> None

Resize the terminal, reflowing what it shows, as resizing a terminal window does.

set_default_colors

set_default_colors(
    foreground: Color, background: Color
) -> None

Changes the colors the program's default colors stand for wherever a cell has to name them (see Screen.background), as the user changing their terminal's theme does, keeping everything else the terminal shows. A color the program set (with OSC 10 or 11) stays what it set.

close

close() -> None

Free the emulator's memory. It can't be fed or snapshotted after.

snapshot

snapshot() -> Screen

What the terminal shows now.

scrolled_off

scrolled_off() -> Rows

The rows that scrolled off the top of the screen since the last call, oldest first, or the newest scrollback of them if more did.

It returns each row once, as it was when it scrolled off, so a program's whole output is what each call returns in turn, followed by the screen. Erasing the scrollback (ESC [ 3 J) doesn't take back rows it already returned, and a program drawing on the alternate screen scrolls nothing off.

update

update() -> tuple[Color, Color]

Brings the render state up to date with the terminal, setting aside the rows that scrolled off the screen since the last update, and returns the colors the program's default colors stand for.

The render state's rows stay dirty until a snapshot cleans them, however many updates come first.

request_overscan

request_overscan(above: int) -> None

Asks the next update to capture above rows above the screen, or as many as there are.

row_ids

row_ids(count: int) -> list[tuple[int, int]]

The ids of the first count rows the render state captured, top to bottom.

convert_row

convert_row(
    rows: object, foreground: Color, background: Color
) -> tuple[StyledGrapheme, ...]

The cells of the row rows is at.

A styled cell's value holds its style's id within the row's page, so within one row, a cell value that's a lone codepoint maps to the same cells wherever it recurs, and a style id to the same style, which saves reading the style's colors and attributes again.

erased_style

erased_style(value: int, content: int) -> CellStyle

The style of a blank cell whose value holds the background the program erased it with, and no style.

read_style

read_style(
    cells: object, foreground: Color, background: Color
) -> CellStyle

The style of the cell cells is at, leaving colors that are the program's defaults unset, but where it has to name them, as foreground and background.

style_color

style_color(color: object) -> AnyColor | None

A style's color, or None for the program's default.

palette_color

palette_color(index: int) -> AnyColor

Palette entry index, which follows the user's theme as an IndexedColor, unless the program changed it, since the user's terminal doesn't know the color it changed it to.

cursor_position

cursor_position() -> Position | None

Where the cursor is, at the first column of a wide cell it's on, or None if it's hidden.

modes

modes() -> Modes

The input modes the program has set.

title

title() -> str

The title the program set (with OSC 0 or 2), or an empty one.

counterweight.emulator.Screen dataclass

Screen(
    columns: int,
    rows: int,
    lines: Rows,
    cursor: Position | None,
    modes: Modes,
    title: str,
    foreground: Color,
    background: Color,
)

What a program's terminal shows at one moment: its rows of cells, its cursor, its modes and its title.

Compared by identity: a snapshot of a terminal that changed is a new Screen, and a snapshot of one that didn't is the same Screen as before, so a canvas drawing it draws again only when the program's output changed it. A row the program didn't change is the same tuple as in the previous snapshot.

columns instance-attribute

columns: int

rows instance-attribute

rows: int

lines instance-attribute

lines: Rows

One row of cells for each of the terminal's rows, each taking exactly columns cells.

A cell in the program's default colors leaves them unset, so they follow the style of the canvas showing it, and one in a palette color is an IndexedColor, which follows the user's theme, unless the program changed that palette entry (with OSC 4), which makes it the RGB Color the program chose. Where the emulator and counterweight's grapheme_width disagree on how wide a grapheme cluster is, the row keeps the emulator's columns, which the program laid its line out by: a cluster narrower here is followed by spaces, and one wider here, or with no width, is drawn as spaces.

cursor instance-attribute

cursor: Position | None

Where the cursor is, or None if the program hid it.

modes instance-attribute

modes: Modes

title instance-attribute

title: str

foreground instance-attribute

foreground: Color

background instance-attribute

background: Color

The colors that the program's default colors stand for wherever a cell has to name them: where the program swapped a cell's colors (inverse video, and the drawn cursor) or made its text the color of its background (invisible text).

counterweight.emulator.Modes dataclass

Modes(
    application_cursor_keys: bool = False,
    bracketed_paste: bool = False,
    mouse_tracking: MouseTracking = "none",
    sgr_mouse: bool = False,
    focus_events: bool = False,
    alternate_screen: bool = False,
)

The modes a program has set that change how its input must be encoded.

application_cursor_keys class-attribute instance-attribute

application_cursor_keys: bool = False

DECCKM (?1): cursor keys send ESC O A rather than ESC [ A.

bracketed_paste class-attribute instance-attribute

bracketed_paste: bool = False

?2004: pastes are wrapped in ESC [ 200 ~ and ESC [ 201 ~.

mouse_tracking class-attribute instance-attribute

mouse_tracking: MouseTracking = 'none'

Which mouse events the program asked for (?9, ?1000, ?1002, ?1003), if any.

sgr_mouse class-attribute instance-attribute

sgr_mouse: bool = False

?1006: mouse events are reported in SGR format.

focus_events class-attribute instance-attribute

focus_events: bool = False

?1004: the program wants ESC [ I and ESC [ O when it gains and loses focus.

alternate_screen class-attribute instance-attribute

alternate_screen: bool = False

?1049 (or ?47, ?1047): the program is drawing on the alternate screen, as full-screen programs do.

counterweight.emulator.MouseTracking

MouseTracking = Literal[
    "none", "x10", "normal", "button", "any"
]

Which mouse events a program asked its terminal to report, from fewest to most.

counterweight.emulator.MAX_SCROLLBACK module-attribute

MAX_SCROLLBACK = 2 ** 16 - 1

The most rows an emulator's scrollback can hold: as many as libghostty captures above its screen at once.

counterweight.painters.terminal.ScreenView dataclass

ScreenView(screen: Screen, show_cursor: bool = False)

A Canvas painter that draws a terminal's screen, with its cursor as a cell with its colors swapped when show_cursor is set, such as while the canvas is focused.

The box sizes the terminal, not the other way round, so its natural size is zero: size the canvas with its style, and resize the terminal to the canvas's content box. Cells in the program's default colors follow the canvas's text_style, and rows and columns past the box's edges are cut off.

screen instance-attribute

screen: Screen

show_cursor class-attribute instance-attribute

show_cursor: bool = False

natural_size property

natural_size: tuple[int, int]

keeps_aspect_ratio property

keeps_aspect_ratio: bool

draw

draw(width: int, height: int) -> Rows

The screen's top height rows, with the cursor drawn if it's shown and among them.