TTY

Terminal input events, styled output, presentation surfaces, and local virtual viewports.

Every function resolves the terminal port attached to the calling process frame. A process on a Terminal Host owns the physical terminal; a process.lua on a regular process.host owns a virtual terminal when it is spawned with a viewport grant. Without either attachment the module returns "no terminal context".

Loading

local tty = require("tty")

Model

A Surface is one process's exclusive presentation lease on its terminal port. It publishes complete row snapshots; the backend owns diffing and terminal recovery. Only one surface may be open on a port at a time.

A Canvas is an in-process styled-cell composition buffer. It clips at cell boundaries and never emits terminal control commands of its own.

A Viewport is a local, structured terminal boundary that lets one process host another process's surface without sharing byte streams. The shell decides where viewport content appears and translates input into the child's coordinates; the child sees an ordinary terminal port and does not know whether it is full-screen, tiled, tabbed, or hidden.

Viewports are local to one runtime node. Grants and handles are opaque local capabilities, not serializable network references.

Input Loop

Start input delivery, subscribe to events, and process them in a loop:

local tty = require("tty")
local io = require("io")

local function handler()
    local events = tty.events()
    tty.start()

    while true do
        local ev, open = events:receive()
        if not open then break end

        if ev.type == "key" then
            if ev.key == "q" or (ev.ctrl and ev.key == "c") then
                break
            end
            local _, print_err = io.print("Key: " .. ev.key)
            if print_err then loop_err = print_err; break end

        elseif ev.type == "resize" then
            local _, print_err = io.print("Size: " .. ev.width .. "x" .. ev.height)
            if print_err then loop_err = print_err; break end
        end
    end

    local _, stop_err = tty.stop()
    if loop_err then return nil, loop_err end
    if stop_err then return nil, stop_err end
    return started
end

Call events() before start() so a consumer is ready when the first events arrive. On a virtual port, start() opens viewer-to-producer event delivery and stop() closes it: a Viewport:send() outside that interval fails instead of silently dropping input. Resize delivery is independent of input state.

Input Control

tty.start()

Start input delivery for the current port. A physical terminal switches to raw mode.

local ok, err = tty.start()

Returns: boolean, error

tty.stop()

Stop input delivery and restore the terminal to normal mode.

local ok, err = tty.stop()

Returns: boolean, error

tty.events()

Subscribe to the port's terminal events and return a channel. Events are delivered as tables with a type field. Subscribe once and reuse the channel.

local events, err = tty.events()

Returns: EventChannel, error

EventChannel has receive() and case_receive(), so it composes with channel.select.

tty.screen_size()

Read the current terminal dimensions.

local width, height, err = tty.screen_size()

Returns: number, number, error

tty.mouse(enable)

Enable or disable mouse event tracking.

local ok, err = tty.mouse(true)
Parameter Type Description
enable boolean true to enable, false to disable

Returns: boolean, error

Surface

A surface is the port's presentation lease. Acquire one, publish complete frames, and close it when done.

tty.surface(options?)

local surface, err = tty.surface({
    alternate_screen = true,
    hide_cursor = true,
    synchronized_output = true,
})
Option Type Default Description
alternate_screen boolean false Present on the terminal's alternate screen buffer
hide_cursor boolean false Hide the terminal cursor while the surface is open
synchronized_output boolean false Wrap each frame in synchronized-output markers

Returns: Surface, error

Opening a second surface on a port that already has one fails. A virtual port keeps the options as surface metadata; a physical port translates them into terminal modes and restores them on close.

surface:present(rows, options?)

Publish a complete array of row strings. Row 1 is the top line.

local stats, err = surface:present(rows, {
    cursor = {x = 12, y = 3, visible = true},
    images = {
        {placement_id = "logo", image = logo, x = 2, y = 2, cols = 20, rows = 8, alt = "Logo"},
    },
})
Parameter Type Description
rows string[] Complete frame, at most 16384 rows
options.cursor table {x, y, visible} in one-based surface coordinates
options.images table[] Complete retained-image placement set for the frame

Omitting cursor preserves the last explicit cursor state. All three cursor fields are required when cursor is present.

Returns: stats, error — an immutable record with rows, changed_rows, and bytes_written. A physical frame identical to the previous one writes nothing.

surface:invalidate()

Forget backend presentation state without erasing the logical frame. The next present commits even when its rows are unchanged. Use it after an outer terminal resize or when another owner may have disturbed physical state.

