# "TTY"
_Path: en/lua/system/tty_
> "Terminal input events, styled output, presentation surfaces, and local virtual viewports."
## Table of Contents
- TTY
## Content
# 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
```lua
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:
```lua
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.
### `tty.start()`
Start input delivery for the current port. A physical terminal switches to raw mode.
```lua
local ok, err = tty.start()
```
**Returns:** `boolean, error`
### `tty.stop()`
Stop input delivery and restore the terminal to normal mode.
```lua
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.
```lua
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.
```lua
local width, height, err = tty.screen_size()
```
**Returns:** `number, number, error`
### `tty.mouse(enable)`
Enable or disable mouse event tracking.
```lua
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?)
```lua
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.
```lua
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:
```lua
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)
```lua
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.
```lua
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.
```lua
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.
```lua
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?)
```lua
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.
```lua
local view, err = tty.attach(handle)
```
**Returns:** `Viewport, error`
### viewport:grant()
Return the one-shot producer capability. Pass it as the `terminal` spawn option:
```lua
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](lua/core/process.md#spawner-with-options).
**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.
```lua
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`.
```lua
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.
```lua
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:
```lua
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:
```lua
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
```lua
{
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)`.
```lua
{
type = "mouse",
action = "press", -- "press", "release", "motion", "wheel"
button = "left", -- button name
x = 10,
y = 5,
alt = false,
ctrl = false,
shift = false
}
```
### Resize Event
```lua
{type = "resize", width = 120, height = 40}
```
### Start Event
Emitted once after `tty.start()` with initial dimensions.
```lua
{type = "start", width = 120, height = 40}
```
### Focus Event
Reports keyboard ownership.
```lua
{type = "focus", focused = true}
```
### Visibility Event
Reports whether repainting is useful. It does not prescribe application lifecycle or background computation.
```lua
{type = "visibility", visible = true}
```
### Paste Event
```lua
{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.
```lua
{type = "close"}
```
## Key Bindings
Create reusable key bindings that match against key events:
```lua
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.
```lua
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
```lua
tty.borders.NORMAL
tty.borders.ROUNDED
tty.borders.THICK
tty.borders.DOUBLE
tty.borders.HIDDEN
```
### Alignment Constants
```lua
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
```lua
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
```lua
-- 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
```lua
-- 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
```lua
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:
```lua
-- 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
```lua
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](tutorials/tty.md) — build a shell that hosts a child in a viewport
- [Terminal I/O](lua/system/io.md) — stdin/stdout/stderr operations
- [Terminal Host](system/terminal.md) — Terminal host configuration
- [Command Execution](lua/dynamic/exec.md) — PTY processes and terminal sessions
- [Processes](lua/core/process.md) — spawn options, monitoring, lifecycle events
## Navigation
Previous: "OS Time" (lua/system/ostime)
Next: "Hub" (lua/system/hub)