# "Terminal UI" _Path: en/tutorials/tty_ > "Build a terminal shell that draws its own chrome and hosts a child process inside a viewport." ## Table of Contents - Terminal UI ## Content # 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. ```text 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 ``` ```bash mkdir tty-app && cd tty-app mkdir src ``` ## Step 1: Entry Definitions Create `src/_index.yaml`: ```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: ```lua 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"`, `"space"`, `"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`: ```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: ```lua 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 == "space" and not ev.ctrl and not ev.alt then input = input .. " " 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: ```lua 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: ```lua 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: ```lua 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`: ```lua 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: ```lua 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. ```lua 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. ```lua 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: ```lua 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: ```lua 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. ```lua 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`: ```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 ```bash 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 with `tty.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](lua/system/tty.md) — events, surfaces, canvases, viewports, styles, and text utilities - [Command Execution](lua/dynamic/exec.md) — PTY options, `attach_terminal`, and terminal sessions - [Terminal](system/terminal.md) — terminal host configuration and the composable terminal model - [Processes](lua/core/process.md) — spawn options, monitoring, and lifecycle events - [CLI Applications](tutorials/cli.md) — line-oriented terminal programs ## Navigation Previous: "CLI Applications" (tutorials/cli) Next: "Echo Service" (tutorials/echo-service)