Returns: boolean

surface:close()

Release the lease. Idempotent: later calls return the first close result. A physical backend restores terminal modes.

Returns: boolean, error

surface:capabilities()

Return {images = "native" | "kitty" | "pending" | "none"}. Start terminal input before probing. A physical backend may briefly return pending while it queries the terminal; virtual surfaces retain images without probing.

Returns: table, error

surface:clipboard(text)

Write an OSC 52 clipboard request on a physical surface. Text must be valid UTF-8 and at most 65,536 bytes. Success means the terminal output accepted the request; terminal policy may still ignore it. Virtual surfaces return an unsupported error, and the API provides no clipboard read or acknowledgement.

Returns: boolean, error

Retained Images

Import a PNG into bounded runtime storage, then place its handle in a complete surface frame:

local image = assert(tty.image(png_bytes))
local info = image:info() -- id, format, width, height, bytes

assert(surface:present(rows, {images = {{
    placement_id = "preview",
    image = image,
    x = 1, y = 1, cols = 40, rows = 12,
    src = {x = 0, y = 0, width = info.width, height = info.height},
    z = 1,
    alt = "Preview",
}}}))

tty.image() validates PNG bytes asynchronously. image:read() explicitly exports the encoded bytes and image:close() releases the reference. Source pixel coordinates are zero-based; destination cell coordinates are one-based. Omitting images from a later present clears prior placements. Unsupported physical terminals display the placement's alt text, while virtual surfaces keep the image resource for viewers.

Canvas

A canvas is a bounded styled-cell buffer used to compose a frame before presenting it.

tty.canvas(width, height)

local canvas = tty.canvas(width, height)

Width is capped at 16384 columns, height at 16384 rows, and the area at 262,144 cells. Out-of-range arguments raise an argument error.

Returns: Canvas

Drawing accepts styled text, not terminal commands. SGR colors and OSC 8 links are preserved; erase, cursor-motion, and other control-only output is not emitted. Each placement is clipped independently at cell boundaries with grapheme-width awareness, so a clipped escape sequence cannot leak into neighboring content.

canvas:clear(fill?)

Clear every cell. An optional styled fill string is repeated across each row.

canvas:clear()
canvas:clear(tty.style():background("#1a1a1a"):render(" "))

Returns: boolean

canvas:put(x, y, text, width?)

Place one styled row at one-based x, y and clip it to width cells (default: the canvas width). Coordinates may be negative or past the edge; the placement is clipped rather than rejected. A newline ends the row, so use put_rows for multi-row content.

canvas:put(3, 1, tty.style():bold():render("Title"), 40)

Returns: boolean

canvas:put_rows(x, y, rows, width?)

Place an array of styled rows starting at x, y, one row per line downward. Every entry is validated before anything is drawn.

canvas:put_rows(2, 2, child_rows, inner_width)

Returns: boolean

canvas:rows()

Render the complete row array, ready for surface:present.

Returns: string[]

Viewport

A viewport is a virtual terminal port. The creating process is its first viewer; the process admitted with its grant is its producer.

tty.viewport(options?)

local view, err = tty.viewport({
    width = 80,
    height = 24,
    page = {foreground = "#e0def4", background = "#191724"},
})
Option Type Default Description
width number 80 Columns, 1 to 65535
height number 24 Rows, 1 to 65535
page table none Opaque #RRGGBB foreground and background defaults

The area is capped at 262,144 cells.

Returns: Viewport, error

tty.attach(handle)

Add another local viewer to an existing viewport. A handle grants viewing, never presentation ownership, and is not valid on another node.

local view, err = tty.attach(handle)

Returns: Viewport, error

viewport:grant()

Return the one-shot producer capability. Pass it as the terminal spawn option:

local grant = assert(view:grant())
local child = assert(process.with_options({terminal = grant})
    :spawn_monitored("app:child", "app:workers"))

Admission consumes the grant transactionally: a rejected start restores an unresolved grant, while a process that has resolved the port consumes it permanently. A host that does not support terminal attachments rejects the spawn instead of dropping the option. See Processes.

Returns: string, error

viewport:handle()

Return the local viewer handle for tty.attach.

Returns: string

viewport:snapshot(after_revision?)

Read the current dimensions, rows, cursor, and revision. With after_revision, return nil when the revision is unchanged.

local frame = view:snapshot(revision)
if frame then
    revision = frame.revision
    canvas:put_rows(2, 2, frame.rows, inner_width)
