Terminal UI
Build a terminal application that owns the screen, draws styled frames, and hosts another process inside a bordered region of its own layout.
What We're Building
A shell process runs on a terminal host and takes the physical terminal's presentation lease. It paints a header, a status bar, and a border. Inside that border it hosts a second process, which runs an interactive Bash through a pseudo-terminal.
physical terminal -> shell surface -> viewport -> child process -> PTY proxy
The shell decides where the child appears and translates input into the child's coordinates. The child sees an ordinary terminal port and never learns that it is framed.
Project Structure
tty-app/
├── wippy.lock
└── src/
├── _index.yaml
├── shell.lua
└── child.lua
mkdir tty-app && cd tty-app
mkdir src
Step 1: Entry Definitions
Create src/_index.yaml:
version: "1.0"
namespace: app
entries:
- name: policy
kind: security.policy
policy:
actions:
- process.context
- process.spawn
- process.spawn.monitored
- process.host
- process.terminate
- exec.get
- exec.run
resources: "*"
effect: allow
# Runs the child process
- name: workers
kind: process.host
host:
workers: 2
lifecycle:
auto_start: true
# Owns the physical terminal
- name: terminal
kind: terminal.host
hide_logs: true
lifecycle:
auto_start: true
- name: exec
kind: exec.native
- name: child
kind: process.lua
source: file://child.lua
method: main
modules: [channel, exec, tty]
security:
policies: [app:policy]
- name: shell
kind: process.lua
source: file://shell.lua
method: main
modules: [channel, process, time, tty]
meta:
command:
name: shell
short: Run the terminal shell
security:
actor: {id: app:shell}
policies: [app:policy]
hide_logs: true redirects log output to the event bus instead of the terminal. A process that owns a surface publishes complete frames, so anything else writing to the same terminal corrupts them.
Step 2: The Input Loop
Create src/shell.lua. Subscribe to events first, then start input delivery, so no event arrives before there is a consumer:
local tty = require("tty")
local function main()
local events = assert(tty.events())
assert(tty.start())
assert(tty.mouse(true))
local width, height = tty.screen_size()
width = math.max(20, math.floor(width or 80))
height = math.max(8, math.floor(height or 24))
while true do
local ev = events:receive()
if not ev then break end
if ev.type == "resize" then
width, height = ev.width, ev.height
elseif ev.type == "key" and ev.ctrl and ev.key == "q" then
break
end
end
assert(tty.stop())
end
return {main = main}
Events are records discriminated by type. For a printable key, key_type is "runes" and key holds the text; for a named key, both key_type and key hold the name ("enter", "backspace", "up"). Coordinates in mouse events are one-based.
Step 3: Drawing Frames
A Surface is the terminal's presentation lease: it takes complete row arrays and diffs them against the last frame. A Canvas composes those rows from styled text without emitting terminal control sequences of its own.
Add styles and a padding helper at the top of shell.lua:
local tty = require("tty")
local header_style = tty.style():bold():foreground("#eceff4"):background("#5e81ac")
local status_style = tty.style():foreground("#a3be8c")
local prompt_style = tty.style():foreground("#88c0d0")
local function fit(text, width)
local clipped = tty.text.truncate(text, width)
return clipped .. string.rep(" ", math.max(0, width - tty.text.width(clipped)))
end
tty.text.truncate and tty.text.width are ANSI-aware, so styled text is measured and clipped by printable cells rather than bytes.
Now open a surface and publish a frame with a header, a scrollable body, a status bar, and an input line pinned to the last row:
local function main()
local events = assert(tty.events())
assert(tty.start())
assert(tty.mouse(true))
local surface = assert(tty.surface({
alternate_screen = true,
hide_cursor = true,
synchronized_output = true,
}))
local width, height = tty.screen_size()
width = math.max(20, math.floor(width or 80))
height = math.max(8, math.floor(height or 24))
local canvas = tty.canvas(width, height)
local lines, scroll, input = {}, 0, ""
local function draw()
local body_height = height - 3
canvas:clear()
canvas:put(1, 1, header_style:render(fit(" wippy tui — Ctrl+Q to quit ", width)))
local first = math.max(1, #lines - body_height + 1 - scroll)
for row = 1, body_height do
local line = lines[first + row - 1]
if line then
canvas:put(2, row + 1, line, width - 2)
end
end
canvas:put(1, height - 1, status_style:render(fit(
string.format(" %d lines scroll %d %dx%d", #lines, scroll, width, height), width)))
canvas:put(1, height, prompt_style:render("> ") .. input)
assert(surface:present(canvas:rows(), {
cursor = {x = math.min(width, 3 + tty.text.width(input)), y = height, visible = true},
}))
end
draw()
while true do
local ev = events:receive()
if not ev then break end
if ev.type == "resize" then
width = math.max(20, ev.width)
height = math.max(8, ev.height)
canvas = tty.canvas(width, height)
surface:invalidate()
elseif ev.type == "key" and ev.ctrl and ev.key == "q" then
break
elseif ev.type == "key" and ev.action == "press" then
if ev.key == "enter" then
lines[#lines + 1] = "> " .. input
input, scroll = "", 0
elseif ev.key == "backspace" then
input = input:sub(1, -2)
elseif ev.key_type == "runes" and not ev.ctrl and not ev.alt then
input = input .. ev.key
end
elseif ev.type == "mouse" and ev.action == "wheel" then
if ev.button == "wheel_up" then
scroll = scroll + 1
elseif ev.button == "wheel_down" then
scroll = math.max(0, scroll - 1)
end
end
draw()
end
assert(surface:close())
assert(tty.stop())
end
Each present publishes the whole frame; the backend writes only the rows that changed and reports rows, changed_rows, and bytes_written. invalidate() forgets that comparison state, which is what you want after the outer terminal resized under you.
Step 4: Hosting a Child
A Viewport is a virtual terminal port. The shell creates one, hands the child its grant, and reads back the frames the child presents.
Replace the body of the layout with a bordered region and put the viewport rows inside it:
local channel = require("channel")
local process = require("process")
local tty = require("tty")
local BODY_X, BODY_Y = 2, 3 -- top-left cell inside the border
local CHROME_ROWS = 5 -- header, two border rows, status, hint
local border_style = tty.style():foreground("#4c566a")
local hint_style = tty.style():faint()
Create the viewport, spawn the child with its grant, and subscribe to update watermarks:
local inner_width = math.max(1, width - 2)
local inner_height = math.max(1, height - CHROME_ROWS)
local viewport = assert(tty.viewport({width = inner_width, height = inner_height}))
local updates = assert(viewport:updates())
local child = assert(process.with_options({terminal = assert(viewport:grant())})
:spawn_monitored("app:child", "app:workers", "/bin/bash --noprofile --norc"))
The grant is one-shot. Admission consumes it: a rejected start leaves it unresolved, and a host that cannot attach terminals rejects the spawn instead of silently dropping the option.
Draw the border yourself and place the child's rows inside it with put_rows, which validates every row before drawing anything:
local function draw()
canvas:clear()
canvas:put(1, 1, header_style:render(fit(" wippy shell — Ctrl+Q to quit ", width)))
canvas:put(1, 2, border_style:render("┌" .. string.rep("─", inner_width) .. "┐"))
for row = 1, inner_height do
canvas:put(1, BODY_Y + row - 1, border_style:render("│"))
canvas:put(width, BODY_Y + row - 1, border_style:render("│"))
end
canvas:put_rows(BODY_X, BODY_Y, frame.rows, inner_width)
canvas:put(1, BODY_Y + inner_height,
border_style:render("└" .. string.rep("─", inner_width) .. "┘"))
canvas:put(1, height - 1, status_style:render(fit(" " .. status, width)))
canvas:put(1, height, hint_style:render(fit(
string.format(" child viewport %dx%d", inner_width, inner_height), width)))
local cursor = {x = 1, y = height, visible = false}
if frame.cursor then
cursor = {
x = math.min(width, BODY_X + frame.cursor.x - 1),
y = math.min(height, BODY_Y + frame.cursor.y - 1),
visible = frame.cursor.visible,
}
end
assert(surface:present(canvas:rows(), {cursor = cursor}))
end
The child publishes into the viewport; the shell learns about it through updates, then reads state with snapshot:
if selected.channel == updates then
local next_frame = viewport:snapshot(revision)
if next_frame then
frame, revision = next_frame, next_frame.revision
if #frame.rows > 0 then ready = true end
draw()
end
end
Updates are coalesced watermarks, not an event log: a slow shell gets only the newest one and must call snapshot() for the actual rows. Passing the last revision makes snapshot return nil when nothing changed. A new revision does not mean the child has drawn: viewport:resize bumps it as well, and until the first frame the snapshot carries no rows. That is why ready keys on rows rather than on the revision.
Input goes the other way through viewport:send. Key events pass through unchanged; mouse coordinates have to be moved into the child's one-based space, and events outside the region are dropped:
local function translate(event)
if event.type ~= "mouse" then
return event
end
local x, y = event.x - BODY_X + 1, event.y - BODY_Y + 1
if x < 1 or y < 1 or x > inner_width or y > inner_height then
return nil
end
return {
type = "mouse", action = event.action, button = event.button,
x = x, y = y, alt = event.alt, ctrl = event.ctrl, shift = event.shift,
}
end
send requires the producer to have called tty.start(), so the shell waits for the first frame before forwarding anything. That is what the ready flag tracks.
Step 5: The Child
Create src/child.lua. The child receives an ordinary terminal port, so it uses the same tty module — but instead of drawing itself, it hands its port to a PTY-backed process.
local channel = require("channel")
local exec = require("exec")
local tty = require("tty")
local function main(command)
local events = assert(tty.events())
assert(tty.start())
local executor = assert(exec.get("app:exec"))
local proc = assert(executor:exec(command or "/bin/bash --noprofile --norc", {
pty = {term = "xterm-256color"},
}))
local session = assert(proc:attach_terminal())
local done = session:done()
while true do
local selected = channel.select({
events:case_receive(),
done:case_receive(),
})
if not selected.ok or selected.channel == done then break end
local event = selected.value
if event.type == "close" then break end
assert(session:send(event))
end
assert(session:close())
assert(executor:release())
assert(tty.stop())
end
return {main = main}
attach_terminal() consumes the unstarted PTY process and returns a TerminalSession that owns it: PTY emulation, input encoding, resize, termination, and reaping. The session opens the surface on whichever port the child holds, so the same code works whether the child runs on a terminal host or inside a viewport.
Everything the child forwards — keys, mouse, paste, focus, and the resize events the shell generates — becomes terminal input for Bash. A close event is the shell asking for a graceful exit.
Step 6: Resize, Shutdown, and Cleanup
A resize of the outer terminal changes three things: the shell's own geometry, the viewport's geometry, and the backend's idea of what is already on screen.
if event.type == "resize" then
width = math.max(20, math.floor(event.width))
height = math.max(8, math.floor(event.height))
inner_width = math.max(1, width - 2)
inner_height = math.max(1, height - CHROME_ROWS)
canvas = tty.canvas(width, height)
assert(viewport:resize(inner_width, inner_height))
surface:invalidate()
draw()
end
viewport:resize bumps the revision for viewers and delivers a resize event to the child, which forwards it to its terminal session, which resizes the PTY. One shell-side call reaches all the way down.
Ctrl+Q asks the child to stop and arms a deadline, so an unresponsive child cannot hang the shell:
elseif event.type == "key" and event.ctrl and event.key == "q" then
if not closing then
closing = true
status = "closing child"
if ready then
assert(viewport:send({type = "close"}))
else
assert(process.terminate(child))
end
deadline = time.after("3s")
draw()
end
The loop watches lifecycle events for the child's exit, and the deadline channel for the case where it never comes:
elseif selected.channel == lifecycle then
local event = selected.value
if event.kind == process.event.EXIT and event.from == child then break end
elseif deadline and selected.channel == deadline then
assert(process.terminate(child))
deadline = nil
Tear down inside out: detach the viewer, release the presentation lease, then stop input.
assert(viewport:close())
assert(surface:close())
assert(tty.stop())
Closing a viewport detaches only that viewer; it never kills the producer. Closing the surface restores the terminal modes it acquired — the alternate screen and the cursor.
Complete Shell
src/shell.lua:
local channel = require("channel")
local process = require("process")
local time = require("time")
local tty = require("tty")
local BODY_X, BODY_Y = 2, 3
local CHROME_ROWS = 5
local header_style = tty.style():bold():foreground("#eceff4"):background("#5e81ac")
local border_style = tty.style():foreground("#4c566a")
local status_style = tty.style():foreground("#a3be8c")
local hint_style = tty.style():faint()
local function fit(text, width)
local clipped = tty.text.truncate(text, width)
return clipped .. string.rep(" ", math.max(0, width - tty.text.width(clipped)))
end
local function main()
local events = assert(tty.events())
local lifecycle = assert(process.events())
assert(tty.start())
assert(tty.mouse(true))
local surface = assert(tty.surface({
alternate_screen = true,
hide_cursor = true,
synchronized_output = true,
}))
local width, height = tty.screen_size()
width = math.max(20, math.floor(width or 80))
height = math.max(8, math.floor(height or 24))
local inner_width = math.max(1, width - 2)
local inner_height = math.max(1, height - CHROME_ROWS)
local canvas = tty.canvas(width, height)
local viewport = assert(tty.viewport({width = inner_width, height = inner_height}))
local updates = assert(viewport:updates())
local child = assert(process.with_options({terminal = assert(viewport:grant())})
:spawn_monitored("app:child", "app:workers", "/bin/bash --noprofile --norc"))
local frame = {rows = {}}
local revision = -1
local ready, closing = false, false
local status = "starting child"
local deadline
local function draw()
canvas:clear()
canvas:put(1, 1, header_style:render(fit(" wippy shell — Ctrl+Q to quit ", width)))
canvas:put(1, 2, border_style:render("┌" .. string.rep("─", inner_width) .. "┐"))
for row = 1, inner_height do
canvas:put(1, BODY_Y + row - 1, border_style:render("│"))
canvas:put(width, BODY_Y + row - 1, border_style:render("│"))
end
canvas:put_rows(BODY_X, BODY_Y, frame.rows, inner_width)
canvas:put(1, BODY_Y + inner_height,
border_style:render("└" .. string.rep("─", inner_width) .. "┘"))
canvas:put(1, height - 1, status_style:render(fit(" " .. status, width)))
canvas:put(1, height, hint_style:render(fit(
string.format(" child viewport %dx%d", inner_width, inner_height), width)))
local cursor = {x = 1, y = height, visible = false}
if frame.cursor then
cursor = {
x = math.min(width, BODY_X + frame.cursor.x - 1),
y = math.min(height, BODY_Y + frame.cursor.y - 1),
visible = frame.cursor.visible,
}
end
assert(surface:present(canvas:rows(), {cursor = cursor}))
end
local function translate(event)
if event.type ~= "mouse" then
return event
end
local x, y = event.x - BODY_X + 1, event.y - BODY_Y + 1
if x < 1 or y < 1 or x > inner_width or y > inner_height then
return nil
end
return {
type = "mouse", action = event.action, button = event.button,
x = x, y = y, alt = event.alt, ctrl = event.ctrl, shift = event.shift,
}
end
draw()
while true do
local cases = {
events:case_receive(),
lifecycle:case_receive(),
updates:case_receive(),
}
if deadline then
cases[#cases + 1] = deadline:case_receive()
end
local selected = channel.select(cases)
if not selected.ok then break end
if selected.channel == updates then
local next_frame = viewport:snapshot(revision)
if next_frame then
frame, revision = next_frame, next_frame.revision
if #frame.rows > 0 then ready = true end
if not closing then
status = "child running"
end
draw()
end
elseif selected.channel == lifecycle then
local event = selected.value
if event.kind == process.event.EXIT and event.from == child then break end
elseif deadline and selected.channel == deadline then
assert(process.terminate(child))
deadline = nil
else
local event = selected.value
if event.type == "resize" then
width = math.max(20, math.floor(event.width))
height = math.max(8, math.floor(event.height))
inner_width = math.max(1, width - 2)
inner_height = math.max(1, height - CHROME_ROWS)
canvas = tty.canvas(width, height)
assert(viewport:resize(inner_width, inner_height))
surface:invalidate()
draw()
elseif event.type == "key" and event.ctrl and event.key == "q" then
if not closing then
closing = true
status = "closing child"
if ready then
assert(viewport:send({type = "close"}))
else
assert(process.terminate(child))
end
deadline = time.after("3s")
draw()
end
elseif not closing and ready and event.type ~= "start" then
local forwarded = translate(event)
if forwarded then
assert(viewport:send(forwarded))
end
end
end
end
assert(viewport:close())
assert(surface:close())
assert(tty.stop())
end
return {main = main}
Run It
wippy init
wippy run shell
Type in the framed Bash normally — arrow keys, tab completion, and full-screen programs such as htop or vim all work, because the child is talking to a real PTY. Resize the terminal window and the border, the status bar, and the child's geometry follow. Press Ctrl+Q to close the child and restore the terminal.
Where to Go Next
- Create a second viewport and split the body between two children, forwarding input only to the focused one.
- Call
viewport:handle()and pass the handle to another process, which attaches withtty.attach(handle)and renders the same child in its own layout. - Replace the Bash child with a Lua process that draws its own surface: the shell does not change, because the viewport is the only contract between them.
See Also
- TTY — events, surfaces, canvases, viewports, styles, and text utilities
- Command Execution — PTY options,
attach_terminal, and terminal sessions - Terminal — terminal host configuration and the composable terminal model
- Processes — spawn options, monitoring, and lifecycle events
- CLI Applications — line-oriented terminal programs