TTY
Terminal input events, styled output, presentation surfaces, and local virtual viewports.
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
- Terminal UI — build a shell that hosts a child in a viewport
- Terminal I/O — stdin/stdout/stderr operations
- Terminal Host — Terminal host configuration
- Command Execution — PTY processes and terminal sessions
- Processes — spawn options, monitoring, lifecycle events