end

Returns: snapshot or nil

Field Type Description
revision number Monotonic revision of this frame
width number Viewport columns
height number Viewport rows
rows string[] Rows last published by the producer
cursor table {x, y, visible} in one-based coordinates, absent until the producer publishes explicit cursor state
images table[] Retained image placement metadata
layers table[] Ordered presentation layers
images_omitted boolean Image resources exist but are not retained by this plain snapshot

A page resolves terminal-default cells and omitted rows to explicit colors. The creator can change it with viewport:set_page(page); passing nil restores the producer's original rows. Page changes advance the revision without requiring the producer to repaint.

viewport:updates()

Return a channel of coalesced revision watermarks. receive() yields the revision number; case_receive() composes with channel.select.

local updates = assert(view:updates())

Updates are bounded hints, not an event log. A slow viewer receives only the newest watermark and must call snapshot() for state. Presentation and resize never block on a slow viewer.

Returns: ViewportUpdateChannel, error

viewport:send(event)

Forward a validated event record to the producer. The producer must have called tty.start(); otherwise the call fails rather than dropping the event.

assert(view:send(event))
assert(view:send({type = "close"}))

Returns: boolean, error

viewport:resize(width, height)

Update the viewport geometry. When the size changes, viewers get a new revision and the producer receives a resize event.

Returns: boolean, error

viewport:close()

Detach this viewer only. Closing the last viewer does not kill a live producer, and closing the producer's port does not destroy state while viewers remain.

Returns: boolean, error

viewport:mount(recipient_pid, rights)

Issue a process-bound reference for a local or remote viewer. Rights are independent and default to false:

local observation = assert(view:mount(agent_pid, {observe = true}))
local control = assert(view:mount(agent_pid, {input = true, resize = true}))

-- In the exact recipient process, on this node or an authenticated mesh peer:
local observer = assert(tty.attach(observation))
local controller = assert(tty.attach(control))

A mount is bound to the recipient's full PID and can be redeemed only once. Mounted viewers cannot create producer grants or delegate further mounts. Remote mounts use a renewable lease; reconnecting requires a fresh mount and cannot replay terminal input. Use viewport:revoke(reference) to revoke an issued mount. Closing the owner viewport or ending the owner process revokes its mounts.

viewport:capture()

Atomically pin a viewport revision and its retained image resources:

local capture = assert(view:capture())
local snapshot = capture:snapshot()
local image = assert(capture:image(snapshot.images[1].image_id))
assert(capture:close())

A plain snapshot() does not retain image bytes. A capture does until closed; image handles already acquired from it remain independently owned.

Event Types

Events are tables with a type field that determines which other fields are present. Coordinates are one-based. The same records are accepted by viewport:send().

Key Event

{
    type = "key",
    key = "a",           -- printable character or key name
    key_type = "runes",  -- "runes" for printable, or special key name
    action = "press",    -- "press" or "release"
    alt = false,
    ctrl = false,
    shift = false
}

Mouse Event

Requires tty.mouse(true).

{
    type = "mouse",
    action = "press",    -- "press", "release", "motion", "wheel"
    button = "left",     -- button name
    x = 10,
    y = 5,
    alt = false,
    ctrl = false,
    shift = false
}

Resize Event

{type = "resize", width = 120, height = 40}

Start Event

Emitted once after tty.start() with initial dimensions.

{type = "start", width = 120, height = 40}

Focus Event

Reports keyboard ownership.

{type = "focus", focused = true}

Visibility Event

Reports whether repainting is useful. It does not prescribe application lifecycle or background computation.

{type = "visibility", visible = true}

Paste Event

{type = "paste", text = "pasted content"}

Close Event

Asks the producer to shut down. A shell sends it through viewport:send to request a graceful child exit.

{type = "close"}

Key Bindings

Create reusable key bindings that match against key events:

local quit = tty.bind({
    keys = {"q", "ctrl+c"},
    help = {key = "q/ctrl+c", desc = "quit"}
})

-- In event loop
if quit:matches(ev) then
    break
end

tty.bind(config)

Field Type Description
keys string[] Required. Key patterns to match (e.g. "a", "ctrl+c", "enter")
help table Optional. {key = "...", desc = "..."} for help text

Returns: KeyBinding

The type schema requires keys. At runtime, an omitted or empty keys table creates a binding that never matches.

KeyBinding Methods

Method Returns Description
matches(event) boolean Test if a key event matches this binding
set_enabled(bool) self Enable or disable the binding
is_enabled() boolean Check if the binding is enabled
help() table Returns {key, desc} help info

Styles

Create styled terminal output. Style values are immutable, so each style method returns a new value.

local tty = require("tty")
local io = require("io")

local title = tty.style()
    :bold()
    :foreground("#FF0000")
    :padding(0, 1)

local box = tty.style()
    :border(tty.borders.ROUNDED)
    :border_foreground("#00FF00")
    :width(40)
    :padding(1, 2)

local _, print_err = io.print(box:render(title:render("Hello"), "World"))
if print_err then return nil, print_err end

tty.style()

Create an empty style.

Returns: Style

Style Methods

All methods return a new Style and can be chained.

Text Decoration

Method Parameter Description
foreground(color) string Text color (hex "#FF0000", ANSI "9", or name)
background(color) string Background color
bold(enable?) boolean Bold text (default: true)
italic(enable?) boolean Italic text
underline(enable?) boolean Underline text
strikethrough(enable?) boolean Strikethrough text
faint(enable?) boolean Dimmed text
blink(enable?) boolean Blinking text
reverse(enable?) boolean Swap foreground/background

Layout

Method Parameter Description
width(n) number Fixed width
height(n) number Fixed height
max_width(n) number Maximum width
max_height(n) number Maximum height
padding(...) numbers Padding (CSS-style: top, right, bottom, left)
margin(...) numbers Margin (CSS-style)
align(pos) number Horizontal alignment
align_vertical(pos) number Vertical alignment
inline(enable?) boolean Inline rendering mode

Borders

Method Parameter Description
border(name, ...) string, booleans Border style, optional per-side toggles
border_foreground(...) strings Border color(s)
border_background(...) strings Border background color(s)

Other

Method Description
render(...) Render strings with this style applied
copy() Create a copy of this style

Border Constants

tty.borders.NORMAL
tty.borders.ROUNDED
tty.borders.THICK
tty.borders.DOUBLE
tty.borders.HIDDEN

Alignment Constants

tty.align.LEFT    -- 0
tty.align.CENTER  -- 0.5
tty.align.RIGHT   -- 1

Text Utilities

The tty.text subtable provides layout and measurement functions for styled text.

Measurement

local w = tty.text.width("hello")         -- printable width (ANSI-aware)
local h = tty.text.height("a\nb\nc")      -- line count
local w, h = tty.text.size("hello\nworld") -- both

Clipping

-- Truncate to a printable width, with an optional tail
local head = tty.text.truncate(line, 40)
local head = tty.text.truncate(line, 40, "…")

-- Take the printable cell range [left, right)
local middle = tty.text.cut(line, 10, 30)

Both preserve ANSI state and grapheme boundaries, so styled text can be clipped and spliced without breaking escape sequences. truncate returns an empty string for a width of zero or less; cut returns an empty string when right is not greater than left.

Joining

-- Join side by side, aligned at top
local row = tty.text.join_horizontal(tty.text.position.TOP, left, right)

-- Stack vertically, centered
local col = tty.text.join_vertical(tty.text.position.CENTER, top, bottom)

Max Dimensions

local w = tty.text.max_width({"short", "a longer string"})   -- widest
local h = tty.text.max_height({"one\ntwo", "single"})         -- tallest

Placement

Place a string within a box with the given dimensions:

-- Center in a 80x24 box
local out = tty.text.place(80, 24, tty.text.position.CENTER, tty.text.position.CENTER, content)

-- Horizontal only
local out = tty.text.place_horizontal(80, tty.text.position.RIGHT, content)

-- Vertical only
local out = tty.text.place_vertical(24, tty.text.position.BOTTOM, content)

Position Constants

tty.text.position.TOP      -- 0
tty.text.position.LEFT     -- 0
tty.text.position.CENTER   -- 0.5
tty.text.position.BOTTOM   -- 1
tty.text.position.RIGHT    -- 1

Permissions

Access to a physical terminal comes from the process frame. Attaching a producer with process.with_options({terminal = grant}) requires process.context on the spawning side. Delegated viewports additionally check:

Action Resource Description
tty.mount Owner viewport handle Issue a process-bound mount
tty.observe Owner viewport handle Read snapshots, updates, and captures
tty.input Owner viewport handle Forward input events
tty.resize Owner viewport handle Resize the viewport

See